feat(crm): frontend code — pages + services + components

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.
This commit is contained in:
Luca
2026-05-26 18:19:32 +02:00
parent d543949188
commit a7e16e7bf6
96 changed files with 18370 additions and 572 deletions
+87 -34
View File
@@ -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",
+6
View File
@@ -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"
+97 -8
View File
@@ -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() {
<Route path="accounts" element={<CustomerManagementPage />} />
<Route path="accounts/:id" element={<CustomerDetailPage />} />
</Route>
{/* Quotes (CRM) — gated by `quotes`. */}
<Route element={<RequireFeature flag="quotes" />}>
<Route path="quotes" element={<QuotesListPage />} />
<Route path="quotes/new" element={<QuoteEditorPage />} />
<Route path="quotes/:id" element={<QuoteDetailPage />} />
<Route path="quotes/:id/edit" element={<QuoteEditorPage />} />
</Route>
{/* Bills / invoices (CRM) — gated by `bills`. */}
<Route element={<RequireFeature flag="bills" />}>
<Route path="bills" element={<BillsListPage />} />
<Route path="bills/new" element={<BillEditorPage />} />
<Route path="bills/:id" element={<BillDetailPage />} />
<Route path="bills/:id/edit" element={<BillEditorPage />} />
</Route>
{/* Contracts (CRM) — gated by `contracts`. Independent
of quotes/bills; a free-standing legal document
type composed from a library of reusable blocks. */}
<Route element={<RequireFeature flag="contracts" />}>
<Route path="contracts" element={<ContractsListPage />} />
<Route path="contracts/new" element={<ContractEditorPage />} />
<Route path="contracts/blocks" element={<BlockLibraryPage />} />
<Route path="contracts/:id" element={<ContractDetailPage />} />
<Route path="contracts/:id/edit" element={<ContractEditorPage />} />
</Route>
{/* Hour logging (standalone surface) — gated by
`hoursLogging`. Independent of `bills` so admin
can log hours before the full billing surface
is enabled. */}
<Route element={<RequireFeature flag="hoursLogging" />}>
<Route path="hours" element={<HoursLoggingPage />} />
</Route>
{/* Admin calendar (migration 137) — gated by
`calendar`. Lazy-loaded so the FullCalendar
bundle stays out of the main chunk. */}
<Route element={<RequireFeature flag="calendar" />}>
<Route
path="calendar"
element={
<Suspense fallback={<Loading />}>
<CalendarPage />
</Suspense>
}
/>
</Route>
{/* Tax / Steuer report — gated by `taxReport`. */}
<Route element={<RequireFeature flag="taxReport" />}>
<Route path="tax-report" element={<TaxReportPage />} />
</Route>
{/* Developer tools — gated by `crmDevelopment`. */}
<Route element={<RequireFeature flag="crmDevelopment" />}>
<Route path="development" element={<CrmDevelopmentPage />} />
</Route>
{/* 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". */}
<Route index element={<Navigate to="/admin/clients/accounts" replace />} />
</Route>
</Route>
@@ -209,6 +286,17 @@ function App() {
{/* Public invitation acceptance page */}
<Route path="/invite/:token" element={<AcceptInvitePage />} />
{/* Public quote accept/decline page (CRM). Token-only,
no auth required. */}
<Route path="/quote/:token" element={<QuoteResponsePage />} />
<Route path="/contract/:token" element={<ContractResponsePage />} />
{/* Admin payment-check page (CRM) — token only,
no auth. Reached from the "Paid in full /
Partial / Not paid" buttons in the payment-
check email. */}
<Route path="/payment-check/:token" element={<PaymentCheckPage />} />
{/* 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() {
<Route path="dashboard" element={<CustomerDashboardPage />} />
<Route path="calendar" element={<CustomerCalendarPage />} />
<Route path="quotes" element={<CustomerQuotesPage />} />
<Route path="contracts" element={<CustomerContractsPage />} />
<Route path="bills" element={<CustomerBillsPage />} />
<Route path="profile" element={<CustomerProfilePage />} />
</Route>
@@ -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<AdminGuestDetailProps> = ({ eventId, guest, onClose }) => {
const { t } = useTranslation();
const { formatDateTime: fmtDateTime } = useLocalizedDate();
const [tab, setTab] = useState<Tab>('all');
const { data, isLoading } = useQuery({
@@ -159,7 +161,7 @@ export const AdminGuestDetail: React.FC<AdminGuestDetailProps> = ({ eventId, gue
/>
<div className="flex-1">
<div className="text-xs text-neutral-500 dark:text-neutral-400">
{c.photo.filename} · {new Date(c.created_at).toLocaleString()}
{c.photo.filename} · {fmtDateTime(c.created_at)}
</div>
<p className="text-sm text-neutral-900 dark:text-neutral-100 mt-1">{c.comment}</p>
</div>
@@ -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<AdminGuestsListProps> = ({ eventId, eventName }) => {
const { t } = useTranslation();
const { format: fmtDate } = useLocalizedDate();
const queryClient = useQueryClient();
const [view, setView] = useState<View>('list');
const [selectedGuest, setSelectedGuest] = useState<AdminGuest | null>(null);
@@ -271,7 +273,7 @@ export const AdminGuestsList: React.FC<AdminGuestsListProps> = ({ eventId, event
{guest.stats.ratings}
</td>
<td className="px-4 py-3 text-sm text-neutral-600 dark:text-neutral-400">
{new Date(guest.last_seen_at).toLocaleDateString()}
{fmtDate(guest.last_seen_at)}
</td>
<td className="px-4 py-3 text-right">
<div className="flex items-center justify-end gap-1">
+92 -17
View File
@@ -35,10 +35,30 @@ export const AdminHeader: React.FC<AdminHeaderProps> = ({ 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 = () => (
<div className="flex items-center gap-2">
{!logoInSidebar && (logoDisplayMode === 'logo_only' || logoDisplayMode === 'logo_and_text') && (
<img src={resolvedLogoUrl} alt={companyName} className="h-8 w-auto object-contain" />
)}
{(logoDisplayMode === 'text_only' || logoDisplayMode === 'logo_and_text') && (
<span className="text-xl sm:text-2xl" style={{ fontFamily: 'Poppins, sans-serif', fontWeight: 600, color: '#145346' }}>{companyName}</span>
)}
</div>
);
const userMenuRef = useRef<HTMLDivElement>(null);
const notificationRef = useRef<HTMLDivElement>(null);
@@ -79,10 +99,25 @@ export const AdminHeader: React.FC<AdminHeaderProps> = ({ onMenuClick }) => {
const unreadCount = notificationsData?.unreadCount || 0;
return (
<header className="sticky top-0 z-30 bg-white dark:bg-neutral-900 border-b border-neutral-200 dark:border-neutral-700">
<div className="px-4 sm:px-6 lg:px-8">
<div className="flex items-center justify-between h-16">
{/* Left side - Menu button, Logo, and Date */}
// border-b is on the OUTER <header> 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.
<header className="sticky top-0 z-30 bg-white dark:bg-neutral-900 h-16 border-b border-neutral-200 dark:border-neutral-700">
<div className="px-4 sm:px-6 lg:px-8 h-full">
<div className="relative flex items-center justify-between h-full gap-3">
{/* 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. */}
<div className="flex items-center gap-3 min-w-0">
<button
onClick={onMenuClick}
@@ -91,26 +126,66 @@ export const AdminHeader: React.FC<AdminHeaderProps> = ({ onMenuClick }) => {
<Menu className="w-6 h-6" />
</button>
{/* Logo - sticky to the left on all sizes */}
<div className="flex items-center gap-2">
{(logoDisplayMode === 'logo_only' || logoDisplayMode === 'logo_and_text') && (
<img src={resolvedLogoUrl} alt={companyName} className="h-8 w-auto object-contain" />
)}
{(logoDisplayMode === 'text_only' || logoDisplayMode === 'logo_and_text') && (
<span className="text-xl sm:text-2xl" style={{ fontFamily: 'Poppins, sans-serif', fontWeight: 600, color: '#145346' }}>{companyName}</span>
)}
</div>
{!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' && (
<div className="flex lg:hidden">{renderBrandBlock()}</div>
)}
{!logoInSidebar && logoPosition === 'right' && (
<div className="flex md:hidden">{renderBrandBlock()}</div>
)}
{/* Date display - hidden on smaller screens */}
<div className="hidden xl:block pl-3 border-l border-neutral-200 dark:border-neutral-700 ml-1">
<p className="text-base text-neutral-700 dark:text-neutral-300">
{/* 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 <p> inheriting its block-level vertical position,
which read as slightly off-centre next to the larger
logo image. */}
<div className={`hidden xl:flex items-center self-stretch ml-1 ${
logoPosition === 'left' && !logoInSidebar
? 'pl-3 border-l border-neutral-200 dark:border-neutral-700'
: ''
}`}>
<p className="text-base leading-none text-neutral-700 dark:text-neutral-300 m-0">
{format(new Date(), 'PPPP')}
</p>
</div>
</div>
{/* 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' && (
<div className="hidden lg:flex absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 pointer-events-none">
{renderBrandBlock()}
</div>
)}
{/* 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. */}
<div className="flex items-center gap-3">
{!logoInSidebar && logoPosition === 'right' && (
<div className="hidden md:flex mr-1 pr-2 border-r border-neutral-200 dark:border-neutral-700">
{renderBrandBlock()}
</div>
)}
{/* Language Selector */}
<LanguageSelector />
+44 -8
View File
@@ -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<boolean>(() => {
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 = () => {
<AdminLayoutInner
sidebarOpen={sidebarOpen}
setSidebarOpen={setSidebarOpen}
sidebarCollapsed={sidebarCollapsed}
setSidebarCollapsed={setSidebarCollapsed}
mustChangePassword={mustChangePassword}
/>
</FeatureFlagsProvider>
@@ -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<AdminLayoutInnerProps> = ({ sidebarOpen, setSidebarOpen, mustChangePassword }) => {
const AdminLayoutInner: React.FC<AdminLayoutInnerProps> = ({ sidebarOpen, setSidebarOpen, sidebarCollapsed, setSidebarCollapsed, mustChangePassword }) => {
return (
<div className="h-screen bg-neutral-50 dark:bg-neutral-950 flex overflow-hidden">
{/* Mandatory Password Change Modal */}
@@ -68,21 +85,40 @@ const AdminLayoutInner: React.FC<AdminLayoutInnerProps> = ({ sidebarOpen, setSid
{/* Sidebar - disabled when password change required */}
<div className={mustChangePassword ? 'pointer-events-none opacity-50' : ''}>
<AdminSidebar isOpen={sidebarOpen} onClose={() => setSidebarOpen(false)} />
<AdminSidebar
isOpen={sidebarOpen}
onClose={() => setSidebarOpen(false)}
collapsed={sidebarCollapsed}
onToggleCollapse={() => setSidebarCollapsed(!sidebarCollapsed)}
/>
</div>
{/* Main content */}
<div className="flex-1 flex flex-col min-w-0 h-screen">
{/* 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
<main>'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. */}
<div
className="flex-1 flex flex-col min-w-0 h-screen overflow-y-auto"
style={{ scrollbarGutter: 'stable' }}
>
{/* Header - disabled when password change required */}
<div className={mustChangePassword ? 'pointer-events-none opacity-50' : ''}>
<AdminHeader onMenuClick={() => setSidebarOpen(true)} />
</div>
{/* Maintenance mode banner */}
<MaintenanceBanner />
{/* Page content - disabled when password change required */}
<main id="main-content" className={`flex-1 px-4 sm:px-6 lg:px-8 py-8 overflow-y-auto ${mustChangePassword ? 'opacity-50 pointer-events-none' : ''}`}>
{/* 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. */}
<main id="main-content" className={`flex-1 px-4 sm:px-6 lg:px-8 py-8 ${mustChangePassword ? 'opacity-50 pointer-events-none' : ''}`}>
<Outlet />
</main>
</div>
+142 -18
View File
@@ -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<AdminSidebarProps> = ({ isOpen, onClose }) => {
export const AdminSidebar: React.FC<AdminSidebarProps> = ({ 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<AdminSidebarProps> = ({ 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 (
<div
className={`fixed inset-y-0 left-0 z-50 w-64 bg-white dark:bg-neutral-900 border-r border-neutral-200 dark:border-neutral-700 transform transition-transform duration-200 ease-in-out lg:relative lg:translate-x-0 lg:h-screen ${
// Right edge drawn via box-shadow rather than `border-r` so the
// brand row's `border-b` can extend to the sidebar's full width
// and meet the header's `border-b` cleanly at the L-junction. A
// 1px border-r would shrink the brand row's content by 1px and
// leave a visible step in the horizontal divider where the
// sidebar meets the main column. Shadow uses the same neutral
// border colors so it looks identical to the previous border.
className={`fixed inset-y-0 left-0 z-50 ${widthClasses} bg-white dark:bg-neutral-900 shadow-[1px_0_0_0_theme(colors.neutral.200)] dark:shadow-[1px_0_0_0_theme(colors.neutral.700)] transform transition-all duration-200 ease-in-out lg:relative lg:translate-x-0 lg:h-screen ${
isOpen ? 'translate-x-0' : '-translate-x-full'
}`}
>
<div className="flex flex-col h-screen lg:h-full">
{/* Brand */}
<div className="flex items-center justify-between h-16 px-6 border-b border-neutral-200 dark:border-neutral-700 flex-shrink-0">
<div className="flex items-center">
<span className="text-xl font-bold text-neutral-900 dark:text-neutral-100">{t('admin.title')}</span>
{/* 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). */}
<div className={`flex items-center h-16 border-b border-neutral-200 dark:border-neutral-700 flex-shrink-0 ${
collapsed ? 'lg:justify-center lg:px-2 px-6 justify-between' : 'justify-between px-6'
}`}>
<div className="flex items-center gap-2 min-w-0">
{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. */}
<img
src={sidebarBrandImageUrl!}
alt={brandAlt}
className={collapsed ? 'h-8 w-8 object-contain lg:h-9 lg:w-9' : 'h-8 w-auto object-contain max-w-full'}
/>
{/* 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 && (
<span className="text-xl font-bold text-neutral-900 dark:text-neutral-100 lg:hidden truncate">
{brandAlt}
</span>
)}
</>
) : (
<>
{showLabels && (
<span className="text-xl font-bold text-neutral-900 dark:text-neutral-100">{t('admin.title')}</span>
)}
{/* When collapsed on desktop the title is hidden; on mobile we
always show it because the rail-narrow style only applies at lg+ */}
{collapsed && (
<span className="text-xl font-bold text-neutral-900 dark:text-neutral-100 lg:hidden">{t('admin.title')}</span>
)}
</>
)}
</div>
<button
onClick={onClose}
className="lg:hidden text-neutral-400 hover:text-neutral-600"
aria-label="Close sidebar"
>
<X className="w-6 h-6" />
</button>
</div>
{/* Navigation */}
<nav className="flex-1 px-4 py-4 space-y-1 overflow-y-auto min-h-0">
<nav className={`flex-1 py-4 space-y-1 overflow-y-auto overflow-x-hidden min-h-0 ${
collapsed ? 'px-4 lg:px-2' : 'px-4'
}`}>
{filteredNavigation.map((item) => {
const isActive = location.pathname === item.href ||
const isActive = location.pathname === item.href ||
(item.href !== '/admin/dashboard' && location.pathname.startsWith(item.href));
const label = t(item.nameKey);
return (
<NavLink
key={item.nameKey}
to={item.href}
onClick={() => onClose()}
className={`flex items-center px-3 py-2 text-sm font-medium rounded-lg transition-colors ${
title={collapsed ? label : undefined}
className={`flex items-center py-2 text-sm font-medium rounded-lg transition-colors ${
collapsed ? 'px-3 lg:px-0 lg:justify-center' : 'px-3'
} ${
isActive
? 'bg-accent-dark text-white'
: 'text-neutral-700 dark:text-neutral-300 hover:bg-neutral-100 dark:hover:bg-neutral-800 hover:text-neutral-900 dark:hover:text-neutral-100'
@@ -143,18 +239,46 @@ export const AdminSidebar: React.FC<AdminSidebarProps> = ({ isOpen, onClose }) =
.tile-selected pattern used in the customizer. The accent
-dark token defaults to the legacy primary green so users
who haven't set CI colours yet see no migration regression. */}
<item.icon className={`w-5 h-5 mr-3 ${
<item.icon className={`w-5 h-5 flex-shrink-0 ${
collapsed ? 'mr-3 lg:mr-0' : 'mr-3'
} ${
isActive ? 'text-white' : 'text-neutral-400'
}`} />
{t(item.nameKey)}
<span className={collapsed ? 'lg:hidden' : ''}>{label}</span>
</NavLink>
);
})}
</nav>
{/* Bottom section - sticky to bottom (only for users with settings.view permission) */}
{/* Desktop collapse / expand toggle.
Lives directly above the version + storage widgets — sits
in admins' muscle-memory zone for chrome controls and
stays visible even when the sidebar is collapsed so the
rail can always be re-expanded. Hidden on mobile (the X
in the brand row already closes the sheet there). */}
{onToggleCollapse && (
<div className={`hidden lg:flex flex-shrink-0 border-t border-neutral-200 dark:border-neutral-700 py-2 ${
collapsed ? 'justify-center px-2' : 'justify-end px-4'
}`}>
<button
type="button"
onClick={onToggleCollapse}
className="inline-flex items-center justify-center w-9 h-9 rounded-md text-neutral-500 hover:text-neutral-900 dark:hover:text-neutral-100 hover:bg-neutral-100 dark:hover:bg-neutral-800 transition-colors"
aria-label={collapsed ? t('admin.expandSidebar', 'Expand sidebar') : t('admin.collapseSidebar', 'Collapse sidebar')}
title={collapsed ? t('admin.expandSidebar', 'Expand sidebar') : t('admin.collapseSidebar', 'Collapse sidebar')}
>
{collapsed
? <PanelLeftOpen className="w-5 h-5" />
: <PanelLeftClose className="w-5 h-5" />}
</button>
</div>
)}
{/* Bottom section - sticky to bottom (only for users with settings.view permission).
Hidden on desktop when collapsed since these widgets don't fit in the icon rail;
mobile keeps them visible because mobile width is always w-64. */}
{hasPermission('settings.view') && (
<div className="flex-shrink-0">
<div className={`flex-shrink-0 ${collapsed ? 'lg:hidden' : ''}`}>
{/* Version Info */}
<VersionInfo />
+60 -15
View File
@@ -14,7 +14,7 @@
import React from 'react';
import { NavLink, Outlet, useLocation, useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { Briefcase, UserCog } from 'lucide-react';
import { Briefcase, UserCog, FileText, Receipt, Wrench, Calculator, Clock, ScrollText, Calendar } from 'lucide-react';
import type { LucideIcon } from 'lucide-react';
import { useFeatureFlags, type FeatureKey } from '../../contexts/FeatureFlagsContext';
@@ -46,12 +46,57 @@ export const ClientsLayout: React.FC = () => {
icon: UserCog,
featureFlag: 'customerPortal',
},
// Add future sub-features here as they ship:
// { key: 'calendar', to: '/admin/clients/calendar', ... featureFlag: 'calendar' }
// { key: 'quotes', to: '/admin/clients/quotes', ... featureFlag: 'quotes' }
// { key: 'bills', to: '/admin/clients/bills', ... featureFlag: 'bills' }
// etc. The empty-state below disappears automatically once any of
// these is enabled.
{
key: 'calendar',
to: '/admin/clients/calendar',
label: t('clients.subnav.calendar', 'Calendar'),
icon: Calendar,
featureFlag: 'calendar',
},
{
key: 'quotes',
to: '/admin/clients/quotes',
label: t('clients.subnav.quotes', 'Quotes'),
icon: FileText,
featureFlag: 'quotes',
},
{
key: 'contracts',
to: '/admin/clients/contracts',
label: t('clients.subnav.contracts', 'Contracts'),
icon: ScrollText,
featureFlag: 'contracts',
},
{
key: 'hours',
to: '/admin/clients/hours',
label: t('clients.subnav.hours', 'Hours'),
icon: Clock,
featureFlag: 'hoursLogging',
},
{
key: 'bills',
to: '/admin/clients/bills',
label: t('clients.subnav.bills', 'Invoices'),
icon: Receipt,
featureFlag: 'bills',
},
{
key: 'tax-report',
to: '/admin/clients/tax-report',
label: t('clients.subnav.taxReport', 'Tax'),
icon: Calculator,
featureFlag: 'taxReport',
},
// Future sub-features:
// { key: 'messaging', ... featureFlag: 'messaging' }
{
key: 'development',
to: '/admin/clients/development',
label: t('clients.subnav.development', 'Development'),
icon: Wrench,
featureFlag: 'crmDevelopment',
},
];
const enabledItems = navItems.filter((item) => flags[item.featureFlag]);
@@ -64,22 +109,22 @@ export const ClientsLayout: React.FC = () => {
<div>
<div className="mb-6">
<h1 className="text-2xl font-bold text-neutral-900 dark:text-neutral-100">
{t('clients.title', 'Clients')}
{t('clients.title', 'CRM')}
</h1>
<p className="text-neutral-600 dark:text-neutral-400 mt-1">
{t('clients.subtitle', 'Customer accounts, scheduling, quotes and billing for recurring clients.')}
{t('clients.subtitle', 'Customer accounts, scheduling, quotes and billing — everything for recurring clients in one place.')}
</p>
</div>
<div className="rounded-xl border border-dashed border-neutral-300 dark:border-neutral-700 bg-neutral-50 dark:bg-neutral-900 p-8 text-center">
<Briefcase className="w-10 h-10 mx-auto mb-3 text-neutral-400" />
<h2 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100 mb-1">
{t('clients.empty.title', 'No client features enabled')}
{t('clients.empty.title', 'No CRM features enabled')}
</h2>
<p className="text-sm text-neutral-600 dark:text-neutral-400">
{t(
'clients.empty.body',
'Enable Accounts (or another Clients sub-feature) under Settings → Features to get started.',
'Enable Accounts (or another CRM sub-feature) under Settings → Features to get started.',
)}
</p>
</div>
@@ -91,10 +136,10 @@ export const ClientsLayout: React.FC = () => {
<div>
<div className="mb-6">
<h1 className="text-2xl font-bold text-neutral-900 dark:text-neutral-100">
{t('clients.title', 'Clients')}
{t('clients.title', 'CRM')}
</h1>
<p className="text-neutral-600 dark:text-neutral-400 mt-1">
{t('clients.subtitle', 'Customer accounts, scheduling, quotes and billing for recurring clients.')}
{t('clients.subtitle', 'Customer accounts, scheduling, quotes and billing — everything for recurring clients in one place.')}
</p>
</div>
@@ -103,7 +148,7 @@ export const ClientsLayout: React.FC = () => {
in one tap on touch devices, no horizontal scroll. */}
<div className="lg:hidden">
<label htmlFor="clients-section" className="sr-only">
{t('clients.navAriaLabel', 'Clients navigation')}
{t('clients.navAriaLabel', 'CRM navigation')}
</label>
<select
id="clients-section"
@@ -120,7 +165,7 @@ export const ClientsLayout: React.FC = () => {
{/* Desktop: sticky left rail */}
<aside className="hidden lg:block">
<nav
aria-label={t('clients.navAriaLabel', 'Clients navigation')}
aria-label={t('clients.navAriaLabel', 'CRM navigation')}
className="sticky top-6 space-y-1"
>
{enabledItems.map((item) => {
@@ -0,0 +1,272 @@
/**
* CrmOverviewSection — headline metrics embedded into the main
* AdminDashboard for admins who use the CRM features.
*
* Feature-flag gating (three layers):
* - `clients` parent flag OFF → renders nothing
* - only `quotes` enabled → only the quote cards render
* - only `bills` enabled → only the invoice + revenue
* + outstanding cards render
* - both enabled → full section
*
* Numbers come from /api/admin/dashboard/crm-stats. Each card deep-
* links into the matching filtered list so the admin can drill in
* with one click.
*/
import React from 'react';
import { Link } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { useQuery } from '@tanstack/react-query';
import {
FileText, Send, CheckCircle2, XCircle, Clock,
Receipt, AlertTriangle, TrendingUp, Wallet,
} from 'lucide-react';
import { Card } from '../common';
import { fetchCrmOverview, type CrmOverviewStats } from '../../services/bills.service';
import { useFeatureFlags } from '../../contexts/FeatureFlagsContext';
import { usePublicSettings } from '../../hooks/usePublicSettings';
import { formatMoneyMinor } from '../../utils/money';
// Local alias preserved so call-sites in this file keep their
// minor-units semantics. The unified helper handles the /100 conversion.
const formatMoney = formatMoneyMinor;
export const CrmOverviewSection: React.FC = () => {
const { t } = useTranslation();
const { flags } = useFeatureFlags();
const { data: publicSettings } = usePublicSettings();
// Outer gate: hide the entire CRM block when the parent feature
// flag is off. The query is also skipped so we don't hit the
// endpoint at all on non-CRM installs.
const clientsOn = !!flags.clients;
const quotesOn = clientsOn && !!flags.quotes;
const billsOn = clientsOn && !!flags.bills;
const anyCrm = quotesOn || billsOn;
// Per-tile visibility (admin pref in Settings → CRM behaviour). All
// default true; only explicit false hides the matching tile. We
// resolve via `!== false` so the very first render — before
// publicSettings finishes loading — shows everything, then settles.
const showRevenue = publicSettings?.crm_overview_show_revenue !== false;
const showOutstanding = publicSettings?.crm_overview_show_outstanding !== false;
const showQuotes = publicSettings?.crm_overview_show_quotes !== false;
const showInvoices = publicSettings?.crm_overview_show_invoices !== false;
// Compute which sub-sections actually render so we can skip the
// outer block entirely when the admin hid everything.
const billsRevenueRow = billsOn && (showRevenue || showOutstanding);
const quotesBlock = quotesOn && showQuotes;
const invoicesBlock = billsOn && showInvoices;
const { data, isLoading, isError } = useQuery({
queryKey: ['crm-overview'],
queryFn: () => fetchCrmOverview(),
enabled: anyCrm,
// Dashboard tile aggregates — admin opens this on every dashboard
// load; refetching the full aggregate on every mount adds DB load
// without observable benefit. 60s window covers the typical
// "click into a contract / click back" pattern.
staleTime: 60_000,
});
if (!anyCrm) return null;
// Admin hid every CRM tile — render nothing, including the heading.
if (!billsRevenueRow && !quotesBlock && !invoicesBlock) return null;
if (isLoading) return null;
if (isError || !data) {
// Surface a tiny inline notice when the section is enabled by
// flags but the API failed — silent renders make this hard to
// debug (the user reported "everything turned on but nothing
// shows" which traced back to a permission check on the
// backend). Keep it small so it doesn't disrupt the page.
return (
<section className="mt-8">
<h2 className="text-xl font-bold text-theme mb-2">
{t('crmOverview.title', 'CRM overview')}
</h2>
<p className="text-sm text-red-600">
{t('crmOverview.loadError',
'Could not load CRM stats. Check that you have bills.view or quotes.view permission and that the backend is on the latest build.')}
</p>
</section>
);
}
const d: CrmOverviewStats = data;
const cur = d.currency || 'CHF';
return (
<section className="mt-8 space-y-5">
<h2 className="text-xl font-bold text-theme">
{t('crmOverview.title', 'CRM overview')}
</h2>
{/* Revenue + outstanding (bills feature only). Revenue trio and
outstanding tile are gated independently — admins who only
want the outstanding figure (or vice versa) can hide either
via Settings → CRM behaviour. */}
{billsRevenueRow && (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-3">
{showRevenue && (
<>
<StatCard
icon={<TrendingUp className="w-5 h-5" />}
label={t('crmOverview.revenue.month', 'Revenue · last 30 days')}
value={formatMoney(d.revenue.monthMinor, cur)}
/>
<StatCard
icon={<TrendingUp className="w-5 h-5" />}
label={t('crmOverview.revenue.quarter', 'Revenue · last 90 days')}
value={formatMoney(d.revenue.quarterMinor, cur)}
/>
<StatCard
icon={<TrendingUp className="w-5 h-5" />}
label={t('crmOverview.revenue.year', 'Revenue · last 365 days')}
value={formatMoney(d.revenue.yearMinor, cur)}
/>
</>
)}
{showOutstanding && (
<StatCard
icon={<Wallet className="w-5 h-5 text-red-600" />}
label={t('crmOverview.outstanding', 'Outstanding payments')}
value={formatMoney(d.outstanding.totalMinor, cur)}
sub={t('crmOverview.outstandingSub', '{{count}} invoice(s) unpaid', {
count: d.outstanding.invoiceCount,
})}
to="/admin/clients/bills?unpaidOnly=true"
/>
)}
</div>
)}
{/* Quotes pipeline (quotes feature only) */}
{quotesBlock && (
<div>
<div className="flex items-center justify-between mb-3">
<h3 className="text-base font-semibold flex items-center gap-2">
<FileText className="w-5 h-5" />
{t('crmOverview.quotes.title', 'Quotes')}
</h3>
<Link to="/admin/clients/quotes" className="text-sm text-primary-600 dark:text-primary-400 hover:underline">
{t('crmOverview.viewAll', 'View all')}
</Link>
</div>
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-3">
<StatCard
icon={<Clock className="w-5 h-5 text-amber-600" />}
label={t('quotes.status.draft', 'Drafts')}
value={d.quotes.draft}
to="/admin/clients/quotes?status=draft"
/>
<StatCard
icon={<Send className="w-5 h-5 text-blue-600" />}
label={t('quotes.status.sent', 'Sent / open')}
value={d.quotes.sent}
to="/admin/clients/quotes?status=sent"
/>
<StatCard
icon={<CheckCircle2 className="w-5 h-5 text-green-600" />}
label={t('quotes.status.accepted', 'Accepted')}
value={d.quotes.accepted}
to="/admin/clients/quotes?status=accepted"
/>
<StatCard
icon={<XCircle className="w-5 h-5 text-red-600" />}
label={t('quotes.status.declined', 'Declined')}
value={d.quotes.declined}
to="/admin/clients/quotes?status=declined"
/>
<StatCard
icon={<Clock className="w-5 h-5 text-neutral-500" />}
label={t('quotes.status.expired', 'Expired')}
value={d.quotes.expired}
to="/admin/clients/quotes?status=expired"
/>
<StatCard
icon={<CheckCircle2 className="w-5 h-5 text-emerald-700" />}
label={t('quotes.status.converted', 'Converted')}
value={d.quotes.converted}
to="/admin/clients/quotes?status=converted"
/>
</div>
</div>
)}
{/* Invoices pipeline (bills feature only) */}
{invoicesBlock && (
<div>
<div className="flex items-center justify-between mb-3">
<h3 className="text-base font-semibold flex items-center gap-2">
<Receipt className="w-5 h-5" />
{t('crmOverview.invoices.title', 'Invoices')}
</h3>
<Link to="/admin/clients/bills" className="text-sm text-primary-600 dark:text-primary-400 hover:underline">
{t('crmOverview.viewAll', 'View all')}
</Link>
</div>
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-5 gap-3">
<StatCard
icon={<Clock className="w-5 h-5 text-amber-600" />}
label={t('bills.status.scheduled', 'Scheduled')}
value={d.invoices.scheduled}
to="/admin/clients/bills?status=scheduled"
/>
<StatCard
icon={<Send className="w-5 h-5 text-blue-600" />}
label={t('bills.status.sent', 'Sent / open')}
value={d.invoices.sent}
to="/admin/clients/bills?status=sent"
/>
<StatCard
icon={<CheckCircle2 className="w-5 h-5 text-green-600" />}
label={t('bills.status.paid', 'Paid')}
value={d.invoices.paid}
to="/admin/clients/bills?status=paid"
/>
<StatCard
icon={<AlertTriangle className="w-5 h-5 text-red-600" />}
label={t('bills.status.overdue', 'Overdue')}
value={d.invoices.overdue}
to="/admin/clients/bills?status=overdue"
/>
<StatCard
icon={<XCircle className="w-5 h-5 text-neutral-500" />}
label={t('bills.status.cancelled', 'Cancelled')}
value={d.invoices.cancelled}
to="/admin/clients/bills?status=cancelled"
/>
</div>
</div>
)}
</section>
);
};
interface StatCardProps {
icon: React.ReactNode;
label: string;
value: string | number;
sub?: string;
to?: string;
}
const StatCard: React.FC<StatCardProps> = ({ icon, label, value, sub, to }) => {
const inner = (
<Card padding="md" className="h-full">
<div className="flex items-start gap-3">
<div className="shrink-0 mt-0.5">{icon}</div>
<div className="min-w-0">
<div className="text-xs uppercase tracking-wider text-muted-theme">{label}</div>
<div className="text-2xl font-bold tabular-nums mt-1">{value}</div>
{sub && <div className="text-xs text-muted-theme mt-1">{sub}</div>}
</div>
</div>
</Card>
);
if (to) {
return <Link to={to} className="block hover:opacity-90 transition-opacity">{inner}</Link>;
}
return inner;
};
export default CrmOverviewSection;
@@ -5,9 +5,11 @@ import { toast } from 'react-toastify';
import { Save, RotateCcw, Code, AlertTriangle, Check } from 'lucide-react';
import { Button, Card, Loading } from '../common';
import { cssTemplatesService, CssTemplate } from '../../services/cssTemplates.service';
import { useLocalizedDate } from '../../hooks/useLocalizedDate';
export const CssTemplateEditor: React.FC = () => {
const { t } = useTranslation();
const { formatDateTime: fmtDateTime } = useLocalizedDate();
const queryClient = useQueryClient();
const [activeSlot, setActiveSlot] = useState(1);
const [localTemplates, setLocalTemplates] = useState<CssTemplate[]>([]);
@@ -228,7 +230,7 @@ export const CssTemplateEditor: React.FC = () => {
{/* Last Updated */}
{activeTemplate.updated_at && (
<p className="text-xs text-neutral-400 dark:text-neutral-500 text-right">
{t('cssTemplates.lastUpdated', 'Last updated')}: {new Date(activeTemplate.updated_at).toLocaleString()}
{t('cssTemplates.lastUpdated', 'Last updated')}: {fmtDateTime(activeTemplate.updated_at)}
</p>
)}
</div>
@@ -0,0 +1,221 @@
/**
* Customer CRM panels — quotes + invoices history shown on the customer
* detail page. Each panel:
* - is gated by its global feature flag (`quotes` / `bills`); when the
* flag is off the panel doesn't render at all (no empty space)
* - shows the 10 most recent rows for that customer, with status
* badge, total and a click-through to the full document
* - exposes a "New …" button + a "Show all" link to the global list
* pre-filtered by this customer
*
* Lives as a separate component so CustomerDetailPage doesn't need to
* know about CRM types; the panels handle their own data fetching.
*/
import React from 'react';
import { useTranslation } from 'react-i18next';
import { Link } from 'react-router-dom';
import { useQuery } from '@tanstack/react-query';
import { FileText, Plus, Receipt, ScrollText } from 'lucide-react';
import { Card, Button, Loading } from '../common';
import { useFeatureFlags } from '../../contexts/FeatureFlagsContext';
import { quotesService } from '../../services/quotes.service';
import { billsService } from '../../services/bills.service';
import { contractsService } from '../../services/contracts.service';
import { formatMoney } from './LineItemsTable';
import { useLocalizedDate } from '../../hooks/useLocalizedDate';
interface Props {
customerAccountId: number;
}
export const CustomerCrmPanels: React.FC<Props> = ({ customerAccountId }) => {
const { flags } = useFeatureFlags();
return (
<>
{flags.quotes && <QuotesPanel customerAccountId={customerAccountId} />}
{flags.contracts && <ContractsPanel customerAccountId={customerAccountId} />}
{flags.bills && <InvoicesPanel customerAccountId={customerAccountId} />}
</>
);
};
const QuotesPanel: React.FC<Props> = ({ customerAccountId }) => {
const { t } = useTranslation();
const { format: fmtDate } = useLocalizedDate();
const { data, isLoading } = useQuery({
queryKey: ['customer-quotes', customerAccountId],
queryFn: () => quotesService.list({ customerAccountId, page: 1, pageSize: 10, sort: 'newest' }),
// Customer detail page mounts these three panels together. Without
// staleTime they all refetch on every visit + every queryClient
// touch elsewhere. 30s lets a quick tab-out/tab-in not re-hit the
// API; admin mutations invalidate the cache explicitly when they
// need fresh data.
staleTime: 30_000,
});
return (
<Card padding="lg">
<div className="flex items-center justify-between mb-3">
<h2 className="text-lg font-semibold text-theme flex items-center gap-2">
<FileText className="w-5 h-5" /> {t('customers.detail.quotesSection', 'Quotes')}
</h2>
<div className="flex gap-2">
<Link to={`/admin/clients/quotes?customerAccountId=${customerAccountId}`}>
<Button variant="outline" size="sm">{t('common.showAll', 'Show all')}</Button>
</Link>
{/* "New quote" pre-fills via state on QuoteEditorPage when a
customerAccountId search-param is present (cheap follow-up
if you want it). For now the editor's customer picker
starts empty. */}
{/* Pre-fill this customer on the editor via search-param
so the admin doesn't have to retype it. The editor picks
it up on mount. */}
<Link to={`/admin/clients/quotes/new?customerAccountId=${customerAccountId}`}>
<Button size="sm"><Plus className="w-4 h-4 mr-1" />{t('quotes.new', 'New quote')}</Button>
</Link>
</div>
</div>
{isLoading ? <Loading /> : !data || data.quotes.length === 0 ? (
<p className="text-sm text-muted-theme">
{t('customers.detail.noQuotes', 'No quotes for this customer yet.')}
</p>
) : (
<ul className="divide-y" style={{ borderColor: 'var(--color-surface-border)' }}>
{data.quotes.map((q) => (
<li key={q.id} className="py-2 flex items-center justify-between gap-3">
<div className="min-w-0 flex-1">
<Link to={`/admin/clients/quotes/${q.id}`} className="text-theme hover:underline font-mono text-sm">
{q.quoteNumber}
</Link>
<span className="text-xs text-muted-theme ml-2">{q.eventName || fmtDate(q.issueDate)}</span>
</div>
<span className="text-sm tabular-nums">{formatMoney(Number(q.totalAmountMinor) / 100, q.currency)}</span>
<span className={`px-2 py-0.5 rounded text-xs font-medium ${
q.status === 'accepted' || q.status === 'converted' ? 'bg-green-100 text-green-800'
: q.status === 'declined' ? 'bg-red-100 text-red-800'
: q.status === 'sent' ? 'bg-blue-100 text-blue-800'
: 'bg-neutral-100 text-neutral-700'
}`}>{t(`quotes.status.${q.status}`, q.status)}</span>
</li>
))}
</ul>
)}
</Card>
);
};
const ContractsPanel: React.FC<Props> = ({ customerAccountId }) => {
const { t } = useTranslation();
const { format: fmtDate } = useLocalizedDate();
const { data, isLoading } = useQuery({
queryKey: ['customer-contracts', customerAccountId],
queryFn: () => contractsService.list({ customerAccountId, page: 1, pageSize: 10, sort: 'newest' }),
staleTime: 30_000,
});
return (
<Card padding="lg">
<div className="flex items-center justify-between mb-3">
<h2 className="text-lg font-semibold text-theme flex items-center gap-2">
<ScrollText className="w-5 h-5" /> {t('customers.detail.contractsSection', 'Contracts')}
</h2>
<div className="flex gap-2">
<Link to={`/admin/clients/contracts?customerAccountId=${customerAccountId}`}>
<Button variant="outline" size="sm">{t('common.showAll', 'Show all')}</Button>
</Link>
<Link to={`/admin/clients/contracts/new?customerAccountId=${customerAccountId}`}>
<Button size="sm"><Plus className="w-4 h-4 mr-1" />{t('contracts.list.new', 'New contract')}</Button>
</Link>
</div>
</div>
{isLoading ? <Loading /> : !data || data.contracts.length === 0 ? (
<p className="text-sm text-muted-theme">
{t('customers.detail.noContracts', 'No contracts for this customer yet.')}
</p>
) : (
<ul className="divide-y" style={{ borderColor: 'var(--color-surface-border)' }}>
{data.contracts.map((c) => (
<li key={c.id} className="py-2 flex items-center justify-between gap-3">
<div className="min-w-0 flex-1">
<Link to={`/admin/clients/contracts/${c.id}`} className="text-theme hover:underline font-mono text-sm">
{c.contractNumber}
</Link>
<span className="text-xs text-muted-theme ml-2 truncate">{c.title || fmtDate(c.issueDate)}</span>
</div>
<span className={`px-2 py-0.5 rounded text-xs font-medium ${
c.status === 'fully_signed' ? 'bg-green-100 text-green-800'
: c.status === 'signed_by_customer' || c.status === 'signed_by_admin' ? 'bg-blue-100 text-blue-800'
: c.status === 'sent' ? 'bg-amber-100 text-amber-800'
: c.status === 'cancelled' ? 'bg-neutral-200 text-neutral-600'
: 'bg-neutral-100 text-neutral-700'
}`}>{t(`contracts.status.${c.status}`, c.status)}</span>
</li>
))}
</ul>
)}
</Card>
);
};
const InvoicesPanel: React.FC<Props> = ({ customerAccountId }) => {
const { t } = useTranslation();
const { format: fmtDate } = useLocalizedDate();
const { data, isLoading } = useQuery({
queryKey: ['customer-invoices', customerAccountId],
queryFn: () => billsService.list({ customerAccountId, page: 1, pageSize: 10, sort: 'newest' }),
staleTime: 30_000,
});
return (
<Card padding="lg">
<div className="flex items-center justify-between mb-3">
<h2 className="text-lg font-semibold text-theme flex items-center gap-2">
<Receipt className="w-5 h-5" /> {t('customers.detail.billsSection', 'Invoices')}
</h2>
<div className="flex gap-2">
<Link to={`/admin/clients/bills?customerAccountId=${customerAccountId}`}>
<Button variant="outline" size="sm">{t('common.showAll', 'Show all')}</Button>
</Link>
{/* Same prefill trick as quotes — see comment in QuotesPanel. */}
<Link to={`/admin/clients/bills/new?customerAccountId=${customerAccountId}`}>
<Button size="sm"><Plus className="w-4 h-4 mr-1" />{t('bills.new', 'New invoice')}</Button>
</Link>
</div>
</div>
{isLoading ? <Loading /> : !data || data.invoices.length === 0 ? (
<p className="text-sm text-muted-theme">
{t('customers.detail.noBills', 'No invoices for this customer yet.')}
</p>
) : (
<ul className="divide-y" style={{ borderColor: 'var(--color-surface-border)' }}>
{data.invoices.map((inv) => (
<li key={inv.id} className="py-2 flex items-center justify-between gap-3">
<div className="min-w-0 flex-1">
<Link to={`/admin/clients/bills/${inv.id}`} className="text-theme hover:underline font-mono text-sm">
{inv.invoiceNumber}
</Link>
<span className="text-xs text-muted-theme ml-2">
{fmtDate(inv.dueDate)}
{inv.installmentTotal > 1 ? ` · ${inv.installmentIndex + 1}/${inv.installmentTotal}` : ''}
</span>
</div>
<span className="text-sm tabular-nums">{formatMoney(Number(inv.totalAmountMinor) / 100, inv.currency)}</span>
<span className={`px-2 py-0.5 rounded text-xs font-medium ${
inv.status === 'paid' ? 'bg-green-100 text-green-800'
: inv.status === 'overdue' ? 'bg-red-100 text-red-800'
: inv.status === 'sent' ? 'bg-blue-100 text-blue-800'
: inv.status === 'cancelled' ? 'bg-neutral-200 text-neutral-600'
: inv.status === 'skipped' ? 'bg-neutral-100 text-neutral-500 italic'
: 'bg-amber-100 text-amber-800'
}`}>{t(`bills.status.${inv.status}`, inv.status)}</span>
</li>
))}
</ul>
)}
</Card>
);
};
@@ -0,0 +1,214 @@
/**
* CustomerPicker — shared search-or-create surface for the three CRM
* editors (Quote, Bill, Contract).
*
* **Why this exists**
*
* The audit flagged that QuoteEditorPage, BillEditorPage, and
* ContractEditorPage each carried near-identical ~80-line customer
* picker blocks: a "currently selected" row when an id is present,
* a search-debounced lookup with passive-badge chips, and an
* "InlineCustomerCreate" expansion when the admin clicks "+ Create
* new customer". The three copies had drifted: passive badge text
* positioning differed, the contract variant used a bare `<input>`
* instead of the shared `<Input>` component, and the contract change
* link used `text-accent-dark hover:underline` instead of the
* Button-variant "outline" style the other two used.
*
* Behavior unified here matches the Quote and Bill variants (which
* were already in sync with each other); the contract variant's
* styling differences are folded in.
*
* **API**
*
* <CustomerPicker
* value={customerAccountId} // number | null
* label={customerLabel} // pre-formatted display label
* isPassive={customerIsPassive} // boolean
* onSelect={(c) => { ... }} // CustomerSummary from search
* onCreate={(c) => { ... }} // CustomerAccountDetail from inline create
* onClear={() => { ... }} // user clicked "Change"
* readOnly={false} // contract editor uses true on edit
* />
*
* The component owns the `customerSearch` state + the debounced
* useQuery against customerAdminService.search and the "+ Create new
* customer" expansion toggle. Parents own the canonical
* `customerAccountId / label / isPassive` triple because each editor
* stores them differently (Quote nests them inside a form object,
* Bill + Contract use separate useStates). Keeping the triple owned
* by the parent avoids a forced shape migration.
*
* **Selection vs creation callbacks**
*
* `onSelect` receives the CustomerSummary shape from the search
* endpoint (id, email, displayName, companyName, firstName, lastName,
* isPassive). `onCreate` receives the full CustomerAccountDetail
* because some editors want to inherit additional fields from a
* freshly-created customer (e.g. Quote inherits the new customer's
* preferredLanguage so the doc renders in their locale by default).
*/
import React, { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useQuery } from '@tanstack/react-query';
import { Button, Input } from '../common';
import { InlineCustomerCreate } from './InlineCustomerCreate';
import {
customerAdminService,
type CustomerAccountDetail,
type CustomerAccountSummary,
} from '../../services/customerAdmin.service';
// Alias for clarity at the call-site: search returns the Summary shape.
export type CustomerSummary = CustomerAccountSummary;
export interface CustomerPickerProps {
value: number | null;
label: string;
isPassive: boolean;
onSelect: (customer: CustomerSummary) => void;
onCreate: (customer: CustomerAccountDetail) => void;
onClear: () => void;
/**
* Read-only mode: render only the selected-row chip, hide search +
* create + change. Used by the contract editor in edit mode where
* the customer is locked to whatever the contract was created with.
*/
readOnly?: boolean;
/**
* Placeholder override for the search box. Defaults to the i18n
* `crm.customerPicker.search` key with an EN fallback. Specific
* editors can pass a doc-type-flavoured label.
*/
searchPlaceholder?: string;
/**
* F.6 — surface a feature-gate badge so the admin sees up front that
* selecting a particular customer won't work for this surface (e.g.
* the calendar's hour-entry drag-create modal would 409 the backend
* on a customer whose `feature_hours_logging` is OFF).
* Currently only 'hoursLogging' is supported; pass undefined to
* skip the badge entirely (default for quote / bill / contract
* editors which don't care about hour-logging eligibility).
*/
requireFeature?: 'hoursLogging';
}
export const CustomerPicker: React.FC<CustomerPickerProps> = ({
value,
label,
isPassive,
onSelect,
onCreate,
onClear,
readOnly = false,
searchPlaceholder,
requireFeature,
}) => {
const { t } = useTranslation();
const [search, setSearch] = useState('');
const [creating, setCreating] = useState(false);
// Debounce the search term before it hits the API. The previous
// shape fired one search request per keystroke; on a passive-
// customer list of 500+ rows, that's hundreds of /api/admin/customers
// calls during a single look-up. 250ms is below the perceptual
// threshold for typing.
const [debouncedSearch, setDebouncedSearch] = useState('');
useEffect(() => {
const handle = window.setTimeout(() => setDebouncedSearch(search), 250);
return () => window.clearTimeout(handle);
}, [search]);
const { data: options = [] } = useQuery({
queryKey: ['crm-customer-picker', debouncedSearch],
queryFn: () => customerAdminService.search(debouncedSearch),
enabled: !readOnly && !value && !creating && debouncedSearch.trim().length >= 2,
});
if (value) {
return (
<div className="flex items-center justify-between bg-neutral-50 dark:bg-neutral-800 rounded-md px-3 py-2">
<div className="flex items-center gap-2 flex-wrap">
<span className="text-sm">{label || `#${value}`}</span>
{isPassive && (
<span className="inline-flex items-center gap-1 text-[11px] px-1.5 py-0.5 rounded bg-neutral-100 dark:bg-neutral-700 text-neutral-700 dark:text-neutral-300">
{t('customers.passive.badge', 'Passive — admin only')}
</span>
)}
</div>
{!readOnly && (
<Button variant="outline" size="sm" onClick={onClear}>
{t('common.change', 'Change')}
</Button>
)}
</div>
);
}
if (creating) {
return (
<InlineCustomerCreate
onCancel={() => setCreating(false)}
onCreated={(c) => {
onCreate(c);
setCreating(false);
}}
/>
);
}
return (
<>
<Input
placeholder={searchPlaceholder
|| (t('crm.customerPicker.search', 'Search customer by email or company…') as string)}
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
{options.length > 0 && (
<ul className="mt-2 rounded-md border border-neutral-200 dark:border-neutral-700 divide-y divide-neutral-200 dark:divide-neutral-700">
{options.map((c) => {
// F.6 — gate badge for the calendar's hour-entry create
// modal. Selecting a customer with feature_hours_logging
// OFF would 409 the backend; warn up front. We still allow
// the click so the admin can open the customer's detail
// page to flip the flag from a separate tab.
const hourLoggingOff =
requireFeature === 'hoursLogging' && c.featureHoursLogging === false;
return (
<li key={c.id}>
<button
type="button"
onClick={() => onSelect(c)}
className="w-full text-left px-3 py-2 hover:bg-neutral-50 dark:hover:bg-neutral-800 text-sm"
>
<span className="font-medium">
{c.companyName || c.displayName || c.email}
</span>
<span className="text-neutral-500 ml-2">{c.email}</span>
{c.isPassive && (
<span className="ml-2 inline-flex items-center gap-1 text-[11px] px-1.5 py-0.5 rounded bg-neutral-100 dark:bg-neutral-700 text-neutral-700 dark:text-neutral-300">
{t('customers.passive.badge', 'Passive — admin only')}
</span>
)}
{hourLoggingOff && (
<span className="ml-2 inline-flex items-center gap-1 text-[11px] px-1.5 py-0.5 rounded bg-amber-100 dark:bg-amber-900/40 text-amber-800 dark:text-amber-300">
{t('customers.hoursLoggingDisabled.badge', 'Hour logging disabled')}
</span>
)}
</button>
</li>
);
})}
</ul>
)}
<button
type="button"
onClick={() => setCreating(true)}
className="mt-3 inline-flex items-center gap-1 text-sm text-primary-600 dark:text-primary-400 hover:underline"
>
{t('customers.create.openLink', '+ Create new customer')}
</button>
</>
);
};
@@ -0,0 +1,313 @@
/**
* <DocumentLineageCard> — flat list of every document sharing one
* `deal_uuid` (migration 140), grouped by type. Renders on the three
* detail pages (Quote / Contract / Bill) so the admin sees the
* complete chain — quotes + contracts + invoices (incl. Storno /
* reissue / installment siblings) — without walking individual FKs.
*
* Data comes from `GET /api/admin/deals/:uuid/documents` (commit #3).
* The card is purely presentational; the parent passes the dealUuid
* and a `currentId` so the row representing "the document you're
* looking at" can be highlighted instead of linked.
*
* When no other documents share the deal (newly-created standalone
* doc), the card renders a compact "no related documents" line
* instead of three empty groups — keeps the detail page from looking
* busy on the common case.
*/
import React, { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useQuery } from '@tanstack/react-query';
import { Link } from 'react-router-dom';
import { FileText, ScrollText, Receipt, AlertTriangle, Pencil } from 'lucide-react';
import { Button, Card } from '../common';
import { formatMoneyMinor } from '../../utils/money';
import { api } from '../../config/api';
import { EditInstallmentPlanModal } from './EditInstallmentPlanModal';
export interface DocumentLineageCardProps {
dealUuid: string | null | undefined;
/** Which document you're currently looking at. The matching row
* renders muted (not a link) so the admin doesn't navigate to the
* page they're already on. */
current: { kind: 'quote' | 'contract' | 'invoice'; id: number };
className?: string;
}
interface DealItemBase {
id: number;
number: string;
status: string;
currency?: string;
totalAmountMinor?: number;
issueDate?: string;
eventName?: string | null;
createdAt?: string;
}
interface DealQuoteItem extends DealItemBase { kind: 'quote'; validUntil?: string; }
interface DealContractItem extends DealItemBase { kind: 'contract'; title?: string | null; validUntil?: string; }
interface DealInvoiceItem extends DealItemBase {
kind: 'invoice';
invoiceKind: 'invoice' | 'storno';
paidAmountMinor?: number;
dueDate?: string;
eventDate?: string | null;
installmentIndex?: number;
installmentTotal?: number;
installmentLabel?: string | null;
installmentTrigger?: string | null;
installmentOffsetDays?: number;
isMonthlyDraft?: boolean;
}
interface DealLineageResponse {
dealUuid: string;
quotes: DealQuoteItem[];
contracts: DealContractItem[];
invoices: DealInvoiceItem[];
}
const EDITABLE_PLAN_STATUSES = new Set(['scheduled', 'pending_delivery']);
export const DocumentLineageCard: React.FC<DocumentLineageCardProps> = ({
dealUuid, current, className = '',
}) => {
const { t } = useTranslation();
const [showEditPlan, setShowEditPlan] = useState(false);
const { data, isLoading, error } = useQuery({
queryKey: ['deal-lineage', dealUuid],
queryFn: async () => {
const res = await api.get(`/admin/deals/${dealUuid}/documents`);
return (res.data.data || res.data) as DealLineageResponse;
},
enabled: !!dealUuid,
staleTime: 30_000,
});
if (!dealUuid) return null;
if (isLoading) {
return (
<Card padding="md" className={className}>
<p className="text-sm text-muted-theme">
{t('dealLineage.loading', 'Loading related documents…')}
</p>
</Card>
);
}
if (error) {
return (
<Card padding="md" className={className}>
<p className="text-sm text-red-700 dark:text-red-300 flex items-center gap-2">
<AlertTriangle className="w-4 h-4" />
{t('dealLineage.error', 'Could not load related documents.')}
</p>
</Card>
);
}
if (!data) return null;
const { quotes, contracts, invoices } = data;
const totalCount = quotes.length + contracts.length + invoices.length;
// Reshape gesture is offered when this deal holds a multi-installment
// plan AND every invoice is still pre-customer. Server re-checks on
// save; if a sibling shipped between render and click, the 409 path
// in the modal handles the race.
const hasMultiInstallment = invoices.some((i) => (i.installmentTotal || 0) > 1);
const allInvoicesEditable = invoices.length > 0
&& invoices.every((i) => i.invoiceKind !== 'storno'
&& EDITABLE_PLAN_STATUSES.has(i.status));
const canEditPlan = hasMultiInstallment && allInvoicesEditable;
// Only ONE doc total = the current one. No siblings to surface.
if (totalCount <= 1) {
return (
<Card padding="md" className={className}>
<h2 className="font-semibold mb-1 flex items-center gap-2">
{t('dealLineage.title', 'Related documents')}
</h2>
<p className="text-xs text-muted-theme">
{t('dealLineage.empty', 'No other documents share this deal yet. New invoices, contracts, or installments will show up here once created.')}
</p>
</Card>
);
}
return (
<Card padding="md" className={className}>
<h2 className="font-semibold mb-3">
{t('dealLineage.title', 'Related documents')}
</h2>
{quotes.length > 0 && (
<Group
icon={<FileText className="w-4 h-4" />}
label={t('dealLineage.quotes', 'Quotes')}
count={quotes.length}
>
{quotes.map((q) => (
<Row
key={`q-${q.id}`}
isCurrent={current.kind === 'quote' && current.id === q.id}
href={`/admin/clients/quotes/${q.id}`}
number={q.number}
statusKey={`quotes.status.${q.status}`}
statusFallback={q.status}
right={q.totalAmountMinor != null && q.currency
? formatMoneyMinor(q.totalAmountMinor, q.currency)
: null}
meta={q.eventName || undefined}
/>
))}
</Group>
)}
{contracts.length > 0 && (
<Group
icon={<ScrollText className="w-4 h-4" />}
label={t('dealLineage.contracts', 'Contracts')}
count={contracts.length}
>
{contracts.map((c) => (
<Row
key={`c-${c.id}`}
isCurrent={current.kind === 'contract' && current.id === c.id}
href={`/admin/clients/contracts/${c.id}`}
number={c.number}
statusKey={`contracts.status.${c.status}`}
statusFallback={c.status}
meta={c.title || c.eventName || undefined}
/>
))}
</Group>
)}
{invoices.length > 0 && (
<Group
icon={<Receipt className="w-4 h-4" />}
label={t('dealLineage.invoices', 'Invoices')}
count={invoices.length}
action={canEditPlan ? (
<Button
variant="outline"
size="sm"
onClick={() => setShowEditPlan(true)}
leftIcon={<Pencil className="w-3.5 h-3.5" />}
className="ml-auto"
>
{t('dealLineage.editPlan', 'Edit plan')}
</Button>
) : undefined}
>
{invoices.map((i) => {
const isStorno = i.invoiceKind === 'storno';
const installmentTag = i.installmentTotal && i.installmentTotal > 1
? ` · ${i.installmentLabel || `${i.installmentIndex! + 1}/${i.installmentTotal}`}`
: '';
return (
<Row
key={`i-${i.id}`}
isCurrent={current.kind === 'invoice' && current.id === i.id}
href={`/admin/clients/bills/${i.id}`}
number={i.number}
statusKey={`bills.status.${i.status}`}
statusFallback={i.status}
right={i.totalAmountMinor != null && i.currency
? formatMoneyMinor(i.totalAmountMinor, i.currency)
: null}
badge={isStorno ? t('bills.kind.storno', 'Storno') as string : undefined}
meta={installmentTag.replace(/^ · /, '') || undefined}
/>
);
})}
</Group>
)}
{canEditPlan && dealUuid && (
<EditInstallmentPlanModal
isOpen={showEditPlan}
onClose={() => setShowEditPlan(false)}
dealUuid={dealUuid}
siblings={invoices.map((i) => ({
id: i.id,
number: i.number,
status: i.status,
totalAmountMinor: i.totalAmountMinor,
installmentIndex: i.installmentIndex,
installmentTotal: i.installmentTotal,
installmentLabel: i.installmentLabel,
installmentTrigger: i.installmentTrigger,
installmentOffsetDays: i.installmentOffsetDays,
}))}
eventDate={invoices.find((i) => i.eventDate)?.eventDate || null}
/>
)}
</Card>
);
};
const Group: React.FC<{
icon: React.ReactNode;
label: string;
count: number;
children: React.ReactNode;
action?: React.ReactNode;
}> = ({ icon, label, count, children, action }) => (
<div className="mb-3 last:mb-0">
<div className="flex items-center gap-2 text-xs uppercase tracking-wider text-muted-theme mb-1">
{icon}
<span>{label}</span>
<span>({count})</span>
{action}
</div>
<ul className="divide-y divide-neutral-200 dark:divide-neutral-700">
{children}
</ul>
</div>
);
const Row: React.FC<{
isCurrent: boolean;
href: string;
number: string;
statusKey: string;
statusFallback: string;
right?: string | null;
meta?: string;
badge?: string;
}> = ({ isCurrent, href, number, statusKey, statusFallback, right, meta, badge }) => {
const { t } = useTranslation();
const inner = (
<div className="flex items-center justify-between gap-3 py-1.5">
<div className="flex items-center gap-2 min-w-0">
<span className={`font-mono text-sm ${isCurrent ? 'text-muted-theme' : ''}`}>{number}</span>
{badge && (
<span className="text-[10px] uppercase tracking-wider px-1.5 py-0.5 rounded font-semibold bg-purple-100 text-purple-800 dark:bg-purple-900/40 dark:text-purple-300">
{badge}
</span>
)}
{meta && (
<span className="text-xs text-muted-theme truncate">{meta}</span>
)}
</div>
<div className="flex items-center gap-2 shrink-0 text-xs">
{right && <span className="tabular-nums">{right}</span>}
<span className="text-muted-theme">{t(statusKey, statusFallback)}</span>
</div>
</div>
);
if (isCurrent) {
return <li className="opacity-60">{inner}</li>;
}
return (
<li>
<Link to={href} className="block hover:bg-neutral-50 dark:hover:bg-neutral-800/40 -mx-2 px-2 rounded">
{inner}
</Link>
</li>
);
};
export default DocumentLineageCard;
@@ -0,0 +1,209 @@
/**
* <EditInstallmentPlanModal> — atomic reshape of an installment plan
* after siblings have spawned. Wraps the same `<InstallmentsPanel>`
* used by Quote/Bill editors, pre-filled from the existing siblings
* (via the lineage payload — no extra fetch needed).
*
* Triggered from `<DocumentLineageCard>`, which only renders the
* "Edit plan" button when every invoice on the deal is still
* scheduled / pending_delivery (the same gate enforced server-side).
* The server still re-checks on save; a 409 INVOICE_LOCKED races back
* if a sibling shipped between modal open and save click.
*
* Plan total preservation: the backend uses sum(existing sibling totals)
* as the plan total, so the panel doesn't need to surface money — only
* the structure (percents / labels / triggers / offsets).
*/
import React, { useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { toast } from 'react-toastify';
import { X, AlertTriangle } from 'lucide-react';
import { Button, Card } from '../common';
import { InstallmentsPanel } from './InstallmentsPanel';
import type { PaymentTermInstallment } from '../../services/quotes.service';
import { dealsService } from '../../services/deals.service';
export interface EditInstallmentPlanModalProps {
isOpen: boolean;
onClose: () => void;
dealUuid: string;
/** Existing sibling invoices on the deal — used to seed the panel
* and to derive the current plan total for percent computation. */
siblings: Array<{
id: number;
number: string;
status: string;
totalAmountMinor?: number;
installmentIndex?: number;
installmentTotal?: number;
installmentLabel?: string | null;
installmentTrigger?: string | null;
installmentOffsetDays?: number;
}>;
/** Event date — passed to <InstallmentsPanel> so its date preview
* works for before_event / after_event rows. */
eventDate?: string | null;
onSaved?: () => void;
}
const VALID_TRIGGERS: PaymentTermInstallment['trigger'][] = [
'quote_accepted', 'before_event', 'after_event', 'after_delivery', 'fixed_date',
];
function isValidTrigger(t: unknown): t is PaymentTermInstallment['trigger'] {
return typeof t === 'string' && (VALID_TRIGGERS as string[]).includes(t);
}
export const EditInstallmentPlanModal: React.FC<EditInstallmentPlanModalProps> = ({
isOpen, onClose, dealUuid, siblings, eventDate, onSaved,
}) => {
const { t } = useTranslation();
const queryClient = useQueryClient();
// Derive the initial panel rows from the existing siblings, sorted
// by installment_index. Percent = total_amount_minor / sum * 100.
const initialPlan = useMemo<PaymentTermInstallment[]>(() => {
const sorted = [...siblings].sort(
(a, b) => (a.installmentIndex ?? 0) - (b.installmentIndex ?? 0),
);
const sum = sorted.reduce((s, x) => s + (x.totalAmountMinor || 0), 0);
if (sum === 0) {
// Degenerate: every sibling totals zero. Fall back to equal split.
return sorted.map((s, i) => ({
label: s.installmentLabel || `${i + 1}/${sorted.length}`,
percent: Math.round(10000 / sorted.length) / 100,
trigger: isValidTrigger(s.installmentTrigger) ? s.installmentTrigger : 'fixed_date',
offset_days: s.installmentOffsetDays ?? 0,
}));
}
// Compute percents, last slice absorbs rounding so the panel reports
// sum=100 on open.
const rows: PaymentTermInstallment[] = [];
let accPct = 0;
sorted.forEach((s, i) => {
let pct = i === sorted.length - 1
? Math.max(0, 100 - accPct)
: Math.round(((s.totalAmountMinor || 0) / sum) * 10000) / 100;
pct = Math.round(pct * 100) / 100;
accPct += pct;
rows.push({
label: s.installmentLabel || `${i + 1}/${sorted.length}`,
percent: pct,
trigger: isValidTrigger(s.installmentTrigger) ? s.installmentTrigger : 'fixed_date',
offset_days: s.installmentOffsetDays ?? 0,
});
});
return rows;
}, [siblings]);
const [plan, setPlan] = useState<PaymentTermInstallment[] | null>(initialPlan);
const [valid, setValid] = useState(true);
// Reseed when the modal opens against fresh siblings.
useEffect(() => {
if (isOpen) setPlan(initialPlan);
}, [isOpen, initialPlan]);
const save = useMutation({
mutationFn: async () => {
if (!plan || plan.length === 0) {
throw new Error(t('dealLineage.editPlanEmpty', 'Plan cannot be empty.') as string);
}
return dealsService.updateInstallmentPlan(dealUuid, plan);
},
onSuccess: () => {
toast.success(t('dealLineage.editPlanSuccess', 'Installment plan updated.'));
queryClient.invalidateQueries({ queryKey: ['deal-lineage', dealUuid] });
queryClient.invalidateQueries({ queryKey: ['adminBills'] });
queryClient.invalidateQueries({ queryKey: ['admin-invoices'] });
onSaved?.();
onClose();
},
onError: (err: unknown) => {
const e = err as { response?: { data?: { error?: string; code?: string } }; message?: string };
const code = e?.response?.data?.code;
if (code === 'INVOICE_LOCKED' || code === 'PLAN_HAS_STORNO') {
toast.error(t('dealLineage.editPlanLocked',
'Plan can no longer be edited — at least one invoice has shipped or been cancelled.'));
queryClient.invalidateQueries({ queryKey: ['deal-lineage', dealUuid] });
onClose();
return;
}
if (code === 'PERCENT_SUM_INVALID') {
toast.error(t('dealLineage.editPlanPercentSumError', 'Percents must sum to 100.'));
return;
}
toast.error(
e?.response?.data?.error
|| e?.message
|| t('dealLineage.editPlanGeneralError', 'Could not update plan.'),
);
},
});
if (!isOpen) return null;
return (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
<Card className="max-w-3xl w-full max-h-[90vh] overflow-y-auto">
<div className="flex items-start justify-between gap-3 mb-2 flex-wrap">
<div>
<h2 className="text-xl font-semibold">
{t('dealLineage.editPlanModalTitle', 'Edit installment plan')}
</h2>
<p className="text-xs text-muted-theme mt-1">
{t('dealLineage.editPlanHelp',
'Atomically reshape this plan: change percents, labels, triggers, add or remove rows. The plan total stays fixed; existing invoice numbers are kept where possible. Refused once any invoice has shipped.')}
</p>
</div>
<button
type="button"
onClick={onClose}
disabled={save.isPending}
className="text-neutral-400 hover:text-neutral-600 disabled:opacity-50"
aria-label={t('common.close', 'Close') as string}
>
<X className="w-5 h-5" />
</button>
</div>
<div className="rounded-md border border-amber-300 bg-amber-50 dark:bg-amber-900/20 dark:border-amber-700 p-3 mb-3 flex items-start gap-2">
<AlertTriangle className="w-4 h-4 text-amber-700 dark:text-amber-300 mt-0.5 shrink-0" />
<p className="text-sm text-amber-800 dark:text-amber-200">
{t('dealLineage.editPlanWarning',
'Trimming rows deletes their invoice numbers (the sequence cannot release them — a §14 UStG continuity rule). Adding rows claims fresh numbers.')}
</p>
</div>
<InstallmentsPanel
value={plan}
onChange={(next) => setPlan(next || [])}
onValidityChange={setValid}
eventDate={eventDate || null}
/>
<div className="flex justify-end gap-2 mt-4">
<Button
variant="outline"
onClick={onClose}
disabled={save.isPending}
>
{t('common.cancel', 'Cancel')}
</Button>
<Button
variant="primary"
onClick={() => save.mutate()}
isLoading={save.isPending}
disabled={save.isPending || !valid || !plan || plan.length === 0}
>
{t('dealLineage.editPlanSave', 'Save plan')}
</Button>
</div>
</Card>
</div>
);
};
export default EditInstallmentPlanModal;
@@ -0,0 +1,177 @@
/**
* <EventReminderOverrideCard>
*
* Per-event override for the pre-event customer reminder (migration
* 143). Mounted once on the EventDetailsPage; admin can:
* - Disable the reminder for THIS event only (no global flip)
* - Override the global "days before" offset (null = inherit)
* - Provide a custom body that overrides the resolved template's
* body for THIS event only (subject still comes from the template)
*
* Saves through the existing PUT /api/admin/events/:id endpoint with
* the three new whitelisted fields (added 2026-05-25):
* - event_reminder_disabled (bool)
* - event_reminder_offset_days (int | null)
* - event_reminder_body_override (string | null)
*
* Behaves correctly on pre-migration installs: if the event object
* doesn't carry the new fields, the form starts blank and "Reset to
* default" is a no-op until admin saves something.
*
* Strings: every label / hint / button goes through `t()` with a
* fallback. The maintainer flagged "no hard coded i18n" in commit #5
* — applying that convention strictly from here forward.
*/
import React, { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { toast } from 'react-toastify';
import { Bell, BellOff, Save } from 'lucide-react';
import { Button, Card, Input } from '../common';
import { api } from '../../config/api';
export interface EventReminderOverrideCardProps {
eventId: number;
/** Initial values from the event row; null when unset. */
initial: {
event_reminder_disabled?: boolean;
event_reminder_offset_days?: number | null;
event_reminder_body_override?: string | null;
};
/** Optional callback so the parent can refresh its event query
* after a save. */
onSaved?: () => void;
}
export const EventReminderOverrideCard: React.FC<EventReminderOverrideCardProps> = ({
eventId, initial, onSaved,
}) => {
const { t } = useTranslation();
const queryClient = useQueryClient();
const [disabled, setDisabled] = useState<boolean>(!!initial.event_reminder_disabled);
const [offsetDays, setOffsetDays] = useState<string>(
initial.event_reminder_offset_days == null ? '' : String(initial.event_reminder_offset_days),
);
const [bodyOverride, setBodyOverride] = useState<string>(initial.event_reminder_body_override || '');
// Reset local state if the parent's `initial` changes (e.g. after
// the event refetches following an unrelated save).
useEffect(() => {
setDisabled(!!initial.event_reminder_disabled);
setOffsetDays(initial.event_reminder_offset_days == null ? '' : String(initial.event_reminder_offset_days));
setBodyOverride(initial.event_reminder_body_override || '');
}, [initial.event_reminder_disabled, initial.event_reminder_offset_days, initial.event_reminder_body_override]);
const save = useMutation({
mutationFn: async () => {
const payload: Record<string, unknown> = {
event_reminder_disabled: disabled,
};
// Empty string → null clears the override and inherits global.
if (offsetDays.trim() === '') {
payload.event_reminder_offset_days = null;
} else {
const n = Number(offsetDays);
if (!Number.isFinite(n) || n < 0) {
throw new Error(t('eventReminderOverride.invalidOffset',
'Offset must be a non-negative integer or blank.') as string);
}
payload.event_reminder_offset_days = Math.floor(n);
}
payload.event_reminder_body_override = bodyOverride.trim() === '' ? null : bodyOverride;
await api.put(`/admin/events/${eventId}`, payload);
},
onSuccess: () => {
toast.success(t('eventReminderOverride.saved', 'Reminder override saved.'));
queryClient.invalidateQueries({ queryKey: ['admin-event', eventId] });
queryClient.invalidateQueries({ queryKey: ['adminEvent', eventId] });
onSaved?.();
},
onError: (err: unknown) => {
const e = err as { message?: string; response?: { data?: { error?: string } } };
toast.error(
e?.response?.data?.error
|| e?.message
|| t('eventReminderOverride.saveError', 'Could not save reminder override.'),
);
},
});
return (
<Card padding="lg" className="mt-4">
<div className="flex items-start justify-between gap-3 mb-2 flex-wrap">
<div className="flex items-center gap-2">
{disabled
? <BellOff className="w-5 h-5 text-muted-theme" aria-hidden />
: <Bell className="w-5 h-5" aria-hidden />}
<h2 className="text-lg font-semibold">
{t('eventReminderOverride.title', 'Pre-event reminder')}
</h2>
</div>
<Button
variant="outline"
size="sm"
onClick={() => save.mutate()}
isLoading={save.isPending}
disabled={save.isPending}
leftIcon={<Save className="w-4 h-4" />}
>
{t('eventReminderOverride.save', 'Save override')}
</Button>
</div>
<p className="text-xs text-muted-theme mb-3">
{t('eventReminderOverride.help',
'Per-event override for the customer reminder. Global on-off + default offset live under Settings → Reminder emails. Anything left blank here inherits the global setting / resolved template.')}
</p>
<div className="space-y-3">
<label className="flex items-center gap-2 text-sm cursor-pointer">
<input
type="checkbox"
checked={disabled}
onChange={(e) => setDisabled(e.target.checked)}
/>
{t('eventReminderOverride.disabledLabel',
'Disable the reminder for this event (no email goes out)')}
</label>
<div>
<Input
type="number"
min={0}
max={365}
label={t('eventReminderOverride.offsetLabel',
'Days before the event (override) — leave blank to inherit') as string}
value={offsetDays}
onChange={(e) => setOffsetDays(e.target.value)}
placeholder={t('eventReminderOverride.offsetPlaceholder',
'Leave blank to use the global default') as string}
disabled={disabled}
className="md:w-80"
/>
</div>
<div>
<label className="block text-sm font-medium mb-1">
{t('eventReminderOverride.bodyOverrideLabel',
'Custom body for this event (overrides the resolved template body)')}
</label>
<textarea
rows={6}
className="input w-full text-sm"
placeholder={t('eventReminderOverride.bodyOverridePlaceholder',
'Leave blank to use the template body. Variables like {{customer_name}}, {{event_name}}, {{event_date}} still work here.') as string}
value={bodyOverride}
onChange={(e) => setBodyOverride(e.target.value)}
disabled={disabled}
/>
</div>
</div>
</Card>
);
};
export default EventReminderOverrideCard;
@@ -0,0 +1,407 @@
/**
* Hours section card (migration 129).
*
* Used in two places:
* 1. CustomerDetailPage — rendered when the per-customer
* `feature_hours_logging` flag is on AND the master `hoursLogging`
* flag is on. Sits between the features card and account actions.
* 2. The standalone /admin/clients/hours page — admin picks ANY
* customer with hours logging enabled, then sees this card.
*
* Wraps customerAdminService.{list,create,delete,billUnbilled}HourEntries.
* All writes go through react-query invalidation so the entry list
* refreshes after every action.
*/
import React, { useMemo, useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { toast } from 'react-toastify';
import { Clock } from 'lucide-react';
import { Button, Card } from '../common';
import { DecimalInput } from '../common/DecimalInput';
import { parseLocaleDecimal, parseDuration } from '../../utils/parsers';
import { customerAdminService } from '../../services/customerAdmin.service';
import { businessProfileService } from '../../services/businessProfile.service';
import { useLocalizedDate } from '../../hooks/useLocalizedDate';
export interface HoursSectionProps {
customerId: number;
customerHourlyRateMinor: number | null;
billingCadence: 'per_event' | 'monthly' | 'quarterly';
onHourlyRateChange?: (next: number | null) => void;
/**
* When true, render only the entry-history table + the per-event
* "Bill these hours" action. Hides the inline log-entry form and
* the default-rate input. Used on the customer detail page now
* that logging itself lives on the standalone /admin/clients/hours
* surface — the detail page becomes a read-only history view with
* the on-demand bill action for per-event customers.
*/
compact?: boolean;
}
export const HoursSection: React.FC<HoursSectionProps> = ({
customerId, customerHourlyRateMinor, billingCadence, onHourlyRateChange, compact,
}) => {
const { t } = useTranslation();
const qc = useQueryClient();
const { format: fmtDate, formatTime: fmtTime, timeFormat } = useLocalizedDate();
// `lang` hint on <input type="time"> nudges Chrome/Edge to render the
// picker in the matching clock convention (de-DE → 24h, en-US → 12h).
// Safari/Firefox follow OS locale and ignore this — that's a browser
// limitation, not something we can fix in the page. The underlying
// value stays HH:mm (24h) regardless of how the picker presents it.
const timeInputLang = timeFormat === '12h' ? 'en-US' : 'de-DE';
const [entryDate, setEntryDate] = useState(() => new Date().toISOString().slice(0, 10));
const [startTime, setStartTime] = useState('09:00');
const [endTime, setEndTime] = useState('10:00');
const [duration, setDuration] = useState<string>('');
const [rateOverride, setRateOverride] = useState<string>('');
const [description, setDescription] = useState('');
// Duration shortcut — admin types "1.5", "1,5", "1:30" or "1h" and
// the end-time jumps to start + duration. Pure convenience; the End
// input still works for explicit times. Empty / unparseable input is
// a no-op so a typo doesn't overwrite a freshly-edited End.
const applyDuration = (raw: string) => {
const minutes = parseDuration(raw);
if (minutes == null) return;
const [hh, mm] = startTime.split(':').map(Number);
if (!Number.isFinite(hh) || !Number.isFinite(mm)) return;
const totalEnd = Math.min(hh * 60 + mm + minutes, 24 * 60 - 1);
const eh = Math.floor(totalEnd / 60).toString().padStart(2, '0');
const em = (totalEnd % 60).toString().padStart(2, '0');
setEndTime(`${eh}:${em}`);
};
const { data: entries = [], isLoading } = useQuery({
queryKey: ['admin-customer-hour-entries', customerId],
queryFn: () => customerAdminService.listHourEntries(customerId),
enabled: Number.isFinite(customerId) && customerId > 0,
});
// Pull the configured default currency so the hint can show
// "{{currency}} 150" instead of the hardcoded "CHF 150". Same cache
// key as CustomerDetailPage so a single round-trip serves both
// mount points. 5-minute stale window — the value changes via
// Settings → Business profile, not during a hours-logging session.
const { data: profileSnapshot } = useQuery({
queryKey: ['business-profile-snapshot'],
queryFn: () => businessProfileService.get(),
staleTime: 5 * 60 * 1000,
});
const profileDefaultCurrency = profileSnapshot?.profile?.defaultCurrency || 'CHF';
const createMutation = useMutation({
mutationFn: () => customerAdminService.createHourEntry(customerId, {
entryDate, startTime, endTime,
// Locale-tolerant: "12,50" and "12.50" both yield 1250.
hourlyRateMinorOverride: (() => {
if (!rateOverride) return null;
const n = parseLocaleDecimal(rateOverride);
return Number.isFinite(n) && n >= 0 ? Math.round(n * 100) : null;
})(),
description: description || null,
}),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['admin-customer-hour-entries', customerId] });
qc.invalidateQueries({ queryKey: ['admin-customer', customerId] });
setStartTime('09:00');
setEndTime('10:00');
setDuration('');
setRateOverride('');
setDescription('');
toast.success(t('customers.hours.toast.created', 'Entry logged'));
},
onError: (err: any) => {
const msg = err?.response?.data?.error || err?.message || 'Failed to log entry';
toast.error(msg);
},
});
const deleteMutation = useMutation({
mutationFn: (entryId: number) => customerAdminService.deleteHourEntry(customerId, entryId),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['admin-customer-hour-entries', customerId] });
qc.invalidateQueries({ queryKey: ['admin-customer', customerId] });
toast.success(t('customers.hours.toast.deleted', 'Entry deleted'));
},
onError: (err: any) => {
toast.error(err?.response?.data?.error || 'Failed to delete entry');
},
});
const billMutation = useMutation({
mutationFn: () => customerAdminService.billUnbilledHourEntries(customerId),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['admin-customer-hour-entries', customerId] });
qc.invalidateQueries({ queryKey: ['admin-customer', customerId] });
toast.success(t('customers.hours.toast.billed', 'Hours billed'));
},
onError: (err: any) => {
toast.error(err?.response?.data?.error || 'Failed to bill hours');
},
});
// Single pass — both the count and the money total live behind the
// same filter. Memoised so a parent re-render (e.g. the
// CustomerDetailPage form state changing) doesn't reshuffle the
// entries array in JS on every keystroke.
const { unbilledCount, unbilledTotalMajor } = useMemo(() => {
let count = 0;
let minor = 0;
for (const e of entries) {
if (e.status !== 'unbilled') continue;
count += 1;
const rateMinor = e.hourlyRateMinorOverride ?? customerHourlyRateMinor ?? 0;
minor += rateMinor * e.durationMinutes / 60;
}
return { unbilledCount: count, unbilledTotalMajor: minor / 100 };
}, [entries, customerHourlyRateMinor]);
const isMonthly = billingCadence === 'monthly';
// Local lockout check — mirrors customerHoursService.isEntryLocked
// so the delete button can be disabled before the request is sent.
const isLocked = (entry: typeof entries[number]) => {
if (!entry.invoiceId) return false;
if (entry.invoiceIsMonthlyDraft) return false;
if (entry.invoiceStatus !== 'scheduled') return true;
if (!entry.invoiceScheduledSendAt) return false;
return new Date(entry.invoiceScheduledSendAt).getTime() <= Date.now();
};
return (
<Card padding="lg">
<h2 className="text-lg font-semibold text-theme mb-1 flex items-center gap-2">
<Clock className="w-5 h-5" />
{t('customers.hours.section', 'Hours')}
</h2>
<p className="text-xs text-muted-theme mb-4">
{isMonthly
? t('customers.hours.monthlyHint',
'Entries auto-append to the current monthly draft. Edit / delete remains possible until the scheduler arms the draft for send.')
: t('customers.hours.perEventHint',
'Logged entries stay unbilled until you click "Create draft invoice" — a standalone draft invoice is generated with one line per entry, ready for you to review before sending.')}
</p>
{/* Default rate — hidden in compact mode (history-only on the
customer detail page; admin edits the rate elsewhere). */}
{!compact && (
<div className="mb-4">
<label className="block text-sm font-medium text-theme mb-1">
{t('customers.field.hourlyRate', 'Default hourly rate')}
</label>
<DecimalInput
value={customerHourlyRateMinor != null ? customerHourlyRateMinor / 100 : NaN}
fractionDigits={2}
onChange={(n) => {
if (!onHourlyRateChange) return;
if (!Number.isFinite(n)) {
onHourlyRateChange(null);
return;
}
onHourlyRateChange(Math.max(0, Math.round(n * 100)));
}}
disabled={!onHourlyRateChange}
className="w-40 input"
placeholder="150.00"
/>
<p className="text-xs text-muted-theme mt-1">
{t('customers.field.hourlyRateHint',
'Major units (e.g. 150.00 for {{currency}} 150). Leave blank to require a per-entry override on every block.',
{ currency: profileDefaultCurrency })}
</p>
</div>
)}
{/* Inline log-new-entry form — hidden in compact mode. Logging
lives on the standalone /admin/clients/hours surface. */}
{!compact && (
<div className="border-t border-neutral-200 dark:border-neutral-700 pt-4 mb-4">
<h3 className="text-sm font-semibold mb-3">{t('customers.hours.form.title', 'Log new entry')}</h3>
<div className="grid grid-cols-2 md:grid-cols-6 gap-3">
<div>
<label className="block text-xs text-muted-theme mb-1">
{t('customers.hours.form.date', 'Date')}
</label>
<input type="date" value={entryDate}
onChange={(e) => setEntryDate(e.target.value)} className="input w-full" />
</div>
<div>
<label className="block text-xs text-muted-theme mb-1">
{t('customers.hours.form.start', 'Start')}
</label>
<input type="time" lang={timeInputLang} value={startTime}
onChange={(e) => setStartTime(e.target.value)} className="input w-full" />
</div>
<div>
<label className="block text-xs text-muted-theme mb-1">
{t('customers.hours.form.end', 'End')}
</label>
<input type="time" lang={timeInputLang} value={endTime}
onChange={(e) => setEndTime(e.target.value)} className="input w-full" />
</div>
<div>
<label className="block text-xs text-muted-theme mb-1">
{t('customers.hours.form.duration', 'Duration')}
</label>
<input
type="text"
inputMode="decimal"
value={duration}
onChange={(e) => setDuration(e.target.value)}
onBlur={(e) => applyDuration(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
applyDuration((e.target as HTMLInputElement).value);
}
}}
placeholder={t('customers.hours.form.durationPlaceholder', '1h · 1.5 · 1:30') as string}
title={t('customers.hours.form.durationHint',
'Type a duration to auto-fill End: 1h, 1.5, 1,5 or 1:30') as string}
className="input w-full" />
</div>
<div>
<label className="block text-xs text-muted-theme mb-1">
{t('customers.hours.form.rateOverride', 'Rate override')}
</label>
<input
type="text"
inputMode="decimal"
value={rateOverride}
onChange={(e) => setRateOverride(e.target.value)}
placeholder={customerHourlyRateMinor != null
? (customerHourlyRateMinor / 100).toFixed(2)
: '—'}
className="input w-full" />
</div>
</div>
<div className="mt-3">
<label className="block text-xs text-muted-theme mb-1">
{t('customers.hours.form.note', 'Note / description')}
</label>
<textarea rows={2} value={description}
onChange={(e) => setDescription(e.target.value)}
className="input w-full text-sm"
placeholder={t('customers.hours.form.notePlaceholder',
'What was worked on?') as string} />
</div>
<div className="mt-3 flex justify-end">
<Button
variant="primary"
disabled={createMutation.isPending}
isLoading={createMutation.isPending}
onClick={() => createMutation.mutate()}
>
{t('customers.hours.form.save', 'Add entry')}
</Button>
</div>
</div>
)}
{/* Bill-these-hours button for per-event customers only. Stays
visible in compact mode so the customer-detail page can
still trigger the on-demand billing action. */}
{!isMonthly && unbilledCount > 0 && (
<div className="mb-4 flex items-center justify-between bg-blue-50 dark:bg-blue-900/20 rounded p-3">
<span className="text-sm">
{t('customers.hours.unbilledCount',
'{{count}} unbilled entries totaling {{total}}',
{
count: unbilledCount,
total: unbilledTotalMajor.toFixed(2),
})}
</span>
<Button
variant="primary"
disabled={billMutation.isPending}
isLoading={billMutation.isPending}
onClick={() => billMutation.mutate()}
>
{t('customers.hours.billButton', 'Create draft invoice')}
</Button>
</div>
)}
{/* Entry list table. */}
{isLoading ? (
<p className="text-sm text-muted-theme">{t('common.loading', 'Loading…')}</p>
) : entries.length === 0 ? (
<p className="text-sm text-muted-theme">
{t('customers.hours.empty', 'No entries logged yet.')}
</p>
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="text-left text-xs uppercase text-muted-theme">
<th className="py-2 pr-3">{t('customers.hours.col.date', 'Date')}</th>
<th className="py-2 pr-3">{t('customers.hours.col.range', 'Time')}</th>
<th className="py-2 pr-3 text-right">{t('customers.hours.col.hours', 'Hours')}</th>
<th className="py-2 pr-3 text-right">{t('customers.hours.col.rate', 'Rate')}</th>
<th className="py-2 pr-3 text-right">{t('customers.hours.col.total', 'Total')}</th>
<th className="py-2 pr-3">{t('customers.hours.col.note', 'Note')}</th>
<th className="py-2 pr-3">{t('customers.hours.col.status', 'Status')}</th>
<th className="py-2 pr-3"></th>
</tr>
</thead>
<tbody>
{entries.map((e) => {
const rate = e.hourlyRateMinorOverride ?? customerHourlyRateMinor ?? 0;
const hours = e.durationMinutes / 60;
const total = (hours * rate) / 100;
const locked = isLocked(e);
return (
<tr key={e.id} className="border-t border-neutral-200 dark:border-neutral-700">
<td className="py-1.5 pr-3 tabular-nums">{fmtDate(e.entryDate)}</td>
<td className="py-1.5 pr-3 tabular-nums">{fmtTime(e.startTime)}{fmtTime(e.endTime)}</td>
<td className="py-1.5 pr-3 text-right tabular-nums">{hours.toFixed(2)}</td>
<td className="py-1.5 pr-3 text-right tabular-nums">{(rate / 100).toFixed(2)}</td>
<td className="py-1.5 pr-3 text-right tabular-nums font-medium">{total.toFixed(2)}</td>
<td className="py-1.5 pr-3 max-w-xs truncate" title={e.description || ''}>
{e.description || '—'}
</td>
<td className="py-1.5 pr-3">
{e.status === 'billed' ? (
<span className="text-xs text-green-700 dark:text-green-300">
{e.invoiceNumber
? t('customers.hours.status.billedOn',
'Billed: {{number}}', { number: e.invoiceNumber })
: t('customers.hours.status.billed', 'Billed')}
</span>
) : (
<span className="text-xs text-amber-700 dark:text-amber-300">
{t('customers.hours.status.unbilled', 'Unbilled')}
</span>
)}
</td>
<td className="py-1.5 pr-3 text-right">
<button
type="button"
disabled={locked || deleteMutation.isPending}
onClick={() => {
if (window.confirm(t('customers.hours.confirmDelete',
'Delete this entry? If it has been billed onto a draft, the matching invoice line will also be removed.') as string)) {
deleteMutation.mutate(e.id);
}
}}
className="text-xs text-red-600 hover:underline disabled:text-neutral-400 disabled:cursor-not-allowed"
title={locked ? t('customers.hours.locked',
'Locked: invoice already armed for send') as string : undefined}
>
{t('common.delete', 'Delete')}
</button>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
</Card>
);
};
export default HoursSection;
@@ -0,0 +1,360 @@
/**
* Inline "+ Create new customer" form mounted inside the quote /
* invoice editor's customer card.
*
* Two save modes:
* - "Save as passive customer" → POST /admin/customers. No email,
* no invitation. The customer becomes a usable record
* immediately for the current quote/invoice.
* - "Save & send portal invitation" → POST /admin/customers
* followed by POST /admin/customers/:id/send-invite. Customer is
* created (passive in DB), then a standard onboarding email is
* queued so they can claim portal access. Orchestrated client-
* side so the backend endpoints stay simple and single-purpose.
*
* If the second call fails after the first succeeds, the customer
* stays saved (passive) and a warning toast asks the admin to retry
* from the customer detail page.
*
* Field set mirrors the customer detail page so admins see the same
* shape regardless of where they're editing.
*/
import React, { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { toast } from 'react-toastify';
import { Save, Send, X } from 'lucide-react';
import { Button, Input } from '../common';
import {
customerAdminService,
type CustomerAccountDetail,
type CustomerInvitePrefill,
} from '../../services/customerAdmin.service';
import { businessProfileService } from '../../services/businessProfile.service';
import { useQuery } from '@tanstack/react-query';
interface Props {
/**
* Fires after a successful save. The customer payload is the same
* shape the customer-detail endpoint returns, so callers can use
* its id/email/company directly to populate the quote/invoice's
* customer pin.
*/
onCreated: (customer: CustomerAccountDetail) => void;
/** Revert the editor card back to the search-only state. */
onCancel: () => void;
/**
* Which save action(s) the form should expose.
* - 'both' (default): renders both buttons — used by the quote /
* invoice editors where the admin picks the mode in-place.
* - 'passive': renders only "Save as passive customer".
* - 'invite': renders only "Save & send portal invitation".
* The "passive" / "invite" specialisations let CustomerManagementPage
* route both header buttons through the same modal — the only
* difference between the two flows is which action button shows.
*/
mode?: 'both' | 'passive' | 'invite';
}
type FormState = {
email: string;
salutation: string;
firstName: string;
lastName: string;
displayName: string;
phone: string;
companyName: string;
vatId: string;
addressLine1: string;
addressLine2: string;
postalCode: string;
city: string;
state: string;
countryCode: string;
preferredLanguage: string;
};
const empty: FormState = {
email: '', salutation: '', firstName: '', lastName: '', displayName: '',
phone: '', companyName: '', vatId: '',
addressLine1: '', addressLine2: '', postalCode: '', city: '', state: '',
countryCode: '', preferredLanguage: '',
};
function buildPrefill(f: FormState): CustomerInvitePrefill {
// Backend's PREFILLABLE_FIELDS uses snake_case. Translate at the
// wire boundary and drop empty strings so the server doesn't store
// "" where null would be more honest.
const out: CustomerInvitePrefill = {};
if (f.salutation) out.salutation = f.salutation;
if (f.firstName) out.first_name = f.firstName;
if (f.lastName) out.last_name = f.lastName;
if (f.displayName) out.display_name = f.displayName;
if (f.phone) out.phone = f.phone;
if (f.companyName) out.company_name = f.companyName;
if (f.vatId) out.vat_id = f.vatId;
if (f.addressLine1) out.address_line1 = f.addressLine1;
if (f.addressLine2) out.address_line2 = f.addressLine2;
if (f.postalCode) out.postal_code = f.postalCode;
if (f.city) out.city = f.city;
if (f.state) out.state = f.state;
if (f.countryCode) out.country_code = f.countryCode.toUpperCase();
if (f.preferredLanguage) out.preferred_language = f.preferredLanguage;
return out;
}
export const InlineCustomerCreate: React.FC<Props> = ({ onCreated, onCancel, mode = 'both' }) => {
const { t } = useTranslation();
const [form, setForm] = useState<FormState>(empty);
const [busy, setBusy] = useState<'passive' | 'invite' | null>(null);
// Resolve a title + subtitle that matches the selected mode. The
// 'both' branch keeps the legacy copy so inline (in-editor) callers
// see the same wording they had before this prop existed.
const heading = mode === 'invite'
? {
title: t('customers.invite.title', 'Invite a customer'),
subtitle: t('customers.invite.description',
'They will receive an email with a link to set up their account. Once they have accepted, you can assign them to events.'),
}
: mode === 'passive'
? {
title: t('customers.create.openButton', 'Create passive customer'),
subtitle: t('customers.create.passiveSubtitle',
'Adds an admin-only customer record. The customer is not notified and cannot log in until you send them an invitation later.'),
}
: {
title: t('customers.create.title', 'Create new customer'),
subtitle: t('customers.create.subtitle',
'Fill in the details below. Choose "Save as passive customer" to create an admin-only record, or "Save & send portal invitation" to also email the customer a sign-up link.'),
};
// Business-profile default locale powers the preferred-language
// hint AND seeds the field on mount.
const { data: profileSnapshot } = useQuery({
queryKey: ['business-profile-snapshot'],
queryFn: () => businessProfileService.get(),
staleTime: 5 * 60 * 1000,
});
const profileDefaultLocale = profileSnapshot?.profile?.defaultLocale || 'en';
// Seed preferredLanguage with the profile default once the profile
// arrives (only if the field is still empty so we don't clobber
// explicit user input).
React.useEffect(() => {
if (profileDefaultLocale && !form.preferredLanguage) {
setForm((prev) => prev.preferredLanguage ? prev : { ...prev, preferredLanguage: profileDefaultLocale });
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [profileDefaultLocale]);
const setField = (key: keyof FormState) =>
(e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>) =>
setForm((prev) => ({ ...prev, [key]: e.target.value }));
const isValid = !!form.email && /\S+@\S+\.\S+/.test(form.email);
const handleSave = async (mode: 'passive' | 'invite') => {
if (!isValid) {
toast.error(t('customers.create.emailRequired', 'A valid email is required.'));
return;
}
setBusy(mode);
try {
const customer = await customerAdminService.createDirect(form.email, buildPrefill(form));
if (mode === 'invite') {
// Customer is now saved as passive. Fire the second call to
// promote them. If THIS fails, keep the customer selected
// (it exists, just no email went out) and warn the admin.
try {
await customerAdminService.sendInvite(customer.id);
toast.success(t('customers.create.savedActiveToast',
'Customer created and portal invitation sent.'));
} catch (err: any) {
toast.warn(t('customers.create.inviteFailedToast',
'Customer saved (passive). Invitation email failed — retry from the customer detail page.'));
// eslint-disable-next-line no-console
console.warn('sendInvite failed', err);
}
} else {
toast.success(t('customers.create.savedPassiveToast',
'Passive customer created.'));
}
onCreated(customer);
} catch (err: any) {
const msg = err?.response?.data?.error || err?.message || t('common.error', 'Something went wrong.');
toast.error(String(msg));
} finally {
setBusy(null);
}
};
return (
<div className="space-y-3">
<div className="flex items-start gap-3 mb-2">
<div>
<h4 className="text-sm font-semibold text-neutral-900 dark:text-neutral-100">
{heading.title}
</h4>
<p className="text-xs text-neutral-600 dark:text-neutral-400 mt-0.5">
{heading.subtitle}
</p>
</div>
<button
type="button"
onClick={onCancel}
className="ml-auto p-1 rounded text-neutral-500 hover:text-neutral-900 dark:hover:text-neutral-100"
aria-label={t('common.cancel', 'Cancel') as string}
>
<X className="w-4 h-4" />
</button>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
<Input
type="email"
label={`${t('customers.detail.email', 'Email')} *`}
value={form.email}
onChange={setField('email')}
placeholder="[email protected]"
required
/>
<Input
label={t('customers.detail.companyName', 'Company name') as string}
value={form.companyName}
onChange={setField('companyName')}
/>
<div>
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">
{t('customers.detail.salutation', 'Salutation')}
</label>
{/* Salutation values are stored verbatim ("Herr", "Frau",
"Mx", "Dr") — canonical tokens across locales. Display
labels are translated; the option's value stays in the
German form so the same key works regardless of which
locale the admin is editing in. Matches CustomerDetailPage. */}
<select
value={form.salutation}
onChange={setField('salutation')}
className="w-full rounded-md border border-neutral-300 dark:border-neutral-600 bg-white dark:bg-neutral-800 px-3 py-2 text-sm text-neutral-900 dark:text-neutral-100"
>
<option value="">{t('customer.profile.salutation.none', '— Not specified —')}</option>
<option value="Herr">{t('customer.profile.salutation.herr', 'Mr.')}</option>
<option value="Frau">{t('customer.profile.salutation.frau', 'Ms.')}</option>
<option value="Mx">{t('customer.profile.salutation.mx', 'Mx')}</option>
<option value="Dr">{t('customer.profile.salutation.dr', 'Dr.')}</option>
</select>
</div>
<Input
label={t('customers.detail.phone', 'Phone') as string}
value={form.phone}
onChange={setField('phone')}
/>
<Input
label={t('customers.detail.firstName', 'First name') as string}
value={form.firstName}
onChange={setField('firstName')}
/>
<Input
label={t('customers.detail.lastName', 'Last name') as string}
value={form.lastName}
onChange={setField('lastName')}
/>
<Input
label={t('customers.detail.displayName', 'Display name') as string}
value={form.displayName}
onChange={setField('displayName')}
/>
<Input
label={t('customers.detail.vatId', 'VAT ID') as string}
value={form.vatId}
onChange={setField('vatId')}
/>
<div className="md:col-span-2">
<Input
label={t('customers.detail.addressLine1', 'Address line 1') as string}
value={form.addressLine1}
onChange={setField('addressLine1')}
/>
</div>
<div className="md:col-span-2">
<Input
label={t('customers.detail.addressLine2', 'Address line 2') as string}
value={form.addressLine2}
onChange={setField('addressLine2')}
/>
</div>
<Input
label={t('customers.detail.postalCode', 'Postal code') as string}
value={form.postalCode}
onChange={setField('postalCode')}
/>
<Input
label={t('customers.detail.city', 'City') as string}
value={form.city}
onChange={setField('city')}
/>
<Input
label={t('customers.detail.state', 'State / canton') as string}
value={form.state}
onChange={setField('state')}
/>
<Input
label={t('customers.detail.countryCode', 'Country (ISO code)') as string}
value={form.countryCode}
onChange={setField('countryCode')}
placeholder="CH"
maxLength={2}
/>
<div className="md:col-span-2">
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">
{t('customers.detail.preferredLanguage', 'Preferred language')}
</label>
<select
value={form.preferredLanguage || ''}
onChange={setField('preferredLanguage')}
className="w-full rounded-md border border-neutral-300 dark:border-neutral-600 bg-white dark:bg-neutral-800 px-3 py-2 text-sm text-neutral-900 dark:text-neutral-100"
>
<option value="en">English</option>
<option value="de">Deutsch</option>
<option value="fr">Français</option>
<option value="nl">Nederlands</option>
<option value="pt">Português</option>
<option value="ru">Русский</option>
</select>
</div>
</div>
<div className="flex flex-wrap items-center justify-end gap-2 pt-2 border-t border-neutral-200 dark:border-neutral-700">
<Button variant="outline" onClick={onCancel} disabled={busy !== null}>
{t('common.cancel', 'Cancel')}
</Button>
{(mode === 'both' || mode === 'passive') && (
<Button
variant={mode === 'passive' ? 'primary' : 'outline'}
onClick={() => handleSave('passive')}
disabled={busy !== null || !isValid}
isLoading={busy === 'passive'}
leftIcon={<Save className="w-4 h-4" />}
>
{/* Mode 'passive' is the dedicated CTA: promote it to the
primary variant so the button hierarchy mirrors what
an admin who opened the modal from "Create passive
customer" expects. */}
{t('customers.create.saveAsPassive', 'Save as passive customer')}
</Button>
)}
{(mode === 'both' || mode === 'invite') && (
<Button
variant="primary"
onClick={() => handleSave('invite')}
disabled={busy !== null || !isValid}
isLoading={busy === 'invite'}
leftIcon={<Send className="w-4 h-4" />}
>
{t('customers.create.saveAndInvite', 'Save & send portal invitation')}
</Button>
)}
</div>
</div>
);
};
@@ -0,0 +1,320 @@
/**
* <InstallmentsPanel> — shared editor surface for the per-document
* installment plan. Used by both QuoteEditorPage and BillEditorPage.
*
* Two render modes:
* - Simple (default): per-row date picker mapped to trigger='fixed_date'
* - Advanced (toggle): per-row trigger dropdown + offset_days
*
* Rows store the canonical {label, percent, trigger, offset_days}
* shape (PaymentTermInstallment from quotes.service.ts). Date pickers
* are a UX convenience layered over `trigger='fixed_date' + offset_days`.
*
* When the panel is "off" (`value: null`), the document is treated as
* a single-invoice / single-payment plan and the parent editor skips
* the installment field on save. Switching the panel on inserts a
* default 100% row pre-populated from `useInstallmentDefaults()`.
*
* Validation: percents must sum to 100 (rendered inline below the
* footer; parent uses `onValidityChange` to disable Save when wrong).
*
* The parent owns the `value` state; this component is fully
* controlled. Render is React-Strict-Mode safe (no state in refs that
* outlives the props).
*/
import React, { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { Trash2, Plus } from 'lucide-react';
import { Button, Input } from '../common';
import type { PaymentTermInstallment } from '../../services/quotes.service';
import { useInstallmentDefaults } from '../../hooks/useInstallmentDefaults';
import { useLocalizedDate } from '../../hooks/useLocalizedDate';
export type InstallmentPlan = PaymentTermInstallment[];
export interface InstallmentsPanelProps {
/** null = single-document mode (no installments). Array = the plan. */
value: InstallmentPlan | null;
onChange: (next: InstallmentPlan | null) => void;
/** Reports whether percents sum to 100. Parent uses to gate Save. */
onValidityChange?: (valid: boolean) => void;
/** Event date used for trigger preview text in advanced mode. */
eventDate?: string | null;
/** Disable inputs (e.g. document is locked / sent). */
disabled?: boolean;
}
const ALL_TRIGGERS: PaymentTermInstallment['trigger'][] = [
'quote_accepted', 'before_event', 'after_event', 'after_delivery', 'fixed_date',
];
function todayIso(): string {
return new Date().toISOString().slice(0, 10);
}
function addDays(base: string, days: number): string {
const d = new Date(base);
d.setDate(d.getDate() + days);
return d.toISOString().slice(0, 10);
}
function daysBetween(from: string, to: string): number {
const f = new Date(from);
const t = new Date(to);
return Math.round((t.getTime() - f.getTime()) / 86_400_000);
}
export const InstallmentsPanel: React.FC<InstallmentsPanelProps> = ({
value, onChange, onValidityChange, eventDate, disabled,
}) => {
const { t } = useTranslation();
const { dateInputLang } = useLocalizedDate();
const defaults = useInstallmentDefaults();
const [advanced, setAdvanced] = React.useState(false);
const enabled = value !== null;
const rows = value || [];
const totalPercent = useMemo(
() => rows.reduce((s, r) => s + (Number(r.percent) || 0), 0),
[rows],
);
const isValid = !enabled || Math.abs(totalPercent - 100) < 0.001;
React.useEffect(() => {
onValidityChange?.(isValid);
}, [isValid, onValidityChange]);
const update = (idx: number, patch: Partial<PaymentTermInstallment>) => {
const next = rows.map((r, i) => (i === idx ? { ...r, ...patch } : r));
onChange(next);
};
const remove = (idx: number) => {
const next = rows.filter((_, i) => i !== idx);
onChange(next.length === 0 ? null : next);
};
const addRow = () => {
// Default-shape new row: use the admin's defaults via a positional
// heuristic — first row inherits the "first installment" trigger,
// middle rows default to before_event, last row inherits after_event.
let trigger: PaymentTermInstallment['trigger'] = defaults.triggerFirst;
let offsetDays = 0;
if (rows.length === 0) {
trigger = defaults.triggerFirst;
offsetDays = 0;
} else {
// Second row onwards. If there's already a row tagged after_event,
// a new one slots in as before_event; else default to after_event.
const hasAfterEvent = rows.some((r) => r.trigger === 'after_event');
trigger = hasAfterEvent ? 'before_event' : 'after_event';
offsetDays = hasAfterEvent ? -defaults.daysBeforeEvent : defaults.daysAfterEvent;
}
// Suggest a percent that fills the gap (clamped to 0-100).
const gap = Math.max(0, 100 - totalPercent);
const next: PaymentTermInstallment = {
label: '',
percent: gap > 0 ? gap : 0,
trigger,
offset_days: offsetDays,
};
onChange([...rows, next]);
};
const toggleEnabled = () => {
if (enabled) {
onChange(null);
return;
}
// Switch on: seed with one 100% row using the "first installment"
// default trigger so the panel doesn't open with an empty list.
onChange([{
label: t('installments.firstRowDefaultLabel', 'Anzahlung') as string,
percent: 100,
trigger: defaults.triggerFirst,
offset_days: 0,
}]);
};
// Map a row to its "Send on" date in simple mode. Only meaningful
// when trigger is fixed_date; for dynamic triggers we display the
// resolved date if we have eventDate, else show a placeholder.
const previewDate = (row: PaymentTermInstallment): string | null => {
const baseline = todayIso();
if (row.trigger === 'fixed_date' || row.trigger === 'quote_accepted') {
return addDays(baseline, row.offset_days || 0);
}
if ((row.trigger === 'before_event' || row.trigger === 'after_event') && eventDate) {
return addDays(eventDate, row.offset_days || 0);
}
return null;
};
return (
<div>
<div className="flex items-center justify-between gap-2 mb-2 flex-wrap">
<label className="flex items-center gap-2 text-sm font-medium cursor-pointer">
<input
type="checkbox"
checked={enabled}
onChange={toggleEnabled}
disabled={disabled}
/>
{t('installments.enableLabel', 'Split into installments')}
</label>
{enabled && (
<button
type="button"
className="text-xs text-primary-600 dark:text-primary-400 hover:underline"
onClick={() => setAdvanced((v) => !v)}
disabled={disabled}
>
{advanced
? t('installments.simpleToggle', 'Use simple date picker')
: t('installments.advancedToggle', 'Use dynamic triggers')}
</button>
)}
</div>
{enabled && (
<>
<p className="text-xs text-muted-theme mb-3">
{advanced
? t('installments.advancedHint',
'Pick a trigger (quote accepted, before/after event, on delivery, fixed date) plus offset in days. Triggers re-resolve if the event date later shifts.')
: t('installments.simpleHint',
'Each row fires on a specific date. Switch to dynamic triggers for plans tied to event date or delivery.')}
</p>
<div className="space-y-2">
{rows.map((row, idx) => (
<div
key={idx}
className="grid grid-cols-12 gap-2 items-end p-2 rounded-md bg-neutral-50 dark:bg-neutral-800/40"
>
<div className="col-span-2">
<label className="block text-xs text-muted-theme mb-1">
{t('installments.percent', '%')}
</label>
<Input
type="number"
min={0}
max={100}
step="0.01"
value={row.percent}
onChange={(e) => update(idx, { percent: Number(e.target.value) })}
disabled={disabled}
/>
</div>
<div className="col-span-4">
<label className="block text-xs text-muted-theme mb-1">
{t('installments.label', 'Label')}
</label>
<Input
value={row.label}
onChange={(e) => update(idx, { label: e.target.value })}
disabled={disabled}
placeholder={t('installments.labelPlaceholder', 'Anzahlung / vor Event …') as string}
/>
</div>
{!advanced ? (
<div className="col-span-5">
<label className="block text-xs text-muted-theme mb-1">
{t('installments.sendOn', 'Send on')}
</label>
{row.trigger === 'after_delivery' ? (
<div className="text-xs text-muted-theme py-2">
{t('installments.onDeliveryHint',
'On delivery — admin releases manually. Switch to advanced to change.')}
</div>
) : (
<Input
type="date"
lang={dateInputLang}
value={previewDate(row) || ''}
onChange={(e) => {
const next = e.target.value;
if (!next) return;
const offset = daysBetween(todayIso(), next);
update(idx, { trigger: 'fixed_date', offset_days: offset });
}}
disabled={disabled}
/>
)}
</div>
) : (
<>
<div className="col-span-3">
<label className="block text-xs text-muted-theme mb-1">
{t('installments.trigger', 'Trigger')}
</label>
<select
value={row.trigger}
onChange={(e) => update(idx, {
trigger: e.target.value as PaymentTermInstallment['trigger'],
offset_days: e.target.value === 'after_delivery' ? 0 : row.offset_days,
})}
disabled={disabled}
className="w-full px-3 py-2 rounded-md border border-neutral-300 dark:border-neutral-600 bg-white dark:bg-neutral-800 text-sm"
>
{ALL_TRIGGERS.map((tr) => (
<option key={tr} value={tr}>
{t(`installments.triggerOption.${tr}`, tr)}
</option>
))}
</select>
</div>
<div className="col-span-2">
<label className="block text-xs text-muted-theme mb-1">
{t('installments.offsetDays', 'Offset (days)')}
</label>
<Input
type="number"
value={row.offset_days}
onChange={(e) => update(idx, { offset_days: Number(e.target.value) })}
disabled={disabled || row.trigger === 'after_delivery'}
/>
</div>
</>
)}
<div className="col-span-1 flex justify-end">
<button
type="button"
onClick={() => remove(idx)}
disabled={disabled}
className="p-2 rounded hover:bg-neutral-200 dark:hover:bg-neutral-700 text-red-600"
aria-label={t('installments.removeRow', 'Remove row') as string}
>
<Trash2 className="w-4 h-4" />
</button>
</div>
</div>
))}
</div>
<div className="flex items-center justify-between mt-3">
<Button
type="button"
variant="outline"
size="sm"
onClick={addRow}
disabled={disabled || totalPercent >= 100}
leftIcon={<Plus className="w-4 h-4" />}
>
{t('installments.addRow', 'Add installment')}
</Button>
<div className={`text-sm font-medium ${isValid ? 'text-green-700 dark:text-green-300' : 'text-red-700 dark:text-red-300'}`}>
{t('installments.total', 'Total')}: {totalPercent.toFixed(2)}%
{!isValid && `${t('installments.mustSumTo100', 'must sum to 100%')}`}
</div>
</div>
</>
)}
</div>
);
};
export default InstallmentsPanel;
@@ -0,0 +1,481 @@
/**
* Reusable line-items editor for quotes + invoices.
*
* Two-level hierarchy (migration 119):
* - Top-level items roll into the document net/VAT/total.
* - Sub-items render indented under their parent. Their line total
* is shown in parentheses for transparency but is display-only;
* only the parent's price contributes to net.
* - Per-item `detailsText` is an optional free-form notes block
* rendered below the description on the PDF + customer view.
*
* Items in `items` are kept in DISPLAY ORDER (parent immediately
* followed by its sub-items, then the next parent, etc.). `position`
* is a stable unique identifier used to link sub-items to parents in
* the payload — once assigned at row creation we never renumber it.
* Move up/down only swaps within the same level (top-level among
* top-level, sub-items among siblings of the same parent).
*
* Money values are stored in MAJOR units in the form state (e.g. 250.00)
* for editor ergonomics, then converted to minor (25000) when persisting.
* The conversion happens at the save boundary in the parent page.
*/
import React, { useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Plus, X, ArrowUp, ArrowDown, Save as SaveIcon, ChevronDown, ChevronRight, CornerDownRight } from 'lucide-react';
import { Button } from '../common';
import { DecimalInput } from '../common/DecimalInput';
import { formatMoney } from '../../utils/money';
export interface EditableLineItem {
id?: number;
/** Stable unique identifier used to link sub-items to parents. */
position: number;
quantity: number;
description: string;
/** Stored in major units (CHF / EUR) for UX. */
unitPrice: number;
discountPercent: number;
/** NULL = top-level (rolls into net). Non-null = sub-item under that parent's position. */
parentPosition?: number | null;
/** Optional free-form notes rendered below the description. */
detailsText?: string;
}
export interface LineItemPresetMinimal {
id: number;
name: string;
description: string;
unitPriceMinor: number;
quantityDefault: number;
}
interface Props {
items: EditableLineItem[];
currency: string;
showDiscount?: boolean;
vatRate?: number;
shippingAmount?: number;
onChange: (items: EditableLineItem[]) => void;
presets?: LineItemPresetMinimal[];
onSaveAsPreset?: (item: EditableLineItem) => void;
}
function nextFreshPosition(items: EditableLineItem[]) {
return items.reduce((m, it) => Math.max(m, it.position), 0) + 1;
}
function isSub(li: EditableLineItem) {
return li.parentPosition != null;
}
export const LineItemsTable: React.FC<Props> = ({
items, currency, showDiscount = true, vatRate = 0, shippingAmount = 0,
onChange, presets = [], onSaveAsPreset,
}) => {
const { t } = useTranslation();
// Track which rows have the details textarea expanded. Keyed by
// `position` since that's stable across renders.
const [detailsOpen, setDetailsOpen] = useState<Set<number>>(() => new Set(
items.filter((it) => it.detailsText && it.detailsText.trim().length > 0).map((it) => it.position)
));
const toggleDetails = (pos: number) => {
setDetailsOpen((prev) => {
const next = new Set(prev);
if (next.has(pos)) next.delete(pos); else next.add(pos);
return next;
});
};
const setItem = (idx: number, patch: Partial<EditableLineItem>) => {
const next = items.map((it, i) => (i === idx ? { ...it, ...patch } : it));
onChange(next);
};
const addRow = (preset?: LineItemPresetMinimal) => {
const pos = nextFreshPosition(items);
const next = [...items, {
position: pos,
quantity: preset ? Number(preset.quantityDefault) || 1 : 1,
description: preset ? `${preset.name}${preset.description ? `\n${preset.description}` : ''}` : '',
unitPrice: preset ? Number(preset.unitPriceMinor) / 100 : 0,
discountPercent: 0,
parentPosition: null,
detailsText: '',
}];
onChange(next);
};
/**
* Insert a fresh sub-item immediately AFTER the parent's last
* existing sub-item (or the parent itself if there are none yet).
* Keeps display order grouped: parent → its sub-items → next parent.
*/
const addSubItem = (parentIdx: number) => {
const parent = items[parentIdx];
if (!parent || isSub(parent)) return; // can't nest under a sub-item (1 level deep)
let insertAt = parentIdx + 1;
while (insertAt < items.length && items[insertAt].parentPosition === parent.position) {
insertAt += 1;
}
const newRow: EditableLineItem = {
position: nextFreshPosition(items),
quantity: 1,
description: '',
unitPrice: 0,
discountPercent: 0,
parentPosition: parent.position,
detailsText: '',
};
const next = [...items];
next.splice(insertAt, 0, newRow);
onChange(next);
};
/**
* Remove a row. When removing a top-level parent, also sweep its
* sub-items (CASCADE-equivalent in the editor, matches the DB FK
* cascade so the editor's behaviour matches what would persist).
*/
const removeRow = (idx: number) => {
const target = items[idx];
if (!target) return;
if (!isSub(target)) {
onChange(items.filter((it, i) => i !== idx && it.parentPosition !== target.position));
} else {
onChange(items.filter((_, i) => i !== idx));
}
};
/**
* Move up/down — restricted to siblings of the same level. For
* top-level items, the entire "group" (parent + its sub-items) is
* moved as a unit. For sub-items, the swap is within the same
* parent's children only.
*/
const move = (idx: number, dir: -1 | 1) => {
const target = items[idx];
if (!target) return;
if (isSub(target)) {
// Find sibling sub-items with same parent.
const siblings: number[] = [];
for (let i = 0; i < items.length; i += 1) {
if (items[i].parentPosition === target.parentPosition) siblings.push(i);
}
const here = siblings.indexOf(idx);
const other = here + dir;
if (other < 0 || other >= siblings.length) return;
const next = [...items];
[next[siblings[here]], next[siblings[other]]] = [next[siblings[other]], next[siblings[here]]];
onChange(next);
} else {
// Move top-level group as a block. Find the range of this group
// and the adjacent group's range, then swap them.
const groupStart = idx;
let groupEnd = idx + 1;
while (groupEnd < items.length && items[groupEnd].parentPosition === target.position) {
groupEnd += 1;
}
if (dir === -1) {
if (groupStart === 0) return;
// Find the previous top-level item's group range.
let prevTopIdx = groupStart - 1;
while (prevTopIdx > 0 && isSub(items[prevTopIdx])) prevTopIdx -= 1;
const prevGroupStart = prevTopIdx;
const prevGroupEnd = groupStart; // exclusive
const before = items.slice(0, prevGroupStart);
const prevGroup = items.slice(prevGroupStart, prevGroupEnd);
const thisGroup = items.slice(groupStart, groupEnd);
const after = items.slice(groupEnd);
onChange([...before, ...thisGroup, ...prevGroup, ...after]);
} else {
if (groupEnd >= items.length) return;
const nextTopIdx = groupEnd; // is a top-level by construction
let nextGroupEnd = nextTopIdx + 1;
while (nextGroupEnd < items.length && isSub(items[nextGroupEnd])) nextGroupEnd += 1;
const before = items.slice(0, groupStart);
const thisGroup = items.slice(groupStart, groupEnd);
const nextGroup = items.slice(nextTopIdx, nextGroupEnd);
const after = items.slice(nextGroupEnd);
onChange([...before, ...nextGroup, ...thisGroup, ...after]);
}
}
};
const rawLineTotal = (li: EditableLineItem) =>
Math.round(li.quantity * li.unitPrice * (1 - li.discountPercent / 100) * 100) / 100;
/**
* A parent has "priced sub-items" when at least one of its
* children has unitPrice > 0. In that mode the parent's own
* unit_price / qty / discount inputs are disabled and its line
* total auto-resolves to the sum of those priced sub-items.
* Matches the backend resolveParentTotalsFromSubItems() rule
* (migration 119) so the editor mirrors what gets persisted.
*/
// D.4 — memoize the per-parent child-pricing aggregates. The previous
// shape rescanned `items` on every call, and the helpers were called
// inside the JSX loop AND from the subtotal reduce — so on a 20-item
// quote each keystroke ran ~O(n²) array scans. Build a Map once per
// render and read O(1) afterwards.
const childPricingByParent = useMemo(() => {
const map = new Map<number, { hasPriced: boolean; pricedSum: number }>();
for (const c of items) {
if (c.parentPosition == null) continue;
if (!(c.unitPrice > 0)) continue;
const cur = map.get(c.parentPosition) || { hasPriced: false, pricedSum: 0 };
cur.hasPriced = true;
cur.pricedSum += rawLineTotal(c);
map.set(c.parentPosition, cur);
}
return map;
// rawLineTotal is a pure function of the closure's `items`; the
// dependency array gets the items snapshot directly.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [items]);
const hasPricedChildren = (parentPos: number) =>
childPricingByParent.get(parentPos)?.hasPriced || false;
const pricedChildrenSum = (parentPos: number) =>
childPricingByParent.get(parentPos)?.pricedSum || 0;
/** Resolved line total: parent auto-sums when sub-items are priced. */
const lineTotal = (li: EditableLineItem) => {
if (!isSub(li) && hasPricedChildren(li.position)) {
return pricedChildrenSum(li.position);
}
return rawLineTotal(li);
};
// Subtotal: top-level items ONLY (their resolved totals). Sub-items
// never roll directly into net — they only feed their parent's
// auto-resolved line total.
const subtotal = items.filter((li) => !isSub(li)).reduce((s, li) => s + lineTotal(li), 0);
const vatAmount = Math.round(subtotal * vatRate) / 100;
const total = subtotal + vatAmount + (Number(shippingAmount) || 0);
// Display numbering: top-level items get 1, 2, 3...; sub-items
// render as N.1, N.2 under the parent for clarity.
const displayNumbers = (() => {
const out: string[] = [];
let topCount = 0;
let subCount = 0;
for (const li of items) {
if (!isSub(li)) {
topCount += 1;
subCount = 0;
out.push(String(topCount));
} else {
subCount += 1;
out.push(`${topCount}.${subCount}`);
}
}
return out;
})();
return (
<div className="space-y-3">
<div className="overflow-x-auto rounded-lg border border-neutral-200 dark:border-neutral-700">
<table className="w-full text-sm">
<thead className="bg-neutral-50 dark:bg-neutral-800 text-neutral-700 dark:text-neutral-300">
<tr>
<th className="px-2 py-2 text-left w-14">{t('crm.lineItems.position', 'Pos.')}</th>
<th className="px-2 py-2 text-left w-20">{t('crm.lineItems.quantity', 'Anzahl')}</th>
<th className="px-2 py-2 text-left">{t('crm.lineItems.description', 'Beschreibung')}</th>
<th className="px-2 py-2 text-right w-28">{t('crm.lineItems.unitPrice', 'Einzelpreis')}</th>
{showDiscount && (
<th className="px-2 py-2 text-right w-24">{t('crm.lineItems.discount', 'Rabatt %')}</th>
)}
<th className="px-2 py-2 text-right w-28">{t('crm.lineItems.total', 'Summe')}</th>
<th className="px-2 py-2 w-28"></th>
</tr>
</thead>
<tbody>
{items.map((li, idx) => {
const sub = isSub(li);
const open = detailsOpen.has(li.position);
// Parent is "auto-totaled" when at least one of its
// sub-items has a price. In that mode the qty / unit
// price / discount inputs are disabled — the parent's
// total is the sum of priced sub-items, computed by
// the backend on save.
const parentAutoTotaled = !sub && hasPricedChildren(li.position);
const disabledInputClass = parentAutoTotaled
? 'bg-neutral-100 dark:bg-neutral-700 text-neutral-400 cursor-not-allowed'
: 'bg-white dark:bg-neutral-800';
return (
<React.Fragment key={li.position}>
<tr className={`border-t border-neutral-200 dark:border-neutral-700 ${
sub ? 'bg-neutral-50/60 dark:bg-neutral-900/40' : ''
}`}>
<td className="px-2 py-2 text-neutral-600 dark:text-neutral-400 align-top">
<div className="flex items-center gap-1">
{sub && <CornerDownRight className="w-3.5 h-3.5 text-neutral-400" aria-hidden />}
<span>{displayNumbers[idx]}</span>
</div>
</td>
<td className="px-2 py-2 align-top">
<DecimalInput
className={`w-20 rounded border border-neutral-300 dark:border-neutral-600 px-2 py-1 text-sm ${disabledInputClass}`}
value={li.quantity}
onChange={(n) => setItem(idx, { quantity: Number.isFinite(n) ? n : 0 })}
disabled={parentAutoTotaled}
title={parentAutoTotaled ? t('crm.lineItems.autoTotaledHint', 'Total auto-computed from sub-items below') as string : undefined}
/>
</td>
<td className={`px-2 py-2 align-top ${sub ? 'pl-6' : ''}`}>
<textarea
rows={2}
className="w-full rounded border border-neutral-300 dark:border-neutral-600 bg-white dark:bg-neutral-800 px-2 py-1 text-sm"
value={li.description}
onChange={(e) => setItem(idx, { description: e.target.value })}
placeholder={t('crm.lineItems.descriptionPlaceholder', 'Description (multi-line OK)') as string}
/>
<button
type="button"
onClick={() => toggleDetails(li.position)}
className="mt-1 inline-flex items-center gap-1 text-xs text-neutral-500 hover:text-neutral-700 dark:hover:text-neutral-300"
>
{open
? <ChevronDown className="w-3.5 h-3.5" aria-hidden />
: <ChevronRight className="w-3.5 h-3.5" aria-hidden />}
<span>
{(li.detailsText && li.detailsText.trim().length > 0)
? t('crm.lineItems.detailsFilled', 'Details')
: t('crm.lineItems.detailsAdd', '+ Add details / notes')}
</span>
</button>
{open && (
<textarea
rows={2}
maxLength={2000}
className="mt-2 w-full rounded border border-neutral-300 dark:border-neutral-600 bg-white dark:bg-neutral-800 px-2 py-1 text-xs italic"
value={li.detailsText || ''}
onChange={(e) => setItem(idx, { detailsText: e.target.value })}
placeholder={t('crm.lineItems.detailsPlaceholder', 'Optional notes — fine print, package inclusions, conditions…') as string}
/>
)}
</td>
<td className="px-2 py-2 align-top">
<DecimalInput
className={`w-24 rounded border border-neutral-300 dark:border-neutral-600 px-2 py-1 text-sm text-right ${disabledInputClass}`}
value={li.unitPrice}
fractionDigits={2}
onChange={(n) => setItem(idx, { unitPrice: Number.isFinite(n) ? n : 0 })}
disabled={parentAutoTotaled}
title={parentAutoTotaled ? t('crm.lineItems.autoTotaledHint', 'Total auto-computed from sub-items below') as string : undefined}
/>
</td>
{showDiscount && (
<td className="px-2 py-2 align-top">
<DecimalInput
className={`w-20 rounded border border-neutral-300 dark:border-neutral-600 px-2 py-1 text-sm text-right ${disabledInputClass}`}
value={li.discountPercent}
onChange={(n) => {
// Clamp to 0..100 — match the original input's min/max.
const clamped = !Number.isFinite(n) ? 0 : Math.max(0, Math.min(100, n));
setItem(idx, { discountPercent: clamped });
}}
disabled={parentAutoTotaled}
title={parentAutoTotaled ? t('crm.lineItems.autoTotaledHint', 'Total auto-computed from sub-items below') as string : undefined}
/>
</td>
)}
<td className={`px-2 py-2 text-right tabular-nums align-top ${
sub
? 'text-neutral-500 dark:text-neutral-400 italic'
: 'font-medium'
}`}>
{sub
? li.unitPrice > 0
? `(${formatMoney(lineTotal(li), currency)})`
: ''
: formatMoney(lineTotal(li), currency)}
{parentAutoTotaled && (
<div className="text-[10px] font-normal text-neutral-500 dark:text-neutral-400 italic mt-0.5">
{t('crm.lineItems.autoTotaledNote', '= Σ Unterpositionen') as string}
</div>
)}
</td>
<td className="px-2 py-2 align-top">
<div className="flex items-center gap-1 justify-end flex-wrap">
<button type="button" onClick={() => move(idx, -1)} aria-label="Move up"
className="p-1 rounded hover:bg-neutral-100 dark:hover:bg-neutral-700 disabled:opacity-30">
<ArrowUp className="w-4 h-4" />
</button>
<button type="button" onClick={() => move(idx, 1)} aria-label="Move down"
className="p-1 rounded hover:bg-neutral-100 dark:hover:bg-neutral-700 disabled:opacity-30">
<ArrowDown className="w-4 h-4" />
</button>
{!sub && (
<button type="button" onClick={() => addSubItem(idx)} aria-label="Add sub-item"
className="p-1 rounded hover:bg-neutral-100 dark:hover:bg-neutral-700"
title={t('crm.lineItems.addSubItem', 'Add sub-item') as string}>
<CornerDownRight className="w-4 h-4" />
</button>
)}
{onSaveAsPreset && !sub && (
<button type="button" onClick={() => onSaveAsPreset(li)} aria-label="Save as preset"
className="p-1 rounded hover:bg-neutral-100 dark:hover:bg-neutral-700"
title={t('crm.lineItems.saveAsPreset', 'Save as preset') as string}>
<SaveIcon className="w-4 h-4" />
</button>
)}
<button type="button" onClick={() => removeRow(idx)} aria-label="Remove"
className="p-1 rounded hover:bg-red-50 dark:hover:bg-red-900/30 text-red-600">
<X className="w-4 h-4" />
</button>
</div>
</td>
</tr>
</React.Fragment>
);
})}
{items.length === 0 && (
<tr><td colSpan={showDiscount ? 7 : 6} className="px-2 py-6 text-center text-neutral-500">
{t('crm.lineItems.empty', 'No line items yet — add one to get started.')}
</td></tr>
)}
</tbody>
</table>
</div>
<div className="flex flex-wrap items-center gap-2">
<Button type="button" variant="outline" size="sm" onClick={() => addRow()}>
<Plus className="w-4 h-4 mr-1" />{t('crm.lineItems.addRow', 'Add row')}
</Button>
{presets.length > 0 && (
<select
className="text-sm rounded-md border border-neutral-300 dark:border-neutral-600 bg-white dark:bg-neutral-800 px-3 py-1.5"
onChange={(e) => {
const id = parseInt(e.target.value, 10);
const preset = presets.find((p) => p.id === id);
if (preset) addRow(preset);
e.target.value = '';
}}
defaultValue=""
>
<option value="" disabled>{t('crm.lineItems.addFromPreset', 'Add from preset…')}</option>
{presets.map((p) => (
<option key={p.id} value={p.id}>{p.name}</option>
))}
</select>
)}
</div>
<div className="flex flex-col items-end gap-1 text-sm pt-2 border-t border-neutral-200 dark:border-neutral-700">
<div className="flex gap-6"><span className="text-neutral-600 dark:text-neutral-400">{t('crm.lineItems.subtotal', 'Subtotal')}:</span><span className="tabular-nums w-28 text-right">{formatMoney(subtotal, currency)}</span></div>
<div className="flex gap-6"><span className="text-neutral-600 dark:text-neutral-400">{t('crm.lineItems.vat', 'VAT')} ({(vatRate * 100).toFixed(1)}%):</span><span className="tabular-nums w-28 text-right">{formatMoney(vatAmount, currency)}</span></div>
{!!shippingAmount && (
<div className="flex gap-6"><span className="text-neutral-600 dark:text-neutral-400">{t('crm.lineItems.shipping', 'Shipping')}:</span><span className="tabular-nums w-28 text-right">{formatMoney(shippingAmount, currency)}</span></div>
)}
<div className="flex gap-6 font-semibold text-base"><span>{t('crm.lineItems.total', 'Total')}:</span><span className="tabular-nums w-28 text-right">{formatMoney(total, currency)}</span></div>
</div>
</div>
);
};
// `formatMoney` is now the canonical helper from utils/money. Re-exported
// here so call-sites that historically imported from this file
// (CustomerCrmPanels, page-level summaries) keep working without churn.
export { formatMoney };
@@ -0,0 +1,102 @@
/**
* Settings → Branding card: lets the admin pick the font used on every
* PDF (quotes, invoices, tax report) from the bundled families. Sits
* directly beneath the web Typography customizer card on
* `BrandingPage`, inside the left column — matches the typography
* box width so the two visually pair.
*
* Controlled component. State + persistence live on the parent so the
* page's top-level "Save changes" button writes this together with
* the rest of the branding form (no card-local save button).
*
* Loads the same `/public/fonts` list the web font picker consumes,
* so any family bundled in `backend/assets/fonts/` shows up here too
* automatically. The selected value persists to
* `business_profile.pdf_font_family` and pdfService maps it to
* `<family>/400.ttf` (body) + `<family>/700.ttf` (bold) at render
* time.
*
* Hidden by the caller when no PDF-producing feature is enabled
* (quotes / bills / taxReport all off) — when there's no PDF surface
* the setting is irrelevant.
*/
import React from 'react';
import { useTranslation } from 'react-i18next';
import { useQuery } from '@tanstack/react-query';
import { Type } from 'lucide-react';
import { Card } from '../common';
import { fontsService } from '../../services/fonts.service';
/**
* The bundled-fonts API returns the DISPLAY name (e.g. "Playfair
* Display") but pdfService resolves a DIRECTORY name (e.g.
* "Playfair-Display"). They're always related by space ↔ hyphen.
* The helpers below convert between them so the dropdown can show
* a clean human label while persisting the on-disk identifier.
*/
const familyToDirectory = (family: string) => family.replace(/ /g, '-');
const directoryToFamily = (dir: string) => dir.replace(/-/g, ' ');
export interface PdfTypographyCardProps {
/** Directory name (e.g. "Inter", "Playfair-Display") or null/""
* for "Use Helvetica (default)". */
value: string | null;
onChange: (value: string | null) => void;
}
export const PdfTypographyCard: React.FC<PdfTypographyCardProps> = ({ value, onChange }) => {
const { t } = useTranslation();
// Same query the web typography picker consumes — single source of
// truth for "which bundled families exist on disk".
const { data: availableFonts } = useQuery({
queryKey: ['fonts'],
queryFn: () => fontsService.list(),
staleTime: 60 * 60 * 1000, // fonts don't change at runtime
});
const selection = value || '';
return (
<Card className="p-6">
<h3 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100 mb-1 flex items-center gap-2">
<Type className="w-5 h-5" />
{t('branding.pdfTypography', 'PDF typography')}
</h3>
<p className="text-sm text-neutral-600 dark:text-neutral-400 mb-4">
{t('branding.pdfTypographyHelp',
'Used for invoice + quote letterheads. Pick one of the bundled fonts, or leave on default to use Helvetica.')}
</p>
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-2">
{t('branding.pdfFontFamily', 'Body font')}
</label>
<select
value={selection}
onChange={(e) => onChange(e.target.value ? e.target.value : null)}
className="w-full px-3 py-2 border border-neutral-300 dark:border-neutral-600 rounded-lg bg-white dark:bg-neutral-800 text-neutral-900 dark:text-neutral-100"
>
<option value="">
{t('branding.pdfFontFamilyDefault', 'Use Helvetica (default)')}
</option>
{(availableFonts || []).map((f) => (
<option key={f.family} value={familyToDirectory(f.family)}>
{/* Show the display name (with spaces) — but persist
the directory name (with hyphens) so pdfService can
find the on-disk family without an extra lookup. */}
{f.family}
</option>
))}
{/* When the saved value points at a family that's no
longer on disk (e.g. uploaded by an earlier admin,
later removed), still show it so the admin sees what
they have rather than silently re-mapping to default. */}
{selection && !(availableFonts || []).some((f) => familyToDirectory(f.family) === selection) && (
<option value={selection}>
{directoryToFamily(selection)} ({t('branding.pdfFontFamilyMissing', 'missing')})
</option>
)}
</select>
</Card>
);
};
@@ -66,6 +66,12 @@ interface ThemeCustomizerEnhancedProps {
// only the colour tokens swap, layout/header/typography stay put so an
// admin who's already arranged the structure can pull just the palette.
onSyncFromBranding?: () => void;
// Optional render slot inserted between the Typography & Style /
// CSS Templates section and the Event-specific Custom CSS card.
// Used by BrandingPage to slot in unrelated cards (PDF typography)
// so they live with the other typography choices rather than after
// the always-bulky Custom CSS editor.
slotBeforeCustomCss?: React.ReactNode;
}
/**
@@ -174,7 +180,8 @@ export const ThemeCustomizerEnhanced: React.FC<ThemeCustomizerEnhancedProps> = (
onCssTemplateChange,
forceColorMode,
onForceColorModeChange,
onSyncFromBranding
onSyncFromBranding,
slotBeforeCustomCss
}) => {
const { t } = useTranslation();
const [localTheme, setLocalTheme] = useState<ThemeConfig>(value);
@@ -1364,6 +1371,11 @@ export const ThemeCustomizerEnhanced: React.FC<ThemeCustomizerEnhancedProps> = (
</Card>
)}
{/* Caller-provided slot — used by BrandingPage to keep the
PDF typography card adjacent to the web typography section
instead of trailing the (often-collapsed) Custom CSS block. */}
{slotBeforeCustomCss}
{/* Event-specific Custom CSS */}
<Card className="p-6">
<h3 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100 mb-4 flex items-center gap-2">
@@ -0,0 +1,108 @@
import { useEffect, useRef, useState } from 'react';
import { parseLocaleDecimal } from '../../utils/parsers';
/**
* DecimalInput — controlled text input that accepts both '.' and ','
* as the decimal separator. Bubbles a parsed `number` up via
* `onChange`, but keeps the raw typed string in local state so the
* user can see their own keystrokes (German "12,5" stays "12,5"
* during editing instead of being instantly reformatted to "12.5").
*
* Why not `type="number"`? Chrome/Firefox `<input type=number>` reject
* comma input on non-DE document.lang and round-trip everything
* through `parseFloat`, which silently truncates "12,50" to 12. The
* audit found this eating real money on monthly bills typed by the
* operator in German. Switching to `type="text"` plus
* `inputMode="decimal"` keeps the mobile keypad and re-routes parsing
* through the locale-tolerant helper.
*
* Value-prop sync: the parent owns the canonical number. When the
* parent pushes a value that disagrees with what's in our local text
* (after parsing the local text back to a number), we reformat. This
* lets external updates (preset selection, autocompute) reflect in
* the field while still preserving the user's in-progress typing.
*/
export interface DecimalInputProps extends Omit<React.InputHTMLAttributes<HTMLInputElement>, 'value' | 'onChange' | 'type' | 'inputMode'> {
/** Canonical numeric value. May be NaN when the field is empty. */
value: number;
/** Bubbled on every parseable keystroke. Receives NaN when the input is cleared. */
onChange: (next: number) => void;
/**
* Number of fraction digits to display when reformatting from a
* parent-pushed value. Defaults to undefined which uses the
* platform default (`Number.prototype.toString`).
*/
fractionDigits?: number;
/** Optional formatter override — receives the number, returns the display string. */
formatValue?: (value: number) => string;
}
function defaultFormat(value: number, fractionDigits?: number): string {
if (!Number.isFinite(value)) return '';
if (fractionDigits !== undefined) return value.toFixed(fractionDigits);
return String(value);
}
export function DecimalInput({
value,
onChange,
fractionDigits,
formatValue,
...rest
}: DecimalInputProps) {
const fmt = (v: number) => (formatValue ? formatValue(v) : defaultFormat(v, fractionDigits));
const [text, setText] = useState<string>(() => fmt(value));
const lastEmittedRef = useRef<number>(value);
// Sync from the parent when the externally-owned value disagrees
// with our locally-buffered text. We compare on the parsed number,
// not the string, so the user typing "12," doesn't snap back to
// "12" just because the parent saw 12 from the last keystroke.
useEffect(() => {
const parsed = parseLocaleDecimal(text);
const matchesParsed = Number.isFinite(parsed) && parsed === value;
const bothEmpty = text === '' && !Number.isFinite(value);
if (!matchesParsed && !bothEmpty && value !== lastEmittedRef.current) {
setText(fmt(value));
}
lastEmittedRef.current = value;
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [value]);
return (
<input
type="text"
inputMode="decimal"
value={text}
onChange={(e) => {
const raw = e.target.value;
setText(raw);
// Empty field → propagate NaN so the parent can decide
// whether to coerce to 0 or treat as "unset".
if (raw.trim() === '') {
lastEmittedRef.current = NaN;
onChange(NaN);
return;
}
const parsed = parseLocaleDecimal(raw);
if (Number.isFinite(parsed)) {
lastEmittedRef.current = parsed;
onChange(parsed);
}
// Unparseable intermediate state (e.g. "12,") — keep the
// text but don't emit. The next keystroke usually completes
// the number and triggers a valid emit.
}}
onBlur={(e) => {
// Reformat on blur so the resting state shows the canonical
// form. Empty stays empty.
const parsed = parseLocaleDecimal(e.target.value);
if (Number.isFinite(parsed)) {
setText(fmt(parsed));
}
rest.onBlur?.(e);
}}
{...rest}
/>
);
}
@@ -34,7 +34,7 @@ interface GalleryLayoutProps {
logo_url?: string;
logo_size?: 'small' | 'medium' | 'large' | 'xlarge' | 'custom';
logo_max_height?: number;
logo_position?: 'left' | 'center' | 'right';
logo_position?: 'left' | 'center' | 'right' | 'sidepanel';
logo_display_header?: boolean;
logo_display_hero?: boolean;
logo_display_mode?: 'logo_only' | 'text_only' | 'logo_and_text';
@@ -174,9 +174,13 @@ export const GalleryLayout: React.FC<GalleryLayoutProps> = ({
};
};
// Determine logo position classes
// Determine logo position classes. 'sidepanel' is an admin-chrome
// option (logo lives in the admin sidebar) and has no analogue in
// the gallery — fall back to 'left' here so customer galleries keep
// showing the logo somewhere sensible.
const getLogoPositionClass = () => {
const position = brandingSettings?.logo_position || 'left';
const raw = brandingSettings?.logo_position || 'left';
const position = raw === 'sidepanel' ? 'left' : raw;
return {
left: 'justify-start',
center: 'justify-center',
+27 -2
View File
@@ -14,12 +14,28 @@
*/
import React, { createContext, useContext, useEffect, useState } from 'react';
import type { ReactNode } from 'react';
import i18n from '../i18n/config';
import { customerService, type CustomerProfile } from '../services/customer.service';
/**
* Apply a customer's preferred language to the portal UI. The admin-set
* `preferred_language` already drives PDF rendering and queued email
* locale resolution; this closes the third surface so the portal UI
* actually honours the same setting. Swallows errors because i18n init
* can race with the auth flow — failing to switch language must never
* break login. Pattern lifted from QuoteResponsePage.tsx.
*/
function applyCustomerLocale(lang?: string | null) {
if (!lang) return;
if (lang === i18n.language) return;
i18n.changeLanguage(lang).catch(() => {});
}
export interface CustomerFeatureFlags {
calendar: boolean;
quotes: boolean;
bills: boolean;
contracts: boolean;
}
export interface CustomerBrandingFlags {
@@ -61,7 +77,7 @@ const STORAGE_KEY = 'customer_profile';
const FEATURES_KEY = 'customer_features';
const BRANDING_KEY = 'customer_branding';
const DEFAULT_FEATURES: CustomerFeatureFlags = { calendar: false, quotes: false, bills: false };
const DEFAULT_FEATURES: CustomerFeatureFlags = { calendar: false, quotes: false, bills: false, contracts: false };
const DEFAULT_BRANDING: CustomerBrandingFlags = { showLogo: true, showCompanyName: true };
interface ProviderProps { children: ReactNode; }
@@ -113,6 +129,7 @@ export const CustomerAuthProvider: React.FC<ProviderProps> = ({ children }) => {
sessionStorage.setItem(STORAGE_KEY, JSON.stringify(response.customer));
sessionStorage.setItem(FEATURES_KEY, JSON.stringify(response.features));
sessionStorage.setItem(BRANDING_KEY, JSON.stringify(response.branding));
applyCustomerLocale(response.customer.preferredLanguage);
} else {
// Explicit 401 — server says no.
setCustomerState(null);
@@ -128,7 +145,13 @@ export const CustomerAuthProvider: React.FC<ProviderProps> = ({ children }) => {
// cookie is still valid and overwrites stale data.
try {
const cached = sessionStorage.getItem(STORAGE_KEY);
if (cached) setCustomerState(JSON.parse(cached));
if (cached) {
const parsed = JSON.parse(cached) as CustomerProfile;
setCustomerState(parsed);
// Apply the cached locale immediately so a hard refresh doesn't
// flash the default language before refreshSession lands.
applyCustomerLocale(parsed.preferredLanguage);
}
const cachedFeatures = sessionStorage.getItem(FEATURES_KEY);
if (cachedFeatures) setFeatures(JSON.parse(cachedFeatures));
const cachedBranding = sessionStorage.getItem(BRANDING_KEY);
@@ -174,6 +197,7 @@ export const CustomerAuthProvider: React.FC<ProviderProps> = ({ children }) => {
const setCustomer = (c: CustomerProfile) => {
setCustomerState(c);
sessionStorage.setItem(STORAGE_KEY, JSON.stringify(c));
applyCustomerLocale(c.preferredLanguage);
};
const setSession = (s: { customer: CustomerProfile; features: CustomerFeatureFlags; branding: CustomerBrandingFlags }) => {
@@ -183,6 +207,7 @@ export const CustomerAuthProvider: React.FC<ProviderProps> = ({ children }) => {
sessionStorage.setItem(STORAGE_KEY, JSON.stringify(s.customer));
sessionStorage.setItem(FEATURES_KEY, JSON.stringify(s.features));
sessionStorage.setItem(BRANDING_KEY, JSON.stringify(s.branding));
applyCustomerLocale(s.customer.preferredLanguage);
};
const logout = async () => {
+28 -2
View File
@@ -11,7 +11,11 @@ export type { FeatureKey, FeatureFlags };
// hasn't run its migration yet on this instance).
export const DEFAULT_FLAGS: FeatureFlags = {
galleries: true,
reminderEmails: true,
// F.3 — these three are placeholders. The UI cards are locked
// (lockedReason: NOT_YET_AVAILABLE) because the underlying flows
// aren't implemented yet. Default FALSE so the toggle isn't
// confusingly "on but locked".
reminderEmails: false,
calendar: false,
calendarBooking: false,
quotes: false,
@@ -28,6 +32,19 @@ export const DEFAULT_FLAGS: FeatureFlags = {
// logins are opt-in. Migration 095 flips this to TRUE on existing
// installs (events>0).
customerPortal: false,
// CRM developer tools sub-tab. Strictly opt-in.
crmDevelopment: false,
// Tax / Steuer report sub-tab. Independent toggle; forced off when
// `bills` is off (no invoices → nothing to report).
taxReport: false,
// Hours logging master (migration 129). Off by default — admin
// enables in Settings → Features once they're ready to surface the
// per-customer Hours card.
hoursLogging: false,
// Contracts (migration 130). Off by default — admin enables in
// Settings → Features once they've reviewed the seeded block
// library with their lawyer.
contracts: false,
};
export const FEATURE_FLAGS_QUERY_KEY = ['feature-flags'] as const;
@@ -59,6 +76,7 @@ function applyDependencyRules(flags: FeatureFlags): FeatureFlags {
out.galleries = true; // foundation — always on
if (out.quotes === false) out.bills = false; // bills depend on quotes
if (out.calendar === false) out.calendarBooking = false; // booking depends on calendar
if (out.bills === false) out.taxReport = false; // tax report depends on bills
// Clients parent flag is DERIVED from its children. Admins don't
// toggle it directly — enabling any CRM-area sub-feature
// (Accounts today; future Calendar / Quotes / Bills / Messaging)
@@ -66,7 +84,15 @@ function applyDependencyRules(flags: FeatureFlags): FeatureFlags {
// disabling all of them hides it again.
out.clients = Boolean(
out.customerPortal
// future siblings: || out.calendar || out.quotes || out.bills || out.messaging
|| out.crmDevelopment
|| out.quotes
|| out.bills
|| out.taxReport
|| out.hoursLogging
|| out.contracts
// Migration 137 — admin calendar lights up the Clients section.
|| out.calendar
// future siblings: || out.messaging
);
return out;
}
@@ -25,6 +25,11 @@ export interface GeneralSettings {
use_original_filenames_for_downloads: boolean;
default_language: string;
date_format: { format: string; locale: string };
/** '12h' or '24h' — controls how times are RENDERED across admin
* + customer-facing views (formatDateTime / formatTime). The
* underlying storage is always HH:mm (24h); only the visual
* presentation toggles. Default '24h' (operator's locale). */
time_format: '12h' | '24h';
}
export interface SecuritySettings {
@@ -101,7 +106,8 @@ export function useSettingsState() {
short_gallery_urls: false,
use_original_filenames_for_downloads: false,
default_language: 'en',
date_format: { format: 'dd/MM/yyyy', locale: 'en-GB' }
date_format: { format: 'dd/MM/yyyy', locale: 'en-GB' },
time_format: '24h'
});
// Security settings state
@@ -192,7 +198,8 @@ export function useSettingsState() {
? (typeof settings.general_date_format === 'string'
? { format: settings.general_date_format, locale: settings.general_date_format.includes('MM/dd') ? 'en-US' : 'en-GB' }
: settings.general_date_format)
: { format: 'dd/MM/yyyy', locale: 'en-GB' }
: { format: 'dd/MM/yyyy', locale: 'en-GB' },
time_format: settings.general_time_format === '12h' ? '12h' : '24h'
});
setSecuritySettings({
@@ -5,6 +5,7 @@ import { toast } from 'react-toastify';
import { KeyRound, Trash2, Copy, AlertTriangle } from 'lucide-react';
import { Button, Card, Input, Loading } from '../../../components/common';
import { api } from '../../../config/api';
import { useLocalizedDate } from '../../../hooks/useLocalizedDate';
interface ApiTokenRow {
id: number;
@@ -28,6 +29,7 @@ const ALL_SCOPES: Array<'read' | 'write' | 'admin'> = ['read', 'write', 'admin']
*/
export const ApiTokensTab: React.FC = () => {
const { t } = useTranslation();
const { formatDateTime: fmtDateTime, format: fmtDate } = useLocalizedDate();
const queryClient = useQueryClient();
const [name, setName] = useState('');
const [scopes, setScopes] = useState<Array<'read' | 'write' | 'admin'>>(['read']);
@@ -205,10 +207,10 @@ export const ApiTokensTab: React.FC = () => {
pp_live_{token.preview || '••••'}
</td>
<td className="py-3 pr-3 text-neutral-500">
{token.last_used_at ? new Date(token.last_used_at).toLocaleString() : '—'}
{token.last_used_at ? fmtDateTime(token.last_used_at) : '—'}
</td>
<td className="py-3 pr-3 text-neutral-500">
{new Date(token.created_at).toLocaleDateString()}
{fmtDate(token.created_at)}
</td>
<td className="py-3 pr-3">
<span className={`text-xs px-2 py-0.5 rounded ${
@@ -8,11 +8,14 @@ import {
MessageSquare,
CalendarDays,
FileSignature,
ScrollText,
Receipt,
BarChart3,
Users,
UserCog,
Briefcase,
Wrench,
Calculator,
} from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { Button, Card } from '../../../components/common';
@@ -115,10 +118,10 @@ export const FeaturesTab: React.FC = () => {
child below is on), so there's no explicit parent toggle —
admins just enable the specific feature they want and the
section appears automatically. */}
<Section title={t('settings.features.sections.clients', 'Clients')}>
<Section title={t('settings.features.sections.clients', 'CRM')}>
<FeatureCard
icon={UserCog}
title={t('settings.features.customerPortal.title', 'Clients')}
title={t('settings.features.customerPortal.title', 'Customer Accounts')}
description={t(
'settings.features.customerPortal.description',
'Persistent customer logins. Recurring clients see all their assigned galleries from one place — no per-event passwords. Customers log in at /customer/login and you manage them under Clients → Accounts.',
@@ -129,7 +132,7 @@ export const FeaturesTab: React.FC = () => {
// admin clicks first, not the deeper "Accounts" sub-nav.
// Keeps the wording consistent with what's actually visible
// in the menu bar.
sidebarLabel={t('navigation.clients', 'Clients')}
sidebarLabel={t('navigation.clients', 'CRM')}
enabled={staged.customerPortal}
onToggle={(next) => setFlag('customerPortal', next)}
/>
@@ -146,10 +149,10 @@ export const FeaturesTab: React.FC = () => {
title={t('settings.features.reminderEmails.title', 'Reminder Emails')}
description={t(
'settings.features.reminderEmails.description',
'Automatic nudges to guests before their gallery expires and to admins about pending uploads.',
'Automatic nudges to guests before their gallery expires and to admins about pending uploads. Coming soon.',
)}
status="stable"
statusLabel={statusLabel('stable')}
status="roadmap"
statusLabel={statusLabel('roadmap')}
sidebarHidden
sidebarHiddenLabel={sidebarHiddenLabel}
enabled={staged.reminderEmails}
@@ -163,10 +166,10 @@ export const FeaturesTab: React.FC = () => {
title={t('settings.features.messaging.title', 'Messaging')}
description={t(
'settings.features.messaging.description',
'In-app threads with guests, attached to a gallery. Email is genuinely fine for most teams — this is for studios that want everything in one place.',
'In-app threads with guests, attached to a gallery. Email is genuinely fine for most teams — this is for studios that want everything in one place. Coming soon.',
)}
status="experimental"
statusLabel={statusLabel('experimental')}
status="roadmap"
statusLabel={statusLabel('roadmap')}
sidebarLabel={t('settings.features.messaging.sidebar', 'Messages')}
enabled={staged.messaging}
onToggle={() => { /* locked */ }}
@@ -182,12 +185,28 @@ export const FeaturesTab: React.FC = () => {
title={t('settings.features.calendar.title', 'Calendar')}
description={t(
'settings.features.calendar.description',
'See all upcoming and past events on a month/week view. Optionally accept new bookings from clients.',
'Admin-only calendar showing events, logged hours, and pending quotes/contracts in one view. Drag-create hours directly on the calendar.',
)}
status="beta"
statusLabel={statusLabel('beta')}
status="new"
statusLabel={statusLabel('new')}
sidebarLabel={t('settings.features.calendar.sidebar', 'Calendar')}
enabled={staged.calendar}
onToggle={(next) => setFlag('calendar', next)}
/>
{/* Customer-facing booking — placeholder. UI-disabled per
spec; backend dependency rule already gates it on
`calendar`. Re-enable when the booking flow ships. */}
<FeatureCard
icon={CalendarDays}
title={t('settings.features.calendarBooking.title', 'Customer booking')}
description={t(
'settings.features.calendarBooking.description',
'Let customers see free slots on a public calendar and book directly. Coming soon.',
)}
status="roadmap"
statusLabel={statusLabel('roadmap')}
sidebarLabel={t('settings.features.calendarBooking.sidebar', 'Booking')}
enabled={staged.calendarBooking}
onToggle={() => { /* locked */ }}
disabled
lockedReason={NOT_YET_AVAILABLE}
@@ -196,6 +215,10 @@ export const FeaturesTab: React.FC = () => {
{/* Sales */}
<Section title={t('settings.features.sections.sales', 'Sales')}>
{/* Quotes + Bills shipped in the CRM PR (#TBD). The old
placeholder lockedReason / disabled props are removed so
the toggles actually save. The sub-page surfaces under
/admin/clients/{quotes,bills} are gated independently. */}
<FeatureCard
icon={FileSignature}
title={t('settings.features.quotes.title', 'Quotes')}
@@ -207,25 +230,68 @@ export const FeaturesTab: React.FC = () => {
statusLabel={statusLabel('new')}
sidebarLabel={t('settings.features.quotes.sidebar', 'Quotes')}
enabled={staged.quotes}
onToggle={() => { /* locked */ }}
disabled
lockedReason={NOT_YET_AVAILABLE}
onToggle={(next) => setFlag('quotes', next)}
/>
<FeatureCard
icon={ScrollText}
title={t('settings.features.contracts.title', 'Contracts')}
description={t(
'settings.features.contracts.description',
'Compose contracts from a library of reusable blocks (image rights, NDA, model release, cancellation, jurisdiction…) and have customers sign in-browser or upload a wet-signed PDF. Seeded block bodies are EXAMPLES ONLY — review with your lawyer before sending.',
)}
status="new"
statusLabel={statusLabel('new')}
sidebarLabel={t('settings.features.contracts.sidebar', 'Contracts')}
enabled={staged.contracts}
onToggle={(next) => setFlag('contracts', next)}
/>
<FeatureCard
icon={Receipt}
title={t('settings.features.bills.title', 'Bills')}
title={t('settings.features.bills.title', 'Invoices')}
description={t(
'settings.features.bills.description',
'Generate a bill from any accepted quote. Mark paid manually — no payment processor integration.',
'Generate an invoice from any accepted quote. Mark paid manually — no payment processor integration.',
)}
status="roadmap"
statusLabel={statusLabel('roadmap')}
sidebarLabel={t('settings.features.bills.sidebar', 'Bills')}
status="new"
statusLabel={statusLabel('new')}
sidebarLabel={t('settings.features.bills.sidebar', 'Invoices')}
enabled={staged.bills}
onToggle={() => { /* locked */ }}
disabled
lockedReason={NOT_YET_AVAILABLE}
onToggle={(next) => setFlag('bills', next)}
/>
<FeatureCard
icon={Calculator}
title={t('settings.features.taxReport.title', 'Tax report')}
description={t(
'settings.features.taxReport.description',
'Period-scoped revenue list with net + VAT breakdown grouped by VAT rate. Export as PDF (landscape, company letterhead) or CSV for your accountant. Cancelled invoices stay visible for a gap-free audit trail but are excluded from totals.',
)}
status="new"
statusLabel={statusLabel('new')}
sidebarLabel={t('settings.features.taxReport.sidebar', 'Tax')}
enabled={staged.taxReport}
onToggle={(next) => setFlag('taxReport', next)}
disabled={!staged.bills}
lockedReason={!staged.bills ? t(
'settings.features.taxReport.requiresBills',
'Enable Bills first — the tax report reads from your invoices.',
) : undefined}
/>
<FeatureCard
icon={Briefcase}
title={t('settings.features.hoursLogging.title', 'Hours logging')}
description={t(
'settings.features.hoursLogging.description',
'Per-customer time tracking. Admin logs date + start/end times + optional rate override + note. Monthly-mode customers auto-accumulate hours into the running monthly draft; per-event customers see a "Create draft invoice" button that mints a standalone draft invoice with one line per entry. Independent of Bills — log hours even before turning the full billing surface on.',
)}
status="new"
statusLabel={statusLabel('new')}
sidebarLabel={t('settings.features.hoursLogging.sidebar', 'Hours')}
enabled={staged.hoursLogging}
onToggle={(next) => setFlag('hoursLogging', next)}
/>
</Section>
@@ -262,6 +328,20 @@ export const FeaturesTab: React.FC = () => {
'Existing user accounts stay valid; the admin UI for managing them will be hidden until you re-enable this.',
)}
/>
<FeatureCard
icon={Wrench}
title={t('settings.features.crmDevelopment.title', 'CRM developer tools')}
description={t(
'settings.features.crmDevelopment.description',
'Internal helpers for verifying CRM flows (e.g. fire the admin payment-check email instantly, bypass throttles). Surfaces as a "Development" sub-tab under Clients. Strictly opt-in — fires real side effects, use against test data only.',
)}
status="experimental"
statusLabel={statusLabel('experimental')}
sidebarLabel={t('settings.features.crmDevelopment.sidebar', 'Development')}
enabled={staged.crmDevelopment}
onToggle={(next) => setFlag('crmDevelopment', next)}
/>
</Section>
</Card>
@@ -322,6 +322,27 @@ export const GeneralTab: React.FC<GeneralTabProps> = ({
{t('settings.general.dateFormatHelp')}
</p>
</div>
<div>
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-2">
{t('settings.general.timeFormat', 'Time format')}
</label>
<select
value={generalSettings.time_format || '24h'}
onChange={(e) => {
const time_format = e.target.value === '12h' ? '12h' : '24h';
setGeneralSettings(prev => ({ ...prev, time_format }));
}}
className="w-full px-3 py-2 border border-neutral-300 dark:border-neutral-600 rounded-lg bg-white dark:bg-neutral-800 text-neutral-900 dark:text-neutral-100 focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
>
<option value="24h">{t('settings.general.timeFormat24h', '24-hour (14:30)')}</option>
<option value="12h">{t('settings.general.timeFormat12h', '12-hour (2:30 PM)')}</option>
</select>
<p className="text-xs text-neutral-500 dark:text-neutral-400 mt-1">
{t('settings.general.timeFormatHelp',
'Controls how times render across admin views, customer-facing pages, and PDFs. Storage stays 24-hour (HH:mm); only the display switches.')}
</p>
</div>
</div>
<div className="mt-6">
@@ -16,6 +16,7 @@ import { api } from '../../../config/api';
import { settingsService } from '../../../services/settings.service';
import { useStatusTab } from '../hooks/useStatusTab';
import { UpdateNotificationSettings } from '../components/UpdateNotificationSettings';
import { useLocalizedDate } from '../../../hooks/useLocalizedDate';
const BYTES_PER_GB = 1024 * 1024 * 1024;
@@ -55,6 +56,7 @@ export const StatusTab: React.FC<StatusTabProps> = ({
setOverrideDirty,
}) => {
const { t } = useTranslation();
const { formatDateTime: fmtDateTime } = useLocalizedDate();
const { storageInfo, systemStatus } = useStatusTab(isActive);
const queryClient = useQueryClient();
@@ -614,7 +616,7 @@ export const StatusTab: React.FC<StatusTabProps> = ({
{systemStatus && (
<div className="text-xs text-neutral-500 dark:text-neutral-400 text-right flex items-center justify-end gap-1">
<Clock className="w-3 h-3" />
{t('settings.systemStatus.lastUpdate')}: {new Date(systemStatus.timestamp).toLocaleString()}
{t('settings.systemStatus.lastUpdate')}: {fmtDateTime(systemStatus.timestamp)}
</div>
)}
</div>
@@ -6,6 +6,7 @@ import { toast } from 'react-toastify';
import { Webhook as WebhookIcon, Trash2, Copy, AlertTriangle, Activity, CheckCircle2, XCircle } from 'lucide-react';
import { Button, Card, Input, Loading } from '../../../components/common';
import { api } from '../../../config/api';
import { useLocalizedDate } from '../../../hooks/useLocalizedDate';
const WEBHOOK_EVENT_TYPES = [
'event.created',
@@ -39,6 +40,7 @@ interface WebhookRow {
*/
export const WebhooksTab: React.FC = () => {
const { t } = useTranslation();
const { formatDateTime: fmtDateTime } = useLocalizedDate();
const queryClient = useQueryClient();
const [name, setName] = useState('');
const [url, setUrl] = useState('');
@@ -304,13 +306,13 @@ export const WebhooksTab: React.FC = () => {
{lastEither === 'success' && lastSuccess && (
<span className="flex items-center gap-1 text-green-600 dark:text-green-400">
<CheckCircle2 className="w-3.5 h-3.5" />
{lastSuccess.toLocaleString()}
{fmtDateTime(lastSuccess)}
</span>
)}
{lastEither === 'failure' && lastFailure && (
<span className="flex items-center gap-1 text-red-600 dark:text-red-400">
<XCircle className="w-3.5 h-3.5" />
{lastFailure.toLocaleString()}
{fmtDateTime(lastFailure)}
</span>
)}
{lastEither === 'none' && <span className="text-neutral-400"></span>}
@@ -0,0 +1,51 @@
/**
* useInstallmentDefaults — fetch the three CRM-level installment
* defaults seeded by migration 141:
*
* - crm_invoices_installment_trigger_first ('quote_accepted')
* - crm_invoices_installment_days_before_event (14)
* - crm_invoices_installment_days_after_event (14)
*
* Used by InstallmentsPanel on the Quote and Invoice editors so a
* freshly-added installment row pre-populates with the admin's
* preferred trigger shape instead of an empty value.
*
* Cached via react-query with a 5-min staleTime — these settings
* change rarely and only via Settings → CRM. A `defaults` value is
* always returned (hard-coded fallbacks before the fetch completes)
* so callers don't have to guard a loading state.
*/
import { useQuery } from '@tanstack/react-query';
import { settingsService } from '../services/settings.service';
import type { PaymentTermInstallment } from '../services/quotes.service';
export interface InstallmentDefaults {
triggerFirst: PaymentTermInstallment['trigger'];
daysBeforeEvent: number;
daysAfterEvent: number;
}
const HARDCODED_FALLBACK: InstallmentDefaults = {
triggerFirst: 'quote_accepted',
daysBeforeEvent: 14,
daysAfterEvent: 14,
};
export function useInstallmentDefaults(): InstallmentDefaults {
const { data } = useQuery({
queryKey: ['installment-defaults'],
queryFn: () => settingsService.getAllSettings(),
staleTime: 5 * 60_000,
});
if (!data) return HARDCODED_FALLBACK;
return {
triggerFirst: (data.crm_invoices_installment_trigger_first as PaymentTermInstallment['trigger'])
|| HARDCODED_FALLBACK.triggerFirst,
daysBeforeEvent: Number.isFinite(Number(data.crm_invoices_installment_days_before_event))
? Number(data.crm_invoices_installment_days_before_event)
: HARDCODED_FALLBACK.daysBeforeEvent,
daysAfterEvent: Number.isFinite(Number(data.crm_invoices_installment_days_after_event))
? Number(data.crm_invoices_installment_days_after_event)
: HARDCODED_FALLBACK.daysAfterEvent,
};
}
+82 -5
View File
@@ -52,17 +52,94 @@ export const useLocalizedDate = () => {
const dateObj = typeof date === 'string' ? new Date(date) : date;
return dateFnsFormatDistanceToNow(dateObj, { ...options, locale: getLocale() });
};
// Time-format pattern: '24h' → 'HH:mm' (e.g. 14:32), '12h' →
// 'h:mm a' (e.g. 2:32 PM). Defaults to 24h when the setting is
// missing or unrecognised — matches the operator's CH/DE locale.
const timeFormatToken = settings?.general_time_format === '12h' ? 'h:mm a' : 'HH:mm';
/**
* Time only — respects the admin-configured `general_time_format`
* (24-hour HH:mm by default; 12-hour h:mm AM/PM when toggled).
*
* Accepts a Date, an ISO string, OR a bare "HH:MM" / "HH:MM:SS"
* clock string (e.g. "09:00" from a stored startTime/endTime).
* The bare-time path constructs an arbitrary epoch date with the
* given hours/minutes so date-fns can format the time half — the
* date half is discarded by the output pattern.
*/
const formatTime = (date: Date | string) => {
if (typeof date === 'string' && /^\d{2}:\d{2}(:\d{2})?$/.test(date)) {
const [h, m] = date.split(':');
const d = new Date(2000, 0, 1, parseInt(h, 10), parseInt(m, 10));
return dateFnsFormat(d, timeFormatToken, { locale: getLocale() });
}
const dateObj = typeof date === 'string' ? new Date(date) : date;
return dateFnsFormat(dateObj, timeFormatToken, { locale: getLocale() });
};
/**
* Date + time, respecting both `general_date_format` for the date
* half and `general_time_format` for the time half. Examples with
* 24h time format:
* DD.MM.YYYY → "20.05.2026 14:32"
* YYYY-MM-DD → "2026-05-20 14:32"
* With 12h time format:
* DD.MM.YYYY → "20.05.2026 2:32 PM"
*
* Pass `formatStr` to override the date half (same shape as `format()`).
*/
const formatDateTime = (date: Date | string, formatStr?: string) => {
const dateObj = typeof date === 'string' ? new Date(date) : date;
let dateFormat = formatStr;
if (!dateFormat && settings?.general_date_format) {
dateFormat = typeof settings.general_date_format === 'string'
? settings.general_date_format
: settings.general_date_format.format || 'PPP';
}
dateFormat = dateFormat || 'PPP';
dateFormat = convertDateFormat(dateFormat);
return dateFnsFormat(dateObj, `${dateFormat} ${timeFormatToken}`, { locale: getLocale() });
};
return {
format,
formatTime,
formatDateTime,
formatDistanceToNow,
locale: getLocale(),
dateFormat: settings?.general_date_format
dateFormat: settings?.general_date_format
? convertDateFormat(
typeof settings.general_date_format === 'string'
? settings.general_date_format
typeof settings.general_date_format === 'string'
? settings.general_date_format
: settings.general_date_format.format || 'PPP'
)
: 'PPP'
: 'PPP',
/** '12h' or '24h' — exposed so components can decide between
* rendering a native <input type="time"> (always 24h value
* internally) vs a custom 12h-styled control if needed. */
timeFormat: (settings?.general_time_format === '12h' ? '12h' : '24h') as '12h' | '24h',
/** BCP-47 language tag to pin on `<input type="date">` so the
* browser-rendered placeholder + parsing match the admin's
* configured `general_date_format`. Chrome/Edge honour this;
* Safari/Firefox follow OS locale regardless (no way around it
* without a custom date-picker component).
*
* Mapping derives from the format string's first token:
* DD/dd → de-DE (renders TT.MM.JJJJ)
* YYYY/yyyy → en-CA (renders YYYY-MM-DD)
* MM → en-US (renders MM/DD/YYYY)
* fallback → i18n.language. */
dateInputLang: (() => {
const raw = settings?.general_date_format;
const fmt = typeof raw === 'string' ? raw : raw?.format;
if (fmt) {
const head = fmt.trim().slice(0, 2).toUpperCase();
if (head === 'DD') return 'de-DE';
if (head === 'YY') return 'en-CA';
if (head === 'MM') return 'en-US';
}
return i18n.language || 'en';
})(),
};
};
+43
View File
@@ -0,0 +1,43 @@
/**
* usePublicDarkMode — toggle the `.dark` class on <html> based on
* the admin's branding settings, for public pages that live outside
* the admin/customer layouts.
*
* Priority:
* 1. `branding_force_color_mode` → 'dark' / 'light' / null
* 2. fallback to the OS preference (and react when it flips)
*
* Necessary because Tailwind's `dark:` modifiers depend on the
* `.dark` class being present, and ThemeContext only writes CSS
* variables — it doesn't toggle the class.
*
* Shared by QuoteResponsePage + PaymentCheckPage. Adding a third
* public-page consumer? Reuse this hook.
*/
import { useEffect } from 'react';
import { usePublicSettings } from './usePublicSettings';
export function usePublicDarkMode() {
const { data: publicSettings } = usePublicSettings();
useEffect(() => {
const root = document.documentElement;
const forced = publicSettings?.branding_force_color_mode;
const apply = (isDark: boolean) => {
if (isDark) root.classList.add('dark');
else root.classList.remove('dark');
};
if (forced === 'dark') {
apply(true);
return;
}
if (forced === 'light') {
apply(false);
return;
}
const mql = window.matchMedia('(prefers-color-scheme: dark)');
apply(mql.matches);
const listener = (e: MediaQueryListEvent) => apply(e.matches);
mql.addEventListener('change', listener);
return () => mql.removeEventListener('change', listener);
}, [publicSettings?.branding_force_color_mode]);
}
+1 -1
View File
@@ -146,7 +146,7 @@
* legacy behaviour.
*/
.btn {
@apply inline-flex items-center justify-center font-medium transition-all duration-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50;
@apply inline-flex items-center justify-center font-medium whitespace-nowrap transition-all duration-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50;
border-radius: var(--border-radius);
--tw-ring-color: var(--color-accent);
--tw-ring-offset-color: var(--color-background);
+20 -2
View File
@@ -18,6 +18,7 @@ import { useLocalizedDate } from '../../hooks/useLocalizedDate';
import { Button, Card, Loading } from '../../components/common';
import { UpdateNotification } from '../../components/admin/UpdateNotification';
import { CrmOverviewSection } from '../../components/admin/CrmOverviewSection';
import { useQuery } from '@tanstack/react-query';
import { eventsService } from '../../services/events.service';
import { adminService, ActivityType } from '../../services/admin.service';
@@ -47,11 +48,20 @@ export const AdminDashboard: React.FC = () => {
queryFn: () => adminService.getRecentActivity(10),
});
// Fetch system health
// Fetch system health. 30s cadence is fine for a freshness dashboard,
// but we explicitly opt out of background polling so an idle browser
// tab doesn't keep pinging the backend (24 calls/hr → 0 calls/hr
// when the admin tabs away). React-query's default for this flag is
// already `false`, but making it explicit documents the intent so a
// future refactor doesn't accidentally flip it on.
// staleTime lets a focus-regain after <30s skip the immediate
// refetch — the interval re-fires on its own schedule.
const { data: systemHealth } = useQuery({
queryKey: ['admin-system-health'],
queryFn: () => adminService.getSystemHealth(),
refetchInterval: 30000, // Refresh every 30 seconds
refetchInterval: 30000,
refetchIntervalInBackground: false,
staleTime: 30000,
});
// Fetch the next 5 expiring events directly from the server. Previously
@@ -301,6 +311,14 @@ export const AdminDashboard: React.FC = () => {
</Card>
</div>
{/* CRM overview — quote / invoice pipeline + revenue +
outstanding. The section internally gates on the `clients`
feature flag (renders nothing when off), and further hides
the quotes / invoices subsections individually when their
sub-flag is off. Lives at the bottom so admins who don't
use the CRM see no visual difference. */}
<CrmOverviewSection />
</div>
);
};
+75 -12
View File
@@ -6,14 +6,19 @@ import { ThemeCustomizerEnhanced, GalleryPreview } from '../../components/admin'
import { useTheme, type ThemeConfig, GALLERY_THEME_PRESETS } from '../../contexts/ThemeContext';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { settingsService, type BrandingSettings } from '../../services/settings.service';
import { businessProfileService } from '../../services/businessProfile.service';
import { useTranslation } from 'react-i18next';
import { buildResourceUrl } from '../../utils/url';
import { useFeatureEnabled } from '../../contexts/FeatureFlagsContext';
import { useFeatureEnabled, useFeatureFlags } from '../../contexts/FeatureFlagsContext';
import { CustomerDashboardBrandingCard } from '../../components/admin/CustomerDashboardBrandingCard';
import { PdfTypographyCard } from '../../components/admin/PdfTypographyCard';
export const BrandingPage: React.FC = () => {
const { t } = useTranslation();
const { theme, setTheme } = useTheme();
// Used to gate the PDF typography card — when no PDF-producing
// feature is enabled the setting has no surface to apply to.
const { flags } = useFeatureFlags();
const [brandingSettings, setBrandingSettings] = useState<BrandingSettings>({
company_name: '',
company_tagline: '',
@@ -49,6 +54,11 @@ export const BrandingPage: React.FC = () => {
const [currentTheme, setCurrentTheme] = useState<ThemeConfig>(theme);
const [currentThemeName, setCurrentThemeName] = useState('default');
const [isPreviewMode, setIsPreviewMode] = useState(false);
// PDF body font selection (migration 121). Lives on this page so the
// top-level Save button can persist it together with branding +
// theme — no card-local save button. Null = "no preference,
// fall back to Helvetica" — same encoding the column uses.
const [pdfFontFamily, setPdfFontFamily] = useState<string | null>(null);
const faviconInputRef = useRef<HTMLInputElement>(null);
// Fetch current settings
@@ -63,6 +73,17 @@ export const BrandingPage: React.FC = () => {
queryFn: () => settingsService.getSettingsByType('theme'),
});
// Fetch business profile snapshot — needed to hydrate the PDF
// typography card with the saved pdfFontFamily value. The PDF
// typography card is rendered only when a PDF-producing feature is
// enabled, but the query is cheap and the page already issues other
// settings queries on mount, so we always fetch.
const { data: businessProfileSnapshot } = useQuery({
queryKey: ['business-profile-snapshot'],
queryFn: () => businessProfileService.get(),
staleTime: 5 * 60 * 1000,
});
// Update branding mutation
const queryClient = useQueryClient();
@@ -104,6 +125,13 @@ export const BrandingPage: React.FC = () => {
}
}, [settings]);
// Hydrate the PDF font selection once the business profile arrives.
useEffect(() => {
if (businessProfileSnapshot?.profile) {
setPdfFontFamily(businessProfileSnapshot.profile.pdfFontFamily || null);
}
}, [businessProfileSnapshot?.profile?.pdfFontFamily]);
// Initialize theme from database
useEffect(() => {
if (themeSettings) {
@@ -255,16 +283,27 @@ export const BrandingPage: React.FC = () => {
...brandingSettings,
logo_url: currentTheme.logoUrl || brandingSettings.logo_url || ''
};
// Save branding settings to database
await brandingMutation.mutateAsync(updatedBrandingSettings);
// Save theme settings to database
await themeMutation.mutateAsync(currentTheme);
// Persist PDF font family if it changed. Skipped when the value
// matches the snapshot to avoid touching business_profile on
// every Branding save (the row carries unrelated settings).
const savedPdfFontFamily = businessProfileSnapshot?.profile?.pdfFontFamily || null;
if (savedPdfFontFamily !== pdfFontFamily) {
await businessProfileService.update({
pdfFontFamily: pdfFontFamily ? pdfFontFamily : null,
});
queryClient.invalidateQueries({ queryKey: ['business-profile-snapshot'] });
}
// Apply theme globally
setTheme(currentTheme);
// Update local state to reflect saved values
setBrandingSettings(updatedBrandingSettings);
} catch (error) {
@@ -620,13 +659,19 @@ export const BrandingPage: React.FC = () => {
</div>
)}
{/* Logo Position */}
{/* Logo Position
- left / center / right: position inside the gallery header bar.
- sidepanel: moves the logo out of the gallery header
and into the admin sidebar's brand row (replaces the
"PicPeak Admin" text; the favicon takes over when
the sidebar is collapsed). The gallery falls back
to 'left' for its own rendering. */}
<div>
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-2">
{t('branding.logoPosition', 'Logo Position in Header')}
</label>
<div className="flex gap-2">
{(['left', 'center', 'right'] as const).map((position) => (
<div className="flex flex-wrap gap-2">
{(['sidepanel', 'left', 'center', 'right'] as const).map((position) => (
<button
key={position}
type="button"
@@ -641,6 +686,12 @@ export const BrandingPage: React.FC = () => {
</button>
))}
</div>
{brandingSettings.logo_position === 'sidepanel' && (
<p className="text-xs text-neutral-600 dark:text-neutral-400 mt-2">
{t('branding.positionSidepanelHelp',
'The logo appears in the admin sidebar instead of the header. When the sidebar is collapsed, the favicon is shown.')}
</p>
)}
</div>
{/* Display Mode */}
@@ -953,7 +1004,14 @@ export const BrandingPage: React.FC = () => {
</label>
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Left side - Theme Customizer */}
{/* Left side - Theme Customizer.
The PDF typography card is injected via the
customizer's `slotBeforeCustomCss` so it sits
immediately after the web Typography & Style section
and before the (often bulky) Custom CSS editor —
keeps all typography choices visually grouped.
Hidden when no PDF-producing feature is on; persisted
via the top-level Save button (handleSave). */}
<div>
<ThemeCustomizerEnhanced
value={currentTheme}
@@ -964,19 +1022,24 @@ export const BrandingPage: React.FC = () => {
hideActions={true}
forceColorMode={brandingSettings.force_color_mode ?? null}
onForceColorModeChange={handleForceColorModeChange}
slotBeforeCustomCss={
(flags.quotes || flags.bills || flags.taxReport)
? <PdfTypographyCard value={pdfFontFamily} onChange={setPdfFontFamily} />
: null
}
/>
</div>
{/* Right side - Gallery Preview */}
<div className="lg:sticky lg:top-4 lg:h-fit">
<Card className="p-4">
<h3 className="text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-3">
{t('branding.livePreview')}
</h3>
<GalleryPreview
<GalleryPreview
theme={currentTheme}
branding={brandingSettings}
className="shadow-lg"
className="shadow-lg"
/>
</Card>
</div>
+3 -1
View File
@@ -12,9 +12,11 @@ import { cmsService } from '../../services/cms.service';
import type { CMSPage as CMSPageType } from '../../services/cms.service';
import { settingsService, PublicSiteBranding } from '../../services/settings.service';
import { buildResourceUrl } from '../../utils/url';
import { useLocalizedDate } from '../../hooks/useLocalizedDate';
export const CMSPage: React.FC = () => {
const { t } = useTranslation();
const { formatDateTime: fmtDateTime } = useLocalizedDate();
const queryClient = useQueryClient();
const [selectedPage, setSelectedPage] = useState<string>('impressum');
const [editingLang, setEditingLang] = useState<'en' | 'de'>('en');
@@ -771,7 +773,7 @@ export const CMSPage: React.FC = () => {
{currentPage?.updated_at && (
<p className="text-xs text-neutral-500 dark:text-neutral-400 mt-4">
{t('cms.lastUpdated')} {new Date(currentPage.updated_at).toLocaleString()}
{t('cms.lastUpdated')} {fmtDateTime(currentPage.updated_at)}
</p>
)}
</Card>
@@ -36,6 +36,13 @@ interface FormData {
event_type: string;
event_name: string;
event_date: string;
// Migration 137 — calendar time fields. Defaults to full-day so the
// existing UX is unchanged for admins who never touch the toggle.
// When `is_full_day` is true, the time fields are ignored by the
// backend regardless of their value.
event_time_start: string;
event_time_end: string;
is_full_day: boolean;
customer_name: string;
customer_email: string;
customer_phone: string;
@@ -101,6 +108,9 @@ export const CreateEventPage: React.FC = () => {
event_type: 'wedding',
event_name: '',
event_date: new Date().toISOString().split('T')[0], // Initialize with ISO date format
event_time_start: '',
event_time_end: '',
is_full_day: true,
customer_name: '',
customer_email: '',
customer_phone: '',
@@ -434,6 +444,12 @@ export const CreateEventPage: React.FC = () => {
event_type: formData.event_type,
event_name: formData.event_name,
event_date: formData.event_date || undefined,
// Migration 137 — calendar time fields. Backend normalises the
// triple: when is_full_day is true the times are nulled out
// regardless of value, so it's safe to always send them.
event_time_start: formData.is_full_day ? undefined : formData.event_time_start,
event_time_end: formData.is_full_day ? undefined : formData.event_time_end,
is_full_day: formData.is_full_day,
customer_name: formData.customer_name,
customer_email: formData.customer_email,
...(phoneFieldEnabled && formData.customer_phone ? { customer_phone: formData.customer_phone.trim() } : {}),
@@ -581,6 +597,42 @@ export const CreateEventPage: React.FC = () => {
/>
</div>
{/* Migration 137 calendar time fields. Full-day events stay
full-day; admins who unchecks "Full day" get two HH:MM
inputs that flow into the events row's event_time_start /
event_time_end columns and drive how the admin calendar
renders the event (block vs all-day banner). 15-minute
snap matches the calendar's drag-create grid. */}
<div className="mt-3 space-y-2">
<label className="inline-flex items-center gap-2 text-sm text-neutral-800 dark:text-neutral-200">
<input
type="checkbox"
checked={formData.is_full_day}
onChange={(e) => setFormData({ ...formData, is_full_day: e.target.checked })}
className="rounded border-neutral-300 dark:border-neutral-600"
/>
{t('events.fullDay', 'Full day')}
</label>
{!formData.is_full_day && (
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<Input
type="time"
step={900}
label={t('events.eventTimeStart', 'Start time') as string}
value={formData.event_time_start}
onChange={handleInputChange('event_time_start')}
/>
<Input
type="time"
step={900}
label={t('events.eventTimeEnd', 'End time') as string}
value={formData.event_time_end}
onChange={handleInputChange('event_time_end')}
/>
</div>
)}
</div>
<div>
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-2">
{t('events.welcomeMessage')}
+477 -51
View File
@@ -16,28 +16,40 @@ import { toast } from 'react-toastify';
import {
ArrowLeft, Mail, MapPin, Phone, Building2, Save, Trash2, AlertTriangle,
CheckCircle2, X, FileText, Calendar, KeyRound, ToggleLeft, Settings as SettingsIcon,
Clock,
} from 'lucide-react';
import { format } from 'date-fns';
import { Button, Card, Input, Loading } from '../../components/common';
import { SUPPORTED_LANGUAGES } from '../../components/common/LanguageSelector';
import { DecimalInput } from '../../components/common/DecimalInput';
import { AssignedEventsDialog } from '../../components/admin/AssignedEventsDialog';
import {
customerAdminService,
type CustomerAccountDetail,
} from '../../services/customerAdmin.service';
import { businessProfileService } from '../../services/businessProfile.service';
import { CustomerCrmPanels } from '../../components/admin/CustomerCrmPanels';
import { HoursSection } from '../../components/admin/HoursSection';
import { formatMoney } from '../../components/admin/LineItemsTable';
import { useLocalizedDate } from '../../hooks/useLocalizedDate';
import { useFeatureFlags } from '../../contexts/FeatureFlagsContext';
type EditableFields =
| 'email' | 'salutation' | 'firstName' | 'lastName' | 'displayName'
| 'phone' | 'companyName' | 'billingEmail' | 'vatId'
| 'addressLine1' | 'addressLine2' | 'postalCode' | 'city' | 'state'
| 'countryCode' | 'preferredLanguage' | 'notes'
| 'featureCalendar' | 'featureQuotes' | 'featureBills';
| 'countryCode' | 'countryName' | 'preferredLanguage' | 'notes'
| 'featureCalendar' | 'featureQuotes' | 'featureBills' | 'featureHoursLogging'
| 'hourlyRateMinor' | 'billingCadence' | 'billingCycleDay';
const formatDate = (iso: string | null | undefined) => {
if (!iso) return '—';
try { return format(new Date(iso), 'PP'); } catch { return '—'; }
};
// `fmtDate` (from useLocalizedDate, below) is the single canonical date
// formatter. It honors the admin's `general_date_format` setting AND
// the active i18next locale. Per memory:
// `feedback_respect_general_format_settings.md` — every displayed
// date must route through useLocalizedDate(). Previously this file
// had a local `formatDate(iso)` using date-fns 'PP' that ignored
// both settings and locale — two rows on the same customer page
// were rendering dates in two different formats.
export const CustomerDetailPage: React.FC = () => {
const { t } = useTranslation();
@@ -45,6 +57,10 @@ export const CustomerDetailPage: React.FC = () => {
const navigate = useNavigate();
const queryClient = useQueryClient();
const customerId = Number(id);
// Master "Hours logging" flag (Settings → Features). When off, the
// per-customer toggle in the features card is hidden and the
// HoursSection card never renders, regardless of customer state.
const { flags } = useFeatureFlags();
const { data: customer, isLoading, error } = useQuery({
queryKey: ['admin-customer', customerId],
@@ -52,6 +68,38 @@ export const CustomerDetailPage: React.FC = () => {
enabled: Number.isFinite(customerId) && customerId > 0,
});
// Open monthly draft preview (migration 128). Powers the
// "Pending in this month's bill" list shown between the cadence
// controls and the Trigger button. null until the first invoice
// line is appended; refetched on every trigger / invoice mutation.
const { format: fmtDate } = useLocalizedDate();
const { data: monthlyDraftRes } = useQuery({
queryKey: ['admin-customer-monthly-draft', customerId],
queryFn: () => customerAdminService.getMonthlyDraft(customerId),
enabled: Number.isFinite(customerId) && customerId > 0
&& (customer?.billingCadence === 'monthly'),
});
const monthlyDraft = monthlyDraftRes?.draft || null;
// Business-profile default locale powers the "Preferred language"
// dropdown's helper hint — admins see which language a brand-new
// customer would inherit and decide whether to override it.
const { data: profileSnapshot } = useQuery({
queryKey: ['business-profile-snapshot'],
queryFn: () => businessProfileService.get(),
staleTime: 5 * 60 * 1000,
});
const profileDefaultLocale = profileSnapshot?.profile?.defaultLocale || 'en';
// #3 — the hourly-rate hint used to hardcode "CHF" in its example.
// Pull the configured default currency so installs running EUR /
// USD / GBP get a meaningful example instead of one referencing a
// currency they don't use.
const profileDefaultCurrency = profileSnapshot?.profile?.defaultCurrency || 'CHF';
const LOCALE_LABELS: Record<string, string> = {
en: 'English', de: 'Deutsch', fr: 'Français',
nl: 'Nederlands', pt: 'Português', ru: 'Русский',
};
const [form, setForm] = useState<Partial<Pick<CustomerAccountDetail, EditableFields>>>({});
const [confirmDeactivate, setConfirmDeactivate] = useState(false);
const [confirmErase, setConfirmErase] = useState(false);
@@ -82,16 +130,21 @@ export const CustomerDetailPage: React.FC = () => {
city: customer.city,
state: customer.state,
countryCode: customer.countryCode,
countryName: customer.countryName,
preferredLanguage: customer.preferredLanguage,
notes: customer.notes,
featureCalendar: customer.featureCalendar ?? false,
featureQuotes: customer.featureQuotes ?? false,
featureBills: customer.featureBills ?? false,
featureHoursLogging: customer.featureHoursLogging ?? false,
hourlyRateMinor: customer.hourlyRateMinor ?? null,
billingCadence: customer.billingCadence ?? 'per_event',
billingCycleDay: customer.billingCycleDay ?? 1,
} as any);
}
}, [customer, form]);
const toggleFeature = (key: 'featureCalendar' | 'featureQuotes' | 'featureBills') => {
const toggleFeature = (key: 'featureCalendar' | 'featureQuotes' | 'featureBills' | 'featureHoursLogging') => {
setForm((prev) => ({ ...prev, [key]: !prev[key] }) as any);
};
@@ -106,9 +159,19 @@ export const CustomerDetailPage: React.FC = () => {
toast.success(t('customers.detail.saved', 'Customer saved'));
},
onError: (e: any) => {
const msg = e?.response?.status === 409
? t('customers.detail.emailConflict', 'That email is already in use by another customer.')
: e?.response?.data?.error || t('customers.detail.saveError', 'Could not save changes.');
// Surface field-level validation errors so admin can see WHICH
// field failed instead of a generic "Validation failed" toast.
// The backend returns details: [{ field, message }] via the
// ValidationError class in routeHelpers.js.
const details = e?.response?.data?.details;
let msg: string;
if (e?.response?.status === 409) {
msg = t('customers.detail.emailConflict', 'That email is already in use by another customer.');
} else if (Array.isArray(details) && details.length > 0) {
msg = `${e.response.data.error || 'Validation failed'}: ${details.map((d: any) => `${d.field} (${d.message})`).join(', ')}`;
} else {
msg = e?.response?.data?.error || t('customers.detail.saveError', 'Could not save changes.');
}
toast.error(msg);
},
});
@@ -126,6 +189,53 @@ export const CustomerDetailPage: React.FC = () => {
onError: () => toast.error(t('customers.detail.passwordReset.error', 'Could not send password reset')),
});
// Promote a passive customer to active by firing the standard
// portal-invitation email. On success we invalidate the customer
// query so the badge + the "Has portal access" copy update once
// the customer actually claims the invite (next reload).
const sendInviteMutation = useMutation({
mutationFn: () => customerAdminService.sendInvite(customerId),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['admin-customer', customerId] });
toast.success(t('customers.passive.sendInviteToast', 'Portal invitation sent.'));
},
onError: (err: any) => {
if (err?.response?.data?.code === 'CUSTOMER_ALREADY_ACTIVE') {
toast.error(t('customers.passive.alreadyActive',
'Customer already has portal access — no invitation needed.'));
} else {
toast.error(err?.response?.data?.error || err?.message || t('common.error', 'Something went wrong.'));
}
},
});
// Admin override — issue the customer's running monthly draft NOW
// instead of waiting for the cadence-day scheduler tick. Used when
// the customer asks for an out-of-cycle bill or a project wraps
// before the configured day. Surfaces backend errors verbatim so
// admin sees "No pending monthly bill" / "Draft is empty" when the
// queue isn't ready.
const triggerMonthlyBillMutation = useMutation({
mutationFn: () => customerAdminService.triggerMonthlyBill(customerId),
onSuccess: (result) => {
queryClient.invalidateQueries({ queryKey: ['admin-customer', customerId] });
queryClient.invalidateQueries({ queryKey: ['admin-customer-hour-entries', customerId] });
// Clear the draft preview so the list collapses to empty
// immediately after the trigger ships — a new draft is minted
// on the next createInvoice / hour-entry append.
queryClient.invalidateQueries({ queryKey: ['admin-customer-monthly-draft', customerId] });
toast.success(
t('customers.billing.triggered',
'Monthly bill issued: {{number}}',
{ number: result.invoiceNumber }),
);
},
onError: (err: any) => {
toast.error(err?.response?.data?.error
|| t('customers.billing.triggerError', 'Could not trigger the monthly bill.'));
},
});
const deactivateMutation = useMutation({
mutationFn: () => customerAdminService.deactivate(customerId),
onSuccess: () => {
@@ -196,7 +306,7 @@ export const CustomerDetailPage: React.FC = () => {
<p className="text-sm text-muted-theme truncate">{customer.email}</p>
</div>
</div>
<div className="flex items-center gap-2">
<div className="flex flex-col items-end gap-1">
{customer.isActive ? (
<span className="inline-flex items-center gap-1 text-xs" style={{ color: 'var(--color-accent)' }}>
<CheckCircle2 className="w-3.5 h-3.5" />
@@ -208,6 +318,21 @@ export const CustomerDetailPage: React.FC = () => {
{t('customers.status.inactive', 'Deactivated')}
</span>
)}
{customer.isPassive ? (
<span
className="inline-flex items-center gap-1 text-[11px] px-1.5 py-0.5 rounded bg-neutral-100 dark:bg-neutral-800 text-neutral-700 dark:text-neutral-300"
title={t(
'customers.passive.detailHint',
'This customer has no portal access (admin-only record). Click "Send portal invitation" below to email them a sign-up link.',
) as string}
>
{t('customers.passive.badge', 'Passive — admin only')}
</span>
) : (
<span className="text-[11px] text-muted-theme">
{t('customers.passive.activeLabel', 'Has portal access')}
</span>
)}
</div>
</div>
@@ -224,7 +349,7 @@ export const CustomerDetailPage: React.FC = () => {
<div>
<label className="block text-sm font-medium text-theme mb-1">{t('customers.detail.preferredLanguage', 'Preferred language')}</label>
<select
value={form.preferredLanguage || 'en'}
value={form.preferredLanguage || profileDefaultLocale}
onChange={setField('preferredLanguage')}
className="input"
>
@@ -235,6 +360,11 @@ export const CustomerDetailPage: React.FC = () => {
<option key={lang.code} value={lang.code}>{lang.name}</option>
))}
</select>
<p className="text-xs text-neutral-500 mt-1">
{t('customers.detail.preferredLanguageHint',
'Drives portal UI and quote/invoice PDF locale. New customers default to the business-profile language ({{lang}}); override here per customer.',
{ lang: LOCALE_LABELS[profileDefaultLocale] || profileDefaultLocale.toUpperCase() })}
</p>
</div>
</div>
</Card>
@@ -350,8 +480,8 @@ export const CustomerDetailPage: React.FC = () => {
{ev.eventName}
</Link>
<span className="text-xs text-muted-theme">
{ev.eventDate ? formatDate(ev.eventDate) : ''}
{ev.expiresAt ? ` · ${t('customers.detail.expires', 'expires')} ${formatDate(ev.expiresAt)}` : ''}
{ev.eventDate ? fmtDate(ev.eventDate) : ''}
{ev.expiresAt ? ` · ${t('customers.detail.expires', 'expires')} ${fmtDate(ev.expiresAt)}` : ''}
</span>
</li>
))}
@@ -408,18 +538,48 @@ export const CustomerDetailPage: React.FC = () => {
<Input value={form.state || ''} onChange={setField('state')} />
</div>
<div>
<label className="block text-sm font-medium text-theme mb-1">{t('customers.detail.countryCode', 'Country (ISO 2)')}</label>
<label className="block text-sm font-medium text-theme mb-1">{t('customers.detail.countryCode', 'Country abbreviation (FL, CH, DE …)')}</label>
<Input
value={form.countryCode || ''}
onChange={setField('countryCode')}
maxLength={2}
placeholder="e.g. CH"
placeholder="FL"
/>
</div>
<div>
{/* Free-text country name override (migration 107). When
left empty the PDF renderer falls back to the locale-
aware lookup on the abbreviation; useful when the
abbreviation isn't an ISO code (e.g. "FL" for
Liechtenstein, which is "LI" in ISO). */}
<label className="block text-sm font-medium text-theme mb-1">{t('customers.detail.countryName', 'Country (full name)')}</label>
<Input
value={form.countryName || ''}
onChange={setField('countryName')}
placeholder="Liechtenstein"
/>
</div>
</div>
</Card>
{/* Per-customer feature flags (#354 follow-up) */}
{/* Quotes + Invoices history (CRM #TBD).
Each panel renders a compact list scoped to this customer. The
panels are independently feature-flagged so they vanish for
installs that haven't turned the master quotes/bills flag on.
The flag check lives in <CustomerCrmPanels /> so this page
doesn't need to import useFeatureFlags directly. */}
<CustomerCrmPanels customerAccountId={customer.id} />
{/* Per-customer feature flags (#354 follow-up). Sits
second-to-last by request admins glance at these least
often, but they need to live above the destructive
"Account actions" row so the feature surface and its
actions read as one unit. */}
{/* Hide the whole Card when every flag that could surface a
toggle inside it is OFF an empty "Customer features" card
with just a title + hint reads as broken. The Card reappears
the moment any master flag is re-enabled. */}
{(flags.calendar || flags.quotes || flags.bills || flags.hoursLogging) && (
<Card padding="lg">
<h2 className="text-lg font-semibold text-theme mb-1 flex items-center gap-2">
<ToggleLeft className="w-5 h-5" />
@@ -433,23 +593,49 @@ export const CustomerDetailPage: React.FC = () => {
</p>
<div className="space-y-3">
{([
{ key: 'featureCalendar', labelKey: 'customer.nav.calendar', fallback: 'Calendar' },
{ key: 'featureQuotes', labelKey: 'customer.nav.quotes', fallback: 'Quotes' },
{ key: 'featureBills', labelKey: 'customer.nav.bills', fallback: 'Bills' },
] as const).map(({ key, labelKey, fallback }) => {
// Each per-customer toggle hides when its master feature
// flag is OFF — the toggle would do nothing in that state
// and only confuses the admin. The "feature is disabled
// globally" signal is conveyed by the row simply not
// appearing, mirroring the hoursLogging pattern below.
//
// `badge` controls which status pill is shown:
// - 'soon' (amber) for tabs that still don't have a
// customer-facing surface (Calendar booking)
// - 'new' (green) for shipped customer-facing tabs that
// are recent additions to the admin's vocabulary so
// they catch the eye when reviewing per-customer
// overrides. Matches Settings → Features StatusBadge.
...(flags.calendar
? [{ key: 'featureCalendar' as const, labelKey: 'customer.nav.calendar', fallback: 'Calendar', badge: 'soon' as const }]
: []),
...(flags.quotes
? [{ key: 'featureQuotes' as const, labelKey: 'customer.nav.quotes', fallback: 'Quotes', badge: 'new' as const }]
: []),
...(flags.bills
? [{ key: 'featureBills' as const, labelKey: 'customer.nav.bills', fallback: 'Bills', badge: 'new' as const }]
: []),
...(flags.hoursLogging
? [{ key: 'featureHoursLogging' as const, labelKey: 'customers.field.featureHoursLogging', fallback: 'Hours logging', badge: 'new' as const }]
: []),
] as const).map(({ key, labelKey, fallback, badge }) => {
const enabled = !!form[key];
return (
<label key={key} className="flex items-center justify-between gap-3 cursor-pointer">
<span className="text-sm font-medium text-theme flex items-center gap-2">
{t(labelKey, fallback)}
{/* Soon badge these tabs are still coming-soon stubs;
this keeps the admin honest when looking at the
toggles. */}
<span
className="text-[10px] uppercase tracking-wider px-1.5 py-0.5 rounded font-semibold bg-amber-100 text-amber-800 dark:bg-amber-900/40 dark:text-amber-300"
>
{t('customer.nav.soon', 'Soon')}
</span>
{/* Status pill 'soon' = amber, 'new' = green.
Colors match Settings Features StatusBadge so
the two surfaces feel consistent. */}
{badge === 'soon' ? (
<span className="text-[10px] uppercase tracking-wider px-1.5 py-0.5 rounded font-semibold bg-amber-100 text-amber-800 dark:bg-amber-900/40 dark:text-amber-300">
{t('customer.nav.soon', 'Soon')}
</span>
) : (
<span className="text-[10px] uppercase tracking-wider px-1.5 py-0.5 rounded font-semibold bg-green-100 text-green-800 dark:bg-green-900/40 dark:text-green-300">
{t('customer.nav.new', 'New')}
</span>
)}
</span>
<button
type="button"
@@ -467,33 +653,273 @@ export const CustomerDetailPage: React.FC = () => {
);
})}
</div>
</Card>
{/* Account actions: password reset (#354 follow-up) */}
{/* Default hourly rate (migration 129). Only shown when the
master `hoursLogging` flag is on AND the per-customer
toggle is on admin shouldn't see a rate field for a
customer who isn't using hours logging. The rate is the
DEFAULT for new entries; admin can still override on a
per-entry basis from the standalone Hours logging page. */}
{flags.hoursLogging && form.featureHoursLogging && (
<div className="mt-4 pt-4 border-t border-neutral-200 dark:border-neutral-700">
<label className="block text-sm font-medium text-theme mb-1">
{t('customers.field.hourlyRate', 'Default hourly rate')}
</label>
<DecimalInput
value={form.hourlyRateMinor != null ? form.hourlyRateMinor / 100 : NaN}
fractionDigits={2}
onChange={(n) => {
setForm((prev) => ({
...prev,
hourlyRateMinor: Number.isFinite(n) ? Math.max(0, Math.round(n * 100)) : null,
} as any));
}}
placeholder="150.00"
className="w-40 input"
/>
<p className="text-xs text-muted-theme mt-1">
{t('customers.field.hourlyRateHint',
'Major units (e.g. 150.00 for {{currency}} 150). Leave blank to require a per-entry override on every block.',
{ currency: profileDefaultCurrency })}
</p>
</div>
)}
</Card>
)}
{/* Billing cadence (migration 102 + 128). Per-event keeps the
standard invoice-per-event flow; monthly accumulates all
invoices issued in a period into one consolidated bill that
fires on the configured cadence day. Cycle day uses
positive 128 for day-of-month, negative -1..-15 for days
before month end. Hidden entirely when the bills feature is
off admin has nothing to bill, so cadence is moot. */}
{flags.bills && (
<Card padding="lg">
<h2 className="text-lg font-semibold text-theme mb-1 flex items-center gap-2">
<Calendar className="w-5 h-5" />
{t('customers.billing.section', 'Billing cadence')}
</h2>
<p className="text-xs text-muted-theme mb-4">
{t('customers.billing.hint',
'Per-event (default): every invoice is sent on its own schedule. Monthly: all invoices issued in the period accumulate into one bill that fires on the configured day.')}
</p>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
<div>
<label className="block text-sm font-medium text-theme mb-1">
{t('customers.billing.cadence', 'Billing cadence')}
</label>
<select
value={form.billingCadence || 'per_event'}
onChange={(e) => setForm((prev) => ({ ...prev, billingCadence: e.target.value } as any))}
className="input w-full"
>
<option value="per_event">{t('customers.billing.perEvent', 'Per event')}</option>
<option value="monthly">{t('customers.billing.monthly', 'Monthly')}</option>
<option value="quarterly">{t('customers.billing.quarterly', 'Quarterly')}</option>
</select>
</div>
{form.billingCadence && form.billingCadence !== 'per_event' && (
<div>
<label className="block text-sm font-medium text-theme mb-1">
{t('customers.billing.cycleDay', 'Cycle day')}
</label>
<input
type="number"
min={-15}
max={28}
value={form.billingCycleDay ?? 1}
onChange={(e) => setForm((prev) => ({ ...prev, billingCycleDay: Number(e.target.value) } as any))}
className="input w-full"
/>
<p className="text-xs text-muted-theme mt-1">
{t('customers.billing.cycleDayHint',
'1..28 = day of month. Use negative -1..-15 for "N days before month end" (so -3 fires on the 28th of a 31-day month).')}
</p>
</div>
)}
</div>
{/* Preview of the open monthly draft (migration 128). Shows
every line item queued for the customer's current billing
period so admin sees exactly what "Trigger invoice now"
would ship. Hidden when no draft exists yet (admin hasn't
saved anything onto the period). */}
{form.billingCadence === 'monthly' && monthlyDraft && monthlyDraft.lineItems.length > 0 && (
<div className="mt-4 pt-4 border-t border-neutral-200 dark:border-neutral-700">
<div className="flex items-center justify-between mb-2">
<h3 className="text-sm font-semibold text-theme">
{t('customers.billing.draftPreview.title',
'Pending in this month\'s bill')}
</h3>
<span className="text-xs text-muted-theme">
{t('customers.billing.draftPreview.periodRange',
'{{number}} · {{from}} {{to}}',
{
number: monthlyDraft.invoiceNumber,
from: fmtDate(monthlyDraft.periodStart),
to: fmtDate(monthlyDraft.periodEnd),
})}
</span>
</div>
<div className="rounded-lg border border-neutral-200 dark:border-neutral-700 overflow-hidden">
<table className="w-full text-sm">
<thead className="bg-neutral-50 dark:bg-neutral-800 text-neutral-700 dark:text-neutral-300">
<tr>
<th className="px-3 py-2 text-left w-12">#</th>
<th className="px-3 py-2 text-left">
{t('crm.lineItems.description', 'Description')}
</th>
<th className="px-3 py-2 text-right w-20">
{t('crm.lineItems.quantity', 'Qty')}
</th>
<th className="px-3 py-2 text-right w-28">
{t('crm.lineItems.unitPrice', 'Unit')}
</th>
<th className="px-3 py-2 text-right w-28">
{t('crm.lineItems.total', 'Total')}
</th>
</tr>
</thead>
<tbody>
{monthlyDraft.lineItems.map((li) => (
<tr key={li.id} className="border-t border-neutral-200 dark:border-neutral-700">
<td className="px-3 py-1.5 tabular-nums text-muted-theme">{li.position}</td>
<td className="px-3 py-1.5">
{li.parentPosition != null && (
<span className="text-muted-theme mr-1"></span>
)}
{li.description}
</td>
<td className="px-3 py-1.5 text-right tabular-nums">{li.quantity}</td>
<td className="px-3 py-1.5 text-right tabular-nums">
{formatMoney(li.unitPriceMinor / 100, monthlyDraft.currency)}
</td>
<td className="px-3 py-1.5 text-right tabular-nums">
{formatMoney(li.lineTotalMinor / 100, monthlyDraft.currency)}
</td>
</tr>
))}
</tbody>
<tfoot className="bg-neutral-50 dark:bg-neutral-800">
<tr className="border-t-2 border-neutral-300 dark:border-neutral-600">
<td colSpan={4} className="px-3 py-2 text-right font-medium">
{t('crm.lineItems.total', 'Total')}
</td>
<td className="px-3 py-2 text-right font-semibold tabular-nums">
{formatMoney(monthlyDraft.totalAmountMinor / 100, monthlyDraft.currency)}
</td>
</tr>
</tfoot>
</table>
</div>
</div>
)}
{/* Manual trigger issue the running monthly draft NOW
instead of waiting for the cadence-day scheduler tick.
Only shown for monthly-mode customers (per-event has no
draft to arm; the equivalent action there is "Bill these
hours" on the standalone Hours-logging page). */}
{form.billingCadence === 'monthly' && (
<div className="mt-4 pt-4 border-t border-neutral-200 dark:border-neutral-700">
<Button
variant="outline"
disabled={triggerMonthlyBillMutation.isPending}
isLoading={triggerMonthlyBillMutation.isPending}
onClick={() => {
if (window.confirm(t('customers.billing.triggerConfirm',
'Issue this customer\'s monthly bill now? The customer receives the email immediately.') as string)) {
triggerMonthlyBillMutation.mutate();
}
}}
>
{t('customers.billing.triggerNow', 'Trigger invoice now')}
</Button>
<p className="text-xs text-muted-theme mt-2">
{t('customers.billing.triggerHint',
'Bypasses the cadence day and issues the running draft immediately. Refuses when nothing has been queued for the current period.')}
</p>
</div>
)}
</Card>
)}
{/* Hours section (migration 129). Only renders when the
feature_hours_logging toggle above is on. Lives between
features and account-actions so admins see it right after
flipping the toggle. */}
{flags.hoursLogging && form.featureHoursLogging && (
<HoursSection
customerId={customerId}
customerHourlyRateMinor={form.hourlyRateMinor ?? null}
billingCadence={(form.billingCadence as any) || customer.billingCadence || 'per_event'}
// compact: history-only + per-event "Bill these hours"
// button. Logging lives on the standalone
// /admin/clients/hours surface so admins have ONE place to
// record new entries; the customer detail page just
// surfaces what's already on the books.
compact
/>
)}
{/* Account actions: password reset OR portal invitation
(#354 follow-up + passive-customer flow). Passive customers
don't have a password to reset the equivalent action is
firing the standard portal-invitation email. We show ONE
card with the right action based on the customer's state. */}
<Card padding="lg">
<h2 className="text-lg font-semibold text-theme mb-1 flex items-center gap-2">
<KeyRound className="w-5 h-5" />
{t('customers.detail.passwordSection', 'Account actions')}
</h2>
<p className="text-xs text-muted-theme mb-4">
{t(
'customers.detail.passwordHint',
'Sends a 7-day single-use reset link to the customer\'s email. The customer\'s current password keeps working until they click the link and set a new one.'
)}
</p>
<Button
variant="outline"
leftIcon={<KeyRound className="w-4 h-4" />}
isLoading={passwordResetMutation.isPending}
disabled={!customer.isActive}
onClick={() => passwordResetMutation.mutate()}
>
{t('customers.detail.passwordReset.button', 'Send password reset email')}
</Button>
{!customer.isActive && (
<p className="text-xs text-muted-theme mt-2">
{t('customers.detail.passwordReset.inactive', 'Reactivate the customer before sending a reset.')}
</p>
{customer.isPassive ? (
<>
<p className="text-xs text-muted-theme mb-4">
{t(
'customers.passive.detailHint',
'This customer has no portal access (admin-only record). Click below to email them a portal sign-up link. The customer\'s existing invoices, quotes, and gallery assignments are preserved when they claim the invitation.',
)}
</p>
<Button
variant="primary"
leftIcon={<KeyRound className="w-4 h-4" />}
isLoading={sendInviteMutation.isPending}
disabled={!customer.isActive || sendInviteMutation.isPending}
onClick={() => sendInviteMutation.mutate()}
>
{t('customers.passive.sendInvite', 'Send portal invitation')}
</Button>
{!customer.isActive && (
<p className="text-xs text-muted-theme mt-2">
{t('customers.passive.deactivatedHint',
'Reactivate the customer before sending the invitation.')}
</p>
)}
</>
) : (
<>
<p className="text-xs text-muted-theme mb-4">
{t(
'customers.detail.passwordHint',
'Sends a 7-day single-use reset link to the customer\'s email. The customer\'s current password keeps working until they click the link and set a new one.'
)}
</p>
<Button
variant="outline"
leftIcon={<KeyRound className="w-4 h-4" />}
isLoading={passwordResetMutation.isPending}
disabled={!customer.isActive}
onClick={() => passwordResetMutation.mutate()}
>
{t('customers.detail.passwordReset.button', 'Send password reset email')}
</Button>
{!customer.isActive && (
<p className="text-xs text-muted-theme mt-2">
{t('customers.detail.passwordReset.inactive', 'Reactivate the customer before sending a reset.')}
</p>
)}
</>
)}
</Card>
@@ -14,15 +14,16 @@
* picker (customers don't have roles access is boolean per event,
* managed via the event form's CustomerAccountPicker).
*/
import React, { useMemo, useState } from 'react';
import React, { useEffect, useMemo, useState } from 'react';
import { Link } from 'react-router-dom';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { toast } from 'react-toastify';
import {
UserPlus, Mail, Trash2, Search, X, AlertTriangle, CheckCircle2, Clock,
UserPlus, UserCog, Trash2, Search, X, AlertTriangle, CheckCircle2, Clock,
} from 'lucide-react';
import { format } from 'date-fns';
import { InlineCustomerCreate } from '../../components/admin/InlineCustomerCreate';
import { useLocalizedDate } from '../../hooks/useLocalizedDate';
import { Button, Card, Input, Loading } from '../../components/common';
import {
@@ -33,269 +34,37 @@ import {
type TabType = 'customers' | 'invitations';
const formatDate = (iso: string | null | undefined) => {
if (!iso) return '—';
try { return format(new Date(iso), 'PP'); } catch { return '—'; }
};
/**
* Invite modal with optional prefill (#354 follow-up).
*
* Email is the only required field. Everything else is collected behind
* a "Add contact details" toggle so a fast invite stays one-click. When
* filled, the values are stashed on the invitation row and re-rendered
* pre-populated on the customer's accept page (where the customer can
* still edit before submitting).
*/
const InviteModal: React.FC<{
isOpen: boolean;
onClose: () => void;
onInvited: () => void;
}> = ({ isOpen, onClose, onInvited }) => {
const { t } = useTranslation();
const [email, setEmail] = useState('');
const [error, setError] = useState<string | null>(null);
const [submitting, setSubmitting] = useState(false);
const [showPrefill, setShowPrefill] = useState(false);
const [prefill, setPrefill] = useState({
salutation: '', first_name: '', last_name: '', display_name: '',
phone: '', company_name: '', vat_id: '',
address_line1: '', address_line2: '', postal_code: '', city: '', state: '', country_code: '',
});
const updatePrefill = (key: keyof typeof prefill, value: string) => {
setPrefill((p) => ({ ...p, [key]: value }));
};
const reset = () => {
setEmail('');
setError(null);
setShowPrefill(false);
setPrefill({
salutation: '', first_name: '', last_name: '', display_name: '',
phone: '', company_name: '', vat_id: '',
address_line1: '', address_line2: '', postal_code: '', city: '', state: '', country_code: '',
});
};
const submit = async (e: React.FormEvent) => {
e.preventDefault();
setError(null);
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
setError(t('customers.invite.invalidEmail', 'Please enter a valid email'));
return;
}
setSubmitting(true);
try {
// Strip empty values so the backend stores `null`/nothing for fields
// the admin didn't actually fill in. Saves a round trip through
// the backend's sanitiser and keeps the JSON payload small.
const cleaned: Record<string, string> = {};
for (const [k, v] of Object.entries(prefill)) {
const trimmed = v.trim();
if (trimmed) cleaned[k] = trimmed;
}
await customerAdminService.invite(
email.trim(),
Object.keys(cleaned).length > 0 ? cleaned : undefined,
);
toast.success(t('customers.invite.success', 'Invitation sent'));
reset();
onInvited();
onClose();
} catch (e: any) {
const msg = e?.response?.status === 409
? t('customers.invite.conflict', 'A customer with this email already exists or has a pending invitation.')
: e?.response?.data?.error || t('customers.invite.error', 'Could not send invitation.');
setError(msg);
} finally {
setSubmitting(false);
}
};
if (!isOpen) return null;
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 px-4 py-8 overflow-y-auto">
<div className="w-full max-w-2xl rounded-xl shadow-lg my-auto" style={{ backgroundColor: 'var(--color-surface)' }}>
<div className="p-6">
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-semibold text-theme">
{t('customers.invite.title', 'Invite a customer')}
</h2>
<button
type="button"
onClick={() => { reset(); onClose(); }}
className="p-1 rounded hover:bg-neutral-100 dark:hover:bg-neutral-700"
aria-label={t('common.close', 'Close')}
>
<X className="w-5 h-5 text-muted-theme" />
</button>
</div>
<p className="text-sm text-muted-theme mb-4">
{t('customers.invite.description',
'They\'ll receive an email with a link to set up their account. Once they\'ve accepted, you can assign them to events.')}
</p>
<form onSubmit={submit} className="space-y-4">
<div>
<label className="block text-sm font-medium text-theme mb-1">
{t('customers.invite.email', 'Email')} <span className="text-red-500">*</span>
</label>
<Input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
error={error || undefined}
leftIcon={<Mail className="w-5 h-5 text-neutral-400" />}
autoFocus
/>
</div>
<div className="border-t pt-4" style={{ borderColor: 'var(--color-surface-border)' }}>
<button
type="button"
onClick={() => setShowPrefill((v) => !v)}
className="text-sm font-medium hover:underline"
style={{ color: 'var(--color-accent)' }}
>
{showPrefill
? t('customers.invite.hidePrefill', ' Hide contact details')
: t('customers.invite.showPrefill', '+ Add contact details (optional)')}
</button>
<p className="mt-1 text-xs text-muted-theme">
{t('customers.invite.prefillHint',
'Anything you fill in will be pre-populated on the customer\'s sign-up page — they can still edit it.')}
</p>
</div>
{showPrefill && (
<div className="space-y-4 pt-2">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<div>
<label className="block text-sm font-medium text-theme mb-1">
{t('customer.profile.field.salutation', 'Salutation')}
</label>
<select
value={prefill.salutation}
onChange={(e) => updatePrefill('salutation', e.target.value)}
className="w-full rounded-lg border px-3 h-10 text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2"
style={{
backgroundColor: 'var(--color-surface)',
borderColor: 'var(--color-surface-border)',
color: 'var(--color-text)',
}}
>
<option value="">{t('customer.profile.salutation.none', '— Not specified —')}</option>
<option value="Herr">{t('customer.profile.salutation.herr', 'Mr.')}</option>
<option value="Frau">{t('customer.profile.salutation.frau', 'Ms.')}</option>
<option value="Mx">{t('customer.profile.salutation.mx', 'Mx')}</option>
<option value="Dr">{t('customer.profile.salutation.dr', 'Dr.')}</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-theme mb-1">
{t('customer.profile.field.displayName', 'Display name')}
</label>
<Input value={prefill.display_name} onChange={(e) => updatePrefill('display_name', e.target.value)} />
</div>
<div>
<label className="block text-sm font-medium text-theme mb-1">
{t('customer.profile.field.firstName', 'First name')}
</label>
<Input value={prefill.first_name} onChange={(e) => updatePrefill('first_name', e.target.value)} />
</div>
<div>
<label className="block text-sm font-medium text-theme mb-1">
{t('customer.profile.field.lastName', 'Last name')}
</label>
<Input value={prefill.last_name} onChange={(e) => updatePrefill('last_name', e.target.value)} />
</div>
<div>
<label className="block text-sm font-medium text-theme mb-1">
{t('customer.profile.field.phone', 'Phone')}
</label>
<Input value={prefill.phone} onChange={(e) => updatePrefill('phone', e.target.value)} />
</div>
<div>
<label className="block text-sm font-medium text-theme mb-1">
{t('customer.profile.field.companyName', 'Company name')}
</label>
<Input value={prefill.company_name} onChange={(e) => updatePrefill('company_name', e.target.value)} />
</div>
<div>
<label className="block text-sm font-medium text-theme mb-1">
{t('customer.profile.field.vatId', 'VAT ID')}
</label>
<Input value={prefill.vat_id} onChange={(e) => updatePrefill('vat_id', e.target.value)} />
</div>
</div>
<div className="grid grid-cols-1 sm:grid-cols-6 gap-3">
<div className="sm:col-span-6">
<label className="block text-sm font-medium text-theme mb-1">
{t('customer.profile.field.addressLine1', 'Address line 1')}
</label>
<Input value={prefill.address_line1} onChange={(e) => updatePrefill('address_line1', e.target.value)} />
</div>
<div className="sm:col-span-6">
<label className="block text-sm font-medium text-theme mb-1">
{t('customer.profile.field.addressLine2', 'Address line 2')}
</label>
<Input value={prefill.address_line2} onChange={(e) => updatePrefill('address_line2', e.target.value)} />
</div>
<div className="sm:col-span-2">
<label className="block text-sm font-medium text-theme mb-1">
{t('customer.profile.field.postalCode', 'Postal code')}
</label>
<Input value={prefill.postal_code} onChange={(e) => updatePrefill('postal_code', e.target.value)} />
</div>
<div className="sm:col-span-3">
<label className="block text-sm font-medium text-theme mb-1">
{t('customer.profile.field.city', 'City')}
</label>
<Input value={prefill.city} onChange={(e) => updatePrefill('city', e.target.value)} />
</div>
<div className="sm:col-span-1">
<label className="block text-sm font-medium text-theme mb-1">
{t('customer.profile.field.countryCode', 'Country')}
</label>
<Input
value={prefill.country_code}
onChange={(e) => updatePrefill('country_code', e.target.value.toUpperCase().slice(0, 2))}
placeholder="DE"
maxLength={2}
/>
</div>
<div className="sm:col-span-3">
<label className="block text-sm font-medium text-theme mb-1">
{t('customer.profile.field.state', 'State / region')}
</label>
<Input value={prefill.state} onChange={(e) => updatePrefill('state', e.target.value)} />
</div>
</div>
</div>
)}
<div className="flex justify-end gap-2 pt-2">
<Button type="button" variant="outline" onClick={() => { reset(); onClose(); }}>
{t('common.cancel', 'Cancel')}
</Button>
<Button type="submit" variant="primary" isLoading={submitting} leftIcon={<UserPlus className="w-4 h-4" />}>
{t('customers.invite.submit', 'Send invitation')}
</Button>
</div>
</form>
</div>
</div>
</div>
);
};
export const CustomerManagementPage: React.FC = () => {
const { t } = useTranslation();
const queryClient = useQueryClient();
// formatDate respects the admin-configured `general_date_format`
// (DD.MM.YYYY by default) instead of the date-fns long-form 'PP'
// (which always rendered "May 8, 2026" regardless of the setting).
const { format: fmtDate } = useLocalizedDate();
const formatDate = (iso: string | null | undefined) => {
if (!iso) return '—';
try { return fmtDate(new Date(iso)); } catch { return '—'; }
};
const [activeTab, setActiveTab] = useState<TabType>('customers');
// `searchTerm` is the live controlled-input value (keeps the box
// responsive). `debouncedTerm` lags 250ms behind so the filter +
// table re-render only fire after the user pauses typing — matches
// the pattern used in CustomerPicker for the same reason. Filtering
// is client-side so this doesn't change network shape; the win is
// on the render side for installs with many rows.
const [searchTerm, setSearchTerm] = useState('');
const [inviteOpen, setInviteOpen] = useState(false);
const [debouncedTerm, setDebouncedTerm] = useState('');
useEffect(() => {
const handle = window.setTimeout(() => setDebouncedTerm(searchTerm), 250);
return () => window.clearTimeout(handle);
}, [searchTerm]);
// Single state drives the unified create/invite modal. Both header
// buttons open the SAME modal (InlineCustomerCreate) — only the
// mode-specific action button is rendered inside, so the admin's
// choice between "create passive" and "invite" is locked in by
// which trigger they clicked but the form fields stay identical.
// `null` = closed.
const [createMode, setCreateMode] = useState<'passive' | 'invite' | null>(null);
const [confirm, setConfirm] = useState<{ kind: 'deactivate'; id: number; name: string } | { kind: 'cancelInvite'; id: number; email: string } | null>(null);
const { data: customers, isLoading: customersLoading, error: customersError } = useQuery({
@@ -310,22 +79,22 @@ export const CustomerManagementPage: React.FC = () => {
const filteredCustomers = useMemo(() => {
const list = customers || [];
if (!searchTerm.trim()) return list;
const term = searchTerm.trim().toLowerCase();
if (!debouncedTerm.trim()) return list;
const term = debouncedTerm.trim().toLowerCase();
return list.filter((c) =>
c.email.toLowerCase().includes(term)
|| (c.displayName || '').toLowerCase().includes(term)
|| (c.lastName || '').toLowerCase().includes(term)
|| (c.companyName || '').toLowerCase().includes(term)
);
}, [customers, searchTerm]);
}, [customers, debouncedTerm]);
const filteredInvitations = useMemo(() => {
const list = invitations || [];
if (!searchTerm.trim()) return list;
const term = searchTerm.trim().toLowerCase();
if (!debouncedTerm.trim()) return list;
const term = debouncedTerm.trim().toLowerCase();
return list.filter((i) => i.email.toLowerCase().includes(term));
}, [invitations, searchTerm]);
}, [invitations, debouncedTerm]);
const deactivateMutation = useMutation({
mutationFn: (id: number) => customerAdminService.deactivate(id),
@@ -397,9 +166,14 @@ export const CustomerManagementPage: React.FC = () => {
{t('customers.pageSubtitle', 'Recurring customer accounts that can log in at /customer/login.')}
</p>
</div>
<Button variant="primary" leftIcon={<UserPlus className="w-4 h-4" />} onClick={() => setInviteOpen(true)}>
{t('customers.invite.button', 'Invite customer')}
</Button>
<div className="flex flex-wrap items-center gap-2">
<Button variant="outline" leftIcon={<UserCog className="w-4 h-4" />} onClick={() => setCreateMode('passive')}>
{t('customers.create.openButton', 'Create passive customer')}
</Button>
<Button variant="primary" leftIcon={<UserPlus className="w-4 h-4" />} onClick={() => setCreateMode('invite')}>
{t('customers.invite.button', 'Invite customer')}
</Button>
</div>
</div>
<Card padding="lg">
@@ -453,17 +227,28 @@ export const CustomerManagementPage: React.FC = () => {
<td className="px-3 py-3 text-muted-theme">{c.eventCount ?? 0}</td>
<td className="px-3 py-3 text-muted-theme">{formatDate(c.lastLogin)}</td>
<td className="px-3 py-3">
{c.isActive ? (
<span className="inline-flex items-center gap-1 text-xs" style={{ color: 'var(--color-accent)' }}>
<CheckCircle2 className="w-3.5 h-3.5" />
{t('customers.status.active', 'Active')}
</span>
) : (
<span className="inline-flex items-center gap-1 text-xs text-red-600">
<X className="w-3.5 h-3.5" />
{t('customers.status.inactive', 'Deactivated')}
</span>
)}
<div className="flex flex-col gap-1">
{c.isActive ? (
<span className="inline-flex items-center gap-1 text-xs" style={{ color: 'var(--color-accent)' }}>
<CheckCircle2 className="w-3.5 h-3.5" />
{t('customers.status.active', 'Active')}
</span>
) : (
<span className="inline-flex items-center gap-1 text-xs text-red-600">
<X className="w-3.5 h-3.5" />
{t('customers.status.inactive', 'Deactivated')}
</span>
)}
{/* Passive customers (no portal access). The
status badge sits on its own line so a
passive deactivated customer can still
show both states clearly. */}
{c.isPassive && (
<span className="inline-flex items-center gap-1 text-[11px] px-1.5 py-0.5 rounded bg-neutral-100 dark:bg-neutral-800 text-neutral-700 dark:text-neutral-300">
{t('customers.passive.badge', 'Passive — admin only')}
</span>
)}
</div>
</td>
<td className="px-3 py-3 text-right">
{c.isActive && (
@@ -540,11 +325,37 @@ export const CustomerManagementPage: React.FC = () => {
)}
</Card>
<InviteModal
isOpen={inviteOpen}
onClose={() => setInviteOpen(false)}
onInvited={() => queryClient.invalidateQueries({ queryKey: ['admin-customer-invitations'] })}
/>
{/* Unified create / invite modal. The form is identical in both
modes only the bottom action button differs (Save as
passive vs. Save & send portal invitation). Both flows go
through InlineCustomerCreate's existing
createDirect-then-sendInvite path, so the customer row is
materialised immediately and the "Invitations" tab refreshes
on success to surface the pending invite in the invite case. */}
{createMode !== null && (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 px-4"
onClick={() => setCreateMode(null)}
>
<div
className="w-full max-w-2xl rounded-xl shadow-lg max-h-[90vh] overflow-y-auto"
style={{ backgroundColor: 'var(--color-surface)' }}
onClick={(e) => e.stopPropagation()}
>
<div className="p-6">
<InlineCustomerCreate
mode={createMode}
onCancel={() => setCreateMode(null)}
onCreated={() => {
setCreateMode(null);
queryClient.invalidateQueries({ queryKey: ['admin-customers'] });
queryClient.invalidateQueries({ queryKey: ['admin-customer-invitations'] });
}}
/>
</div>
</div>
</div>
)}
{confirm && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 px-4">
@@ -39,6 +39,13 @@ const CATEGORY_ORDER: readonly string[] = [
'customers',
'calendar',
'quotes',
// 'contracts' sits between quotes and billing — matches the
// quote → contract → invoice document flow + the order of the
// Admin → Clients sub-nav so admins find the right bucket at a
// glance. Migration 130 seeds rows with category='contracts';
// before this entry existed they fell through to 'core' via the
// unknown-category fallback below.
'contracts',
'billing',
] as const;
+19 -1
View File
@@ -60,6 +60,8 @@ import { useLocalizedDate } from '../../hooks/useLocalizedDate';
import { Button, Input, Card, Loading, MarkdownContent } from '../../components/common';
import { EventCategoryManager, AdminPhotoGrid, AdminPhotoViewer, PhotoFilters, PasswordResetModal, ThemeCustomizerEnhanced, ThemeDisplay, HeroPhotoSelector, FocalPointPicker, PhotoUploadModal, FeedbackSettings, FeedbackModerationPanel, EventRenameDialog, PhotoFilterPanel, PhotoExportMenu, AdminGuestsList } from '../../components/admin';
import { CustomerAccountPicker } from '../../components/admin/CustomerAccountPicker';
import { EventReminderOverrideCard } from '../../components/admin/EventReminderOverrideCard';
import { useFeatureFlags } from '../../contexts/FeatureFlagsContext';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { eventsService } from '../../services/events.service';
import { usePublicSettings } from '../../hooks/usePublicSettings';
@@ -243,7 +245,8 @@ export const EventDetailsPage: React.FC = () => {
const queryClient = useQueryClient();
const { t } = useTranslation();
const { format } = useLocalizedDate();
const { flags } = useFeatureFlags();
// Validate ID parameter
React.useEffect(() => {
if (!id || isNaN(parseInt(id))) {
@@ -2092,6 +2095,21 @@ export const EventDetailsPage: React.FC = () => {
</div>
</Card>
{/* Pre-event reminder override (migration 143). Hidden when
the reminderEmails master flag is off the override here
would never fire since the cron itself no-ops. */}
{flags.reminderEmails && (
<EventReminderOverrideCard
eventId={event.id}
initial={{
event_reminder_disabled: event.event_reminder_disabled,
event_reminder_offset_days: event.event_reminder_offset_days,
event_reminder_body_override: event.event_reminder_body_override,
}}
onSaved={() => refetchEvent()}
/>
)}
{/* Actions */}
{!event.is_archived && (
<Card padding="md">
+53 -2
View File
@@ -46,6 +46,13 @@ import { BrandingPage } from './BrandingPage';
import { EventTypesPage } from './EventTypesPage';
import { BackupManagement } from './BackupManagement';
import { CMSPage } from './CMSPage';
// CRM (#TBD)
import { SettingsBusinessProfilePage } from './settings/SettingsBusinessProfilePage';
import { CrmSettingsPage } from './settings/CrmSettingsPage';
import { ReminderTemplatesPage } from './settings/ReminderTemplatesPage';
import { BlockLibraryPage } from './contracts/BlockLibraryPage';
import { useFeatureFlags } from '../../contexts/FeatureFlagsContext';
import { Briefcase, Receipt, ScrollText, Mail } from 'lucide-react';
// Tab keys driving the inner-nav. Must include every key used in
// `navGroups` below and in the switch at the bottom of the component.
@@ -68,7 +75,13 @@ type TabType =
| 'webhooks'
| 'status'
| 'analytics'
| 'backup';
| 'backup'
// CRM (#TBD): issuer block for quote/invoice PDFs and per-area
// CRM behaviour toggles.
| 'businessProfile'
| 'crm'
| 'contracts'
| 'reminderTemplates';
interface NavItem {
key: TabType;
@@ -88,6 +101,7 @@ const ALL_TAB_KEYS: TabType[] = [
'security', 'imageSecurity', 'seo',
'apiTokens', 'webhooks',
'status', 'analytics', 'backup',
'businessProfile', 'crm', 'contracts', 'reminderTemplates',
];
function isValidTab(value: string | null): value is TabType {
@@ -97,6 +111,7 @@ function isValidTab(value: string | null): value is TabType {
export const SettingsPage: React.FC = () => {
const { t } = useTranslation();
const [searchParams, setSearchParams] = useSearchParams();
const { flags } = useFeatureFlags();
// Read ?tab=… on mount; default to Features per the redesign.
const initialTab: TabType = isValidTab(searchParams.get('tab'))
@@ -218,6 +233,26 @@ export const SettingsPage: React.FC = () => {
{ key: 'webhooks', label: t('settings.webhooks.title', 'Webhooks'), icon: Webhook },
],
},
{
// CRM group. businessProfile is always relevant (the issuer block
// feeds every PDF, gallery hero, footer, etc — even with zero
// CRM features). The remaining items hide when their matching
// master flag is off so admins don't navigate to a tab that
// configures a feature they can't actually use.
label: t('settings.groups.crm', 'CRM-Settings'),
items: [
{ key: 'businessProfile', label: t('settings.businessProfile.title', 'Business profile'), icon: Briefcase },
...(flags.quotes || flags.bills || flags.contracts
? [{ key: 'crm' as const, label: t('settings.crm.title', 'CRM behaviour'), icon: Receipt }]
: []),
...(flags.contracts
? [{ key: 'contracts' as const, label: t('settings.contracts.title', 'Contracts'), icon: ScrollText }]
: []),
...(flags.reminderEmails
? [{ key: 'reminderTemplates' as const, label: t('settings.reminderTemplates.title', 'Reminder emails'), icon: Mail }]
: []),
],
},
{
label: t('settings.groups.system', 'System'),
items: [
@@ -231,11 +266,23 @@ export const SettingsPage: React.FC = () => {
const allItems = navGroups.flatMap((g) => g.items);
const activeItem = allItems.find((i) => i.key === activeTab) ?? allItems[0];
// If the active tab refers to an item that's now hidden (e.g. admin
// landed on ?tab=reminderTemplates after disabling reminderEmails),
// snap to the first visible item so the content area doesn't render
// a hidden tab's UI. Effect re-fires when flags toggle live.
useEffect(() => {
const visibleKeys = allItems.map((i) => i.key);
if (!visibleKeys.includes(activeTab) && visibleKeys.length > 0) {
setActiveTab(visibleKeys[0]);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [flags.quotes, flags.bills, flags.contracts, flags.reminderEmails, activeTab]);
// For tabs that mount existing top-level pages OR bring their own
// header (FeaturesTab has its own icon+title+description block), skip
// the Settings shell's section heading so the layout doesn't double
// up.
const TABS_WITH_OWN_HEADER: TabType[] = ['features', 'email', 'branding', 'eventTypes', 'backup', 'cms'];
const TABS_WITH_OWN_HEADER: TabType[] = ['features', 'email', 'branding', 'eventTypes', 'backup', 'cms', 'contracts', 'reminderTemplates'];
const showSectionHeading = !TABS_WITH_OWN_HEADER.includes(activeTab);
return (
@@ -363,6 +410,10 @@ export const SettingsPage: React.FC = () => {
{activeTab === 'cms' && <CMSPage />}
{activeTab === 'email' && <EmailConfigPage />}
{activeTab === 'backup' && <BackupManagement />}
{activeTab === 'businessProfile' && <SettingsBusinessProfilePage />}
{activeTab === 'crm' && <CrmSettingsPage />}
{activeTab === 'contracts' && <BlockLibraryPage />}
{activeTab === 'reminderTemplates' && <ReminderTemplatesPage />}
{activeTab === 'status' && (
<StatusTab
@@ -5,6 +5,7 @@ import { toast } from 'react-toastify';
import { ArrowLeft, RefreshCw, RotateCw, Send, X, AlertCircle, CheckCircle2, Clock } from 'lucide-react';
import { Button, Card, Loading } from '../../components/common';
import { api } from '../../config/api';
import { useLocalizedDate } from '../../hooks/useLocalizedDate';
const WEBHOOK_EVENT_TYPES = [
'event.created',
@@ -64,6 +65,7 @@ export const WebhookDeliveriesPage: React.FC = () => {
const { id } = useParams<{ id: string }>();
const webhookId = parseInt(id || '', 10);
const queryClient = useQueryClient();
const { formatDateTime: fmtDateTime } = useLocalizedDate();
const [filter, setFilter] = useState<StatusFilter>('all');
const [openDeliveryId, setOpenDeliveryId] = useState<number | null>(null);
@@ -233,7 +235,7 @@ export const WebhookDeliveriesPage: React.FC = () => {
onClick={() => setOpenDeliveryId(d.id)}
>
<td className="py-2.5 pr-3 text-xs text-neutral-600 dark:text-neutral-400">
{new Date(d.created_at).toLocaleString()}
{fmtDateTime(d.created_at)}
</td>
<td className="py-2.5 pr-3 font-mono text-xs">{d.event_type}</td>
<td className="py-2.5 pr-3">
@@ -0,0 +1,529 @@
/**
* Invoice detail page. Displays the invoice + line items + payment log;
* exposes the action set: Preview PDF, Send, Mark paid (modal), Send
* reminder (manual escalation), Cancel.
*/
import React, { useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Link, useNavigate, useParams } from 'react-router-dom';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { ArrowLeft, Eye, Send, CheckCircle, BellRing, XCircle, Truck, Edit2, RefreshCw } from 'lucide-react';
import { Button, Card, Loading, Input } from '../../../components/common';
import { DocumentLineageCard } from '../../../components/admin/DocumentLineageCard';
import { billsService } from '../../../services/bills.service';
import { formatMoney } from '../../../components/admin/LineItemsTable';
import { useLocalizedDate } from '../../../hooks/useLocalizedDate';
import { toast } from 'react-toastify';
export const BillDetailPage: React.FC = () => {
const { t } = useTranslation();
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const { format: fmtDate } = useLocalizedDate();
const qc = useQueryClient();
const { data, isLoading } = useQuery({
queryKey: ['invoice', id],
queryFn: () => billsService.get(parseInt(id!, 10)),
enabled: !!id,
});
const [payDialogOpen, setPayDialogOpen] = useState(false);
const [payAmount, setPayAmount] = useState('');
const [payMethod, setPayMethod] = useState('');
const [payReference, setPayReference] = useState('');
const [payNotes, setPayNotes] = useState('');
// Migration 126 — admin ticks this when the customer paid the
// discounted total within the Skonto window. The dialog auto-fills
// the amount with total × (1 - skonto%) when ticked, but admin can
// still override it (e.g. partial Skonto + partial waive).
const [payWithSkonto, setPayWithSkonto] = useState(false);
// Pre-build the line-item rows once per data change. Previously this
// was an inline IIFE inside the JSX, rebuilding the array (and N
// <tr> elements) on every render of the page — every payment-dialog
// input keystroke triggered the full reshape. The hook lives above
// the early-return so the rules of hooks stay happy; it returns []
// while data is loading.
const lineItemRows = useMemo<React.ReactNode[]>(() => {
if (!data) return [];
// Migration 119 hierarchy: top-level items get 1, 2, 3…, sub-items
// render as N.M under their parent (indented, greyed, line total
// in parens, empty price cells when unit_price = 0). Details_text
// rows render as a small italic line below their parent. Mirrors
// the customer-facing QuoteResponsePage table.
let topCount = 0;
let subCount = 0;
const rows: React.ReactNode[] = [];
const currency = data.invoice.currency;
for (const li of data.lineItems) {
const isSub = li.parentLineItemId != null || li.parentPosition != null;
if (!isSub) { topCount += 1; subCount = 0; } else { subCount += 1; }
const priceless = isSub && (!li.unitPriceMinor || Number(li.unitPriceMinor) === 0);
rows.push(
<tr
key={`row-${li.id ?? li.position}`}
className={`border-b border-neutral-100 dark:border-neutral-800 ${
isSub ? 'text-neutral-500 dark:text-neutral-400' : ''
}`}
>
<td className="py-2">{isSub ? `${topCount}.${subCount}` : topCount}</td>
<td className="py-2">{Number(li.quantity)}</td>
<td className={`py-2 whitespace-pre-line ${isSub ? 'pl-6' : ''}`}>
{isSub ? '• ' : ''}{li.description}
</td>
<td className="py-2 text-right tabular-nums">
{priceless ? '' : formatMoney(Number(li.unitPriceMinor || 0) / 100, currency)}
</td>
<td className={`py-2 text-right tabular-nums ${isSub ? 'italic' : ''}`}>
{priceless
? ''
: isSub
? `(${formatMoney(Number(li.lineTotalMinor || 0) / 100, currency)})`
: formatMoney(Number(li.lineTotalMinor || 0) / 100, currency)}
</td>
</tr>
);
if (li.detailsText && String(li.detailsText).trim().length > 0) {
rows.push(
<tr key={`details-${li.id ?? li.position}`} className="border-b border-neutral-100 dark:border-neutral-800">
<td className="py-1"></td>
<td className="py-1"></td>
<td
className={`py-1 text-xs italic text-neutral-500 dark:text-neutral-400 whitespace-pre-line ${isSub ? 'pl-10' : 'pl-4'}`}
colSpan={3}
>
{li.detailsText}
</td>
</tr>
);
}
}
return rows;
}, [data]);
if (isLoading || !data) return <Loading />;
const inv = data.invoice;
const handlePreview = async () => {
// Sync-open the placeholder window before any await so the popup
// blocker treats this as a user gesture, then redirect once the
// blob URL is ready.
const previewWindow = window.open('about:blank', '_blank');
if (!previewWindow) {
toast.error(t('bills.errors.popupBlocked', 'Allow pop-ups for this site to preview the PDF.'));
return;
}
try {
const url = await billsService.pdfUrl(inv.id);
previewWindow.location.href = url;
} catch (err: any) {
previewWindow.close();
toast.error(err?.response?.data?.error || err.message || 'Preview failed');
}
};
const handleSend = async () => {
if (!window.confirm(t('bills.confirmSend', 'Send invoice to customer now?'))) return;
try { await billsService.send(inv.id); toast.success(t('bills.sentToast', 'Invoice sent.')); qc.invalidateQueries({ queryKey: ['invoice', id] }); }
catch (e: any) { toast.error(e?.response?.data?.error || 'Send failed'); }
};
const handleReminder = async () => {
if (!window.confirm(t('bills.confirmReminder', 'Send a reminder now?'))) return;
try { await billsService.sendReminder(inv.id); toast.success(t('bills.reminderToast', 'Reminder sent.')); qc.invalidateQueries({ queryKey: ['invoice', id] }); }
catch (e: any) { toast.error(e?.response?.data?.error || 'Reminder failed'); }
};
const handleCancel = async () => {
// Confirmation copy depends on whether the invoice has been
// issued: drafts get a quiet soft-cancel, but sent/overdue/paid
// invoices trigger a Stornorechnung that's emailed to the
// customer immediately. We surface that contract explicitly so
// admins know it can't be undone.
const msg = inv.status === 'scheduled'
? t('bills.confirmCancelDraft', 'Cancel this draft invoice? No document goes out.')
: t('bills.confirmCancelIssued',
'A Stornorechnung will be generated and emailed to the customer immediately. This cannot be undone. Continue?');
if (!window.confirm(msg)) return;
try {
const result = await billsService.cancel(inv.id);
toast.success(result.stornoId
? t('bills.cancelledWithStornoToast', 'Invoice cancelled — Stornorechnung issued to the customer.')
: t('bills.cancelledToast', 'Invoice cancelled.'));
qc.invalidateQueries({ queryKey: ['invoice', id] });
qc.invalidateQueries({ queryKey: ['invoices'] });
} catch (e: any) {
toast.error(e?.response?.data?.error || 'Cancel failed');
}
};
/**
* Cancel + reissue the legally-correct alternative to editing
* a sent invoice. Atomically:
* 1. Cancels this invoice (status cancelled)
* 2. Creates a new scheduled invoice with a fresh number,
* same line items, linked via replacesInvoiceId
* 3. Navigates to the new invoice's editor so the admin can
* adjust whatever was wrong before sending
* For invoices that were never sent, use Edit instead the
* backend rejects reissue with USE_EDIT_INSTEAD.
*/
const handleReissue = async () => {
// For already-cancelled invoices the backend skips Storno creation
// (the original was either soft-cancelled as a draft, or a prior
// Storno already exists). For live invoices we explicitly warn
// that a Stornorechnung will be issued + emailed.
const msg = inv.status === 'cancelled'
? t('bills.confirmReissueCancelled',
'Create a new scheduled invoice with the same line items, linked back to this cancelled one?')
: t('bills.confirmReissue',
'A Stornorechnung will be issued to the customer for this invoice, and a new scheduled draft will be created with the same line items. The new invoice will reference this one as "Replaces R-XXXX". Continue?');
if (!window.confirm(msg)) return;
try {
const result = await billsService.reissue(inv.id);
toast.success(result.stornoId
? t('bills.reissuedWithStornoToast',
'Stornorechnung issued — opening the new draft.')
: t('bills.reissuedToast', 'Invoice reissued — opening the new draft.'));
qc.invalidateQueries({ queryKey: ['invoice', id] });
qc.invalidateQueries({ queryKey: ['invoices'] });
navigate(`/admin/clients/bills/${result.id}/edit`);
} catch (e: any) {
toast.error(e?.response?.data?.error || 'Reissue failed');
}
};
/**
* Release a delivery invoice fires immediately. Used for the
* last installment in a split-payment plan (after_delivery
* trigger). Photos have been delivered, admin clicks the button,
* customer gets the final invoice.
*/
const handleRelease = async () => {
if (!window.confirm(t('bills.confirmRelease',
'Mark the photos as delivered and send this invoice to the customer now?'))) return;
try {
await billsService.releaseForDelivery(inv.id);
toast.success(t('bills.releasedToast', 'Delivery invoice sent.'));
qc.invalidateQueries({ queryKey: ['invoice', id] });
} catch (e: any) {
toast.error(e?.response?.data?.error || 'Release failed');
}
};
const submitPayment = async () => {
try {
await billsService.markPaid(inv.id, {
amountMinor: Math.round(Number(payAmount) * 100),
paymentMethod: payMethod || undefined,
reference: payReference || undefined,
notes: payNotes || undefined,
skontoApplied: payWithSkonto,
});
setPayDialogOpen(false);
setPayAmount(''); setPayMethod(''); setPayReference(''); setPayNotes('');
setPayWithSkonto(false);
qc.invalidateQueries({ queryKey: ['invoice', id] });
toast.success(t('bills.paymentRecordedToast', 'Payment recorded.'));
} catch (e: any) {
toast.error(e?.response?.data?.error || 'Failed to record payment');
}
};
// "Outstanding" mirrors the server's paid-threshold (principal only,
// late fee tracked separately) so the placeholder value in the
// mark-paid dialog matches the amount that flips the invoice to
// status='paid'. The late-fee row above still shows the surcharge
// separately so admins know what they could optionally collect.
//
// When the server has already flipped the invoice to 'paid' — including
// the Skonto path where paid_amount_minor < total_amount_minor by the
// discount — outstanding MUST read zero. Without this, the Skonto branch
// of markPaid leaves the customer-facing UI claiming the discounted
// amount is still owed (migration 126 bug surfaced as `total - paid`
// doesn't subtract the Skonto discount).
const outstanding = inv.status === 'paid'
? 0
: (Number(inv.totalAmountMinor || 0) - Number(inv.paidAmountMinor || 0)) / 100;
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<div>
<button onClick={() => navigate('/admin/clients/bills')}
className="text-sm text-neutral-600 dark:text-neutral-400 hover:underline mb-1 inline-flex items-center gap-1">
<ArrowLeft className="w-4 h-4" /> {t('common.back', 'Back')}
</button>
<h2 className="text-xl font-bold">{inv.invoiceNumber}
{/* Storno discriminator badge sits next to the number so
the admin sees at a glance that this row is a
cancellation document, not an invoice. */}
{inv.kind === 'storno' && (
<span className="ml-2 text-xs font-semibold px-2 py-0.5 rounded bg-purple-100 text-purple-800">
{t('bills.kind.storno', 'Stornorechnung')}
</span>
)}
<span className="ml-2 text-xs font-medium px-2 py-0.5 rounded bg-neutral-100 text-neutral-700">
{t(`bills.status.${inv.status}`, inv.status)}
</span>
</h2>
<p className="text-sm text-neutral-600 dark:text-neutral-400">
{inv.customer.companyName || inv.customer.displayName || inv.customer.email}
</p>
</div>
<div className="flex gap-2 flex-wrap">
<Button variant="outline" onClick={handlePreview}><Eye className="w-4 h-4 mr-1" />{t('common.preview', 'Preview')}</Button>
{/* The bulk of the action set is gated on `kind === 'invoice'`
a Stornorechnung can't be edited, marked paid,
reminded, cancelled, or reissued. The backend enforces
these guards (IS_STORNO errors), but hiding the buttons
keeps the admin from staring at greyed-out controls.
The Send button stays available for storni in the rare
edge case where the auto-send during creation failed
and the scheduler hasn't retried yet. */}
{inv.kind !== 'storno' && inv.status === 'scheduled' && (
<Button variant="outline" onClick={() => navigate(`/admin/clients/bills/${inv.id}/edit`)}>
<Edit2 className="w-4 h-4 mr-1" />{t('common.edit', 'Edit')}
</Button>
)}
{/* Send button: hidden on monthly drafts (migration 128).
Drafts ship only via "Trigger invoice now" on the customer
detail page OR via the scheduled cadence-day flush
never directly. Clicking Send on a draft would early-ship
the running accumulator AND leave is_monthly_draft=true,
causing the next line-item save to silently append onto
the already-sent row. Backend also enforces this. */}
{['scheduled', 'sent', 'overdue'].includes(inv.status) && !inv.isMonthlyDraft && (
<Button onClick={handleSend}><Send className="w-4 h-4 mr-1" />{inv.status === 'scheduled' ? t('bills.sendNow', 'Send now') : t('bills.resend', 'Resend')}</Button>
)}
{inv.isMonthlyDraft && (
<span className="inline-flex items-center px-3 py-1.5 rounded-md text-xs font-medium bg-blue-100 text-blue-800 dark:bg-blue-900/40 dark:text-blue-200">
{t('bills.monthlyDraftBadge',
'Monthly draft — ships via the customer\'s cadence day or "Trigger invoice now"')}
</span>
)}
{inv.kind !== 'storno' && inv.status === 'pending_delivery' && (
<Button onClick={handleRelease}>
<Truck className="w-4 h-4 mr-1" />
{t('bills.releaseForDelivery', 'Mark delivered & send')}
</Button>
)}
{inv.kind !== 'storno' && inv.status !== 'paid' && inv.status !== 'cancelled' && (
<Button variant="outline" onClick={() => {
// Pre-fill the reference with the invoice number — that's
// what the admin types 95% of the time, so save them the
// keystroke. Method left blank so the dropdown's "Select
// method…" placeholder still nudges them to pick.
setPayReference((cur) => cur || inv.invoiceNumber || '');
setPayDialogOpen(true);
}}>
<CheckCircle className="w-4 h-4 mr-1" />{t('bills.markPaid', 'Mark paid')}
</Button>
)}
{inv.kind !== 'storno' && (inv.status === 'sent' || inv.status === 'overdue') && inv.reminderLevel < 2 && (
<Button variant="outline" onClick={handleReminder}>
<BellRing className="w-4 h-4 mr-1" />{t('bills.sendReminder', 'Send reminder')}
</Button>
)}
{inv.kind !== 'storno' && inv.status !== 'cancelled' && (
<Button variant="outline" onClick={handleCancel}>
<XCircle className="w-4 h-4 mr-1" />{t('common.cancel', 'Cancel')}
</Button>
)}
{/* Cancel & reissue the legally-clean alternative to
post-send editing. Available for any invoice that's
already been issued (sent, overdue, paid) or already
cancelled (no-op cancel + create-new). Hidden for
scheduled (use Edit) and pending_delivery (Release
first if relevant). Hidden on storni a Storno is the
cancellation, not a candidate for further cancellation. */}
{inv.kind !== 'storno' && ['sent', 'overdue', 'paid', 'cancelled'].includes(inv.status) && (
<Button variant="outline" onClick={handleReissue}>
<RefreshCw className="w-4 h-4 mr-1" />
{inv.status === 'cancelled'
? t('bills.reissue', 'Reissue')
: t('bills.cancelAndReissue', 'Cancel & reissue')}
</Button>
)}
</div>
</div>
{/* Storno lineage banners. Two cases:
- This row IS a Storno show "Cancels Rechnung R-XXXX" with
a link back to the original it reverses.
- This row is a cancelled invoice with a Storno on file
show "Cancelled by Storno S-XXXX" with a forward link.
Both lineage links are clickable so the admin can hop
between the document pair without leaving the detail flow. */}
{inv.kind === 'storno' && inv.cancelsInvoiceId && (
<Card padding="md" className="bg-purple-50 dark:bg-purple-950/30 border-purple-200 dark:border-purple-800">
<p className="text-sm text-purple-900 dark:text-purple-200">
{t('bills.stornoCancelsLabel', 'This Stornorechnung cancels invoice')}{' '}
<Link to={`/admin/clients/bills/${inv.cancelsInvoiceId}`} className="font-medium underline">
{inv.cancelsInvoiceNumber || `#${inv.cancelsInvoiceId}`}
</Link>
.
</p>
</Card>
)}
{inv.kind !== 'storno' && inv.status === 'cancelled' && inv.cancellationStornoId && (
<Card padding="md" className="bg-amber-50 dark:bg-amber-950/30 border-amber-200 dark:border-amber-800">
<p className="text-sm text-amber-900 dark:text-amber-200">
{t('bills.cancelledByStornoLabel', 'This invoice was cancelled by Stornorechnung')}{' '}
<Link to={`/admin/clients/bills/${inv.cancellationStornoId}`} className="font-medium underline">
{inv.cancellationStornoNumber || `#${inv.cancellationStornoId}`}
</Link>
.
</p>
</Card>
)}
{/* Cross-document lineage via deal_uuid (migration 140). Replaces
the per-FK LinkedDocumentsCard. Storno relationships stay in
the coloured callout cards above those are warnings, not
just lineage. */}
<DocumentLineageCard
dealUuid={inv.dealUuid}
current={{ kind: 'invoice', id: inv.id }}
className="mt-4"
/>
<Card>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 text-sm">
{inv.eventName && (
<div><div className="text-neutral-600 dark:text-neutral-300">{t('bills.field.eventName', 'Event')}</div><div>{inv.eventName}{inv.eventDate ? ` · ${inv.eventDate}` : ''}</div></div>
)}
<div><div className="text-neutral-600 dark:text-neutral-300">{t('bills.field.issueDate', 'Issued')}</div><div>{fmtDate(inv.issueDate)}</div></div>
<div><div className="text-neutral-600 dark:text-neutral-300">{t('bills.field.dueDate', 'Due')}</div><div>{fmtDate(inv.dueDate)}</div></div>
{inv.scheduledSendAt && <div><div className="text-neutral-600 dark:text-neutral-300">{t('bills.field.scheduledSendAt', 'Scheduled send')}</div><div>{fmtDate(inv.scheduledSendAt)}</div></div>}
{inv.installmentTotal > 1 && <div><div className="text-neutral-600 dark:text-neutral-300">{t('bills.field.installment', 'Installment')}</div><div>{inv.installmentIndex + 1}/{inv.installmentTotal}</div></div>}
<div><div className="text-neutral-600 dark:text-neutral-300">{t('bills.field.total', 'Total')}</div><div>{formatMoney(Number(inv.totalAmountMinor || 0) / 100, inv.currency)}</div></div>
<div><div className="text-neutral-600 dark:text-neutral-300">{t('bills.field.paid', 'Paid')}</div><div>{formatMoney(Number(inv.paidAmountMinor || 0) / 100, inv.currency)}</div></div>
<div><div className="text-neutral-600 dark:text-neutral-300">{t('bills.field.outstanding', 'Outstanding')}</div>
<div className={outstanding > 0 ? 'text-red-700 font-medium' : ''}>{formatMoney(outstanding, inv.currency)}</div></div>
{inv.lateFeeAmountMinor > 0 && <div><div className="text-neutral-600 dark:text-neutral-300">{t('bills.field.lateFee', 'Late fee')}</div><div className="text-amber-700">{formatMoney(Number(inv.lateFeeAmountMinor) / 100, inv.currency)}</div></div>}
{/* Source-quote / source-contract cross-links moved out of
the top stats grid into the unified Linked-documents card
above, mirroring the quote + contract detail pages. The
customers see the same provenance as a "Bezug: ..." line
on the PDF itself. */}
</div>
</Card>
<Card>
<h3 className="font-semibold mb-3">{t('bills.section.lineItems', 'Line items')}</h3>
<table className="w-full text-sm">
<thead><tr className="border-b border-neutral-200 dark:border-neutral-700">
<th className="text-left py-2">#</th>
<th className="text-left py-2">{t('crm.lineItems.quantity', 'Qty')}</th>
<th className="text-left py-2">{t('crm.lineItems.description', 'Description')}</th>
<th className="text-right py-2">{t('crm.lineItems.unitPrice', 'Unit')}</th>
<th className="text-right py-2">{t('crm.lineItems.total', 'Total')}</th>
</tr></thead>
<tbody>{lineItemRows}</tbody>
</table>
</Card>
<Card>
<h3 className="font-semibold mb-3">{t('bills.section.paymentLog', 'Payment log')}</h3>
{data.payments.length === 0 ? (
<p className="text-sm text-neutral-500">{t('bills.noPayments', 'No payments recorded yet.')}</p>
) : (
<table className="w-full text-sm">
<thead><tr className="border-b border-neutral-200 dark:border-neutral-700">
{/* Per-cell horizontal padding so the right-aligned
Amount column and the left-aligned Method column
have visible breathing room. Without padding the
two collide visually on narrow rows. */}
<th className="text-left py-2 pr-4">{t('bills.payment.paidAt', 'Date')}</th>
<th className="text-right py-2 px-4">{t('bills.payment.amount', 'Amount')}</th>
<th className="text-left py-2 pl-4 pr-4">{t('bills.payment.method', 'Method')}</th>
<th className="text-left py-2 pr-4">{t('bills.payment.reference', 'Reference')}</th>
<th className="text-left py-2">{t('bills.payment.notes', 'Notes')}</th>
</tr></thead>
<tbody>
{data.payments.map((p) => (
<tr key={p.id} className="border-b border-neutral-100 dark:border-neutral-800">
<td className="py-2 pr-4 whitespace-nowrap">{fmtDate(p.paidAt)}</td>
<td className="py-2 px-4 text-right tabular-nums whitespace-nowrap">{formatMoney(Number(p.amountMinor) / 100, inv.currency)}</td>
<td className="py-2 pl-4 pr-4 whitespace-nowrap">{p.paymentMethod || '—'}</td>
<td className="py-2 pr-4 font-mono text-xs">{p.reference || '—'}</td>
<td className="py-2">{p.notes || '—'}</td>
</tr>
))}
</tbody>
</table>
)}
</Card>
{payDialogOpen && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50" onClick={() => setPayDialogOpen(false)}>
<div className="bg-white dark:bg-neutral-900 rounded-lg shadow-xl w-full max-w-md mx-4 p-5"
onClick={(e) => e.stopPropagation()}>
<h3 className="font-semibold mb-3 text-lg">{t('bills.markPaid', 'Mark paid')}</h3>
<div className="space-y-3">
<Input type="number" step="0.01" label={t('bills.payment.amount', 'Amount') as string} value={payAmount}
onChange={(e) => setPayAmount(e.target.value)} placeholder={String(outstanding.toFixed(2))} />
{/* Skonto checkbox (migration 126). Only surfaced when
the invoice's payment terms actually offer Skonto
the backend resolves skontoPercent from the snapshot
(with legacy + global fallback). Toggling auto-fills
the amount with total × (1 - skonto%); admin can
still override afterwards. */}
{inv.skontoPercent != null && inv.skontoPercent > 0 && (
<label className="flex items-start gap-2 text-sm py-1 cursor-pointer">
<input
type="checkbox"
className="mt-1"
checked={payWithSkonto}
onChange={(e) => {
const next = e.target.checked;
setPayWithSkonto(next);
if (next) {
const discounted = Math.round(
Number(inv.totalAmountMinor) * (1 - Number(inv.skontoPercent) / 100),
) / 100;
setPayAmount(discounted.toFixed(2));
}
}}
/>
<span>
{t('bills.payment.withSkonto',
'Paid with Skonto ({{percent}}% discount)',
{ percent: inv.skontoPercent })}
</span>
</label>
)}
{/* Payment method common methods as a dropdown; we
persist the value verbatim so admins can still record
an out-of-band method by typing into the Notes field. */}
<div>
<label htmlFor="pay-method" className="block text-sm font-medium mb-1">
{t('bills.payment.method', 'Payment method')}
</label>
<select
id="pay-method"
value={payMethod}
onChange={(e) => setPayMethod(e.target.value)}
className="w-full px-3 py-2 rounded-md border border-neutral-300 dark:border-neutral-600 bg-white dark:bg-neutral-800 text-sm focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-accent-dark"
>
<option value="">{t('bills.payment.methodPlaceholder', 'Select method…')}</option>
<option value="cash">{t('bills.payment.methods.cash', 'Cash')}</option>
<option value="card">{t('bills.payment.methods.card', 'Card')}</option>
<option value="paypal">{t('bills.payment.methods.paypal', 'PayPal')}</option>
<option value="twint">{t('bills.payment.methods.twint', 'TWINT')}</option>
</select>
</div>
<Input label={t('bills.payment.reference', 'Reference') as string} value={payReference}
onChange={(e) => setPayReference(e.target.value)} />
<div>
<label className="block text-sm font-medium mb-1">{t('bills.payment.notes', 'Notes')}</label>
<textarea rows={3} className="w-full rounded-md border border-neutral-300 dark:border-neutral-600 bg-white dark:bg-neutral-800 px-3 py-2 text-sm"
value={payNotes} onChange={(e) => setPayNotes(e.target.value)} />
</div>
<div className="flex justify-end gap-2 pt-2">
<Button variant="outline" onClick={() => setPayDialogOpen(false)}>{t('common.cancel', 'Cancel')}</Button>
<Button onClick={submitPayment} disabled={!payAmount}>{t('bills.recordPayment', 'Record payment')}</Button>
</div>
</div>
</div>
</div>
)}
</div>
);
};
@@ -0,0 +1,690 @@
/**
* Invoice editor create (manual) or edit. Most invoices come from
* quote conversion; this page is for one-off / ad-hoc invoicing.
* Smaller surface than the quote editor on purpose.
*/
import React, { useEffect, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useNavigate, useParams, useSearchParams } from 'react-router-dom';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { ArrowLeft, Eye, Save as SaveIcon } from 'lucide-react';
import { Button, Card, Loading, Input } from '../../../components/common';
import { billsService, type InvoiceCreatePayload, type InvoiceQrFormat } from '../../../services/bills.service';
import { quotesService } from '../../../services/quotes.service';
import { contractsService } from '../../../services/contracts.service';
import { businessProfileService } from '../../../services/businessProfile.service';
import { CustomerPicker } from '../../../components/admin/CustomerPicker';
import { LineItemsTable, type EditableLineItem } from '../../../components/admin/LineItemsTable';
import { InstallmentsPanel } from '../../../components/admin/InstallmentsPanel';
import { customerAdminService } from '../../../services/customerAdmin.service';
import { userManagementService } from '../../../services/userManagement.service';
import { settingsService } from '../../../services/settings.service';
import { useAdminAuth } from '../../../contexts/AdminAuthContext';
import { useLocalizedDate } from '../../../hooks/useLocalizedDate';
import { toast } from 'react-toastify';
function toMinor(amount: number) {
return Math.round((Number(amount) || 0) * 100);
}
export const BillEditorPage: React.FC = () => {
const { t } = useTranslation();
const { timeFormat } = useLocalizedDate();
const timeInputLang = timeFormat === '12h' ? 'en-US' : 'de-DE';
const { id } = useParams<{ id?: string }>();
const [searchParams] = useSearchParams();
const navigate = useNavigate();
const qc = useQueryClient();
const isEdit = id && id !== 'new';
const [customerId, setCustomerId] = useState<number | null>(null);
const [customerLabel, setCustomerLabel] = useState('');
// Tracks whether the selected customer is a passive (admin-only)
// account so the editor can render the "Passive — admin only"
// badge next to the label. Driven by the same isPassive boolean
// the backend exposes on transformInvoice.customer + transformQuote.customer.
const [customerIsPassive, setCustomerIsPassive] = useState(false);
// Customer search + inline-create state moved into <CustomerPicker> (C.5).
const [currency, setCurrency] = useState('CHF');
const [issueDate, setIssueDate] = useState(new Date().toISOString().slice(0, 10));
const [dueDate, setDueDate] = useState('');
const [scheduledSendAt, setScheduledSendAt] = useState('');
// null = inherit profile default at render time. 'none' / 'swiss' /
// 'epc' = explicit per-invoice override. (Existing invoices that
// already have a value carry it through `setQrFormat` in the load
// effect; new invoices start as null so they pick up profile.)
const [qrFormat, setQrFormat] = useState<InvoiceQrFormat | null>(null);
const [vatRate, setVatRate] = useState(0);
const [shipping, setShipping] = useState(0);
const [ccPdfEmail, setCcPdfEmail] = useState('');
const [lineItems, setLineItems] = useState<EditableLineItem[]>([]);
const [paymentTermTemplateId, setPaymentTermTemplateId] = useState<number | null>(null);
// Migration 124 — split payment-term picker. Net days + Timing are
// chosen independently; the backend composes the merged snapshot.
const [paymentNetDaysTemplateId, setPaymentNetDaysTemplateId] = useState<number | null>(null);
const [paymentTimingTemplateId, setPaymentTimingTemplateId] = useState<number | null>(null);
// null = use the business-profile default for the chosen currency.
// A number = explicit per-invoice override pointing at a specific
// business_bank_accounts row. The backend's
// resolveBankAccountForCurrency() already prefers this over the
// currency-default when set, so we just need to surface a picker.
const [businessBankAccountId, setBusinessBankAccountId] = useState<number | null>(null);
// Migration 126 — per-invoice Skonto opt-out. Default false so new
// invoices inherit whatever the template / global default offers.
const [skontoDisabled, setSkontoDisabled] = useState(false);
// Ad-hoc installments (commit #6). null = single-invoice mode.
// Array = the plan; backend spawns N invoices on save when length≥2.
const [installments, setInstallments] = useState<import('../../../services/quotes.service').PaymentTermInstallment[] | null>(null);
const [installmentsValid, setInstallmentsValid] = useState(true);
// Inline event snapshot (migration 123). Mirrors the quote editor's
// event section — admin can type a free-text label without needing
// an actual events row, and it carries through to the customer
// portal + tax report + email templates.
const [eventName, setEventName] = useState('');
const [eventDate, setEventDate] = useState('');
const [eventTimeStart, setEventTimeStart] = useState('');
const [eventTimeEnd, setEventTimeEnd] = useState('');
const [busy, setBusy] = useState(false);
// Load every configured bank account so the override dropdown can
// offer them all. We filter by currency in the dropdown itself
// (with an "any currency" fallback in case the admin wants to be
// explicit about a cross-currency bank).
const { data: bankAccountsData } = useQuery({
queryKey: ['business-bank-accounts'],
queryFn: () => businessProfileService.listBankAccounts(),
});
const bankAccounts = bankAccountsData?.bankAccounts || [];
// Migration 124 split the legacy payment-term template into Net days
// + Timing. The editor now drives entirely off the two split lists;
// the legacy `ptTemplates` query was kept around "for back-compat
// preview text" but never read. Removed (D.1 cleanup).
const { data: netDaysTemplates } = useQuery({
queryKey: ['payment-net-days-templates'],
queryFn: () => quotesService.listPaymentNetDaysTemplates(),
});
const { data: timingTemplates } = useQuery({
queryKey: ['payment-timing-templates'],
queryFn: () => quotesService.listPaymentTimingTemplates(),
});
const { data: existing, isLoading } = useQuery({
queryKey: ['invoice', id],
queryFn: () => billsService.get(parseInt(id!, 10)),
enabled: !!isEdit,
});
useEffect(() => {
if (existing) {
const inv = existing.invoice;
setCustomerId(inv.customerAccountId);
setCustomerLabel(inv.customer.companyName || inv.customer.displayName || inv.customer.email || '');
setCustomerIsPassive(Boolean(inv.customer.isPassive));
setCurrency(inv.currency);
setIssueDate(inv.issueDate);
setDueDate(inv.dueDate);
setScheduledSendAt(inv.scheduledSendAt ? inv.scheduledSendAt.slice(0, 16) : '');
// Preserve null when the saved invoice has no explicit format —
// it inherits the profile default at render time.
setQrFormat((inv.qrFormat as InvoiceQrFormat | null) || null);
setVatRate(Number(inv.vatRate || 0));
setShipping(Number(inv.shippingAmountMinor || 0) / 100);
setCcPdfEmail(inv.ccPdfEmail || '');
setPaymentTermTemplateId(inv.paymentTermTemplateId ?? null);
setPaymentNetDaysTemplateId(inv.paymentNetDaysTemplateId ?? null);
setPaymentTimingTemplateId(inv.paymentTimingTemplateId ?? null);
setBusinessBankAccountId(inv.businessBankAccountId ?? null);
setSkontoDisabled(Boolean(inv.skontoDisabled));
setEventName(inv.eventName || '');
setEventDate(inv.eventDate || '');
setEventTimeStart(inv.eventTimeStart || '');
setEventTimeEnd(inv.eventTimeEnd || '');
setLineItems(existing.lineItems.map((li) => ({
id: li.id,
position: li.position,
quantity: Number(li.quantity),
description: li.description,
unitPrice: Number(li.unitPriceMinor || 0) / 100,
discountPercent: Number(li.discountPercent || 0),
parentPosition: li.parentPosition ?? null,
detailsText: li.detailsText || '',
})));
}
}, [existing]);
// Admin auth + list — used to pre-fill + offer a dropdown for the
// "CC PDF to" field (mirrors the QuoteEditorPage pattern + the
// admin_email picker on CreateEventPage). Falls back to an empty
// list silently if the current user lacks users.view permission so
// basic admins still get the auto-prefill from currentAdmin.
const { user: currentAdmin } = useAdminAuth();
const { data: adminUsers } = useQuery({
queryKey: ['admin-users-list'],
queryFn: async () => {
try { return await userManagementService.getUsers(); } catch { return []; }
},
staleTime: 5 * 60 * 1000,
retry: false,
});
const activeAdmins = useMemo(
() => (adminUsers || []).filter((u: any) => u.isActive !== false && !!u.email),
[adminUsers]
);
// Pre-fill CC PDF email with the current admin's email on a brand-
// new invoice. Skip on edit and don't clobber existing values.
const didPrefillCcRef = useRef(false);
useEffect(() => {
if (isEdit) return;
if (didPrefillCcRef.current) return;
if (!currentAdmin?.email) return;
didPrefillCcRef.current = true;
setCcPdfEmail((cur) => cur || currentAdmin.email);
}, [currentAdmin?.email, isEdit]);
// Pre-fill the customer when the editor is opened from a customer
// detail page via `?customerAccountId=42`. Runs once on mount, only
// when creating a new invoice, and skips if the user has already
// picked a customer.
const didPrefillCustomerRef = useRef(false);
useEffect(() => {
if (isEdit) return;
if (didPrefillCustomerRef.current) return;
const raw = searchParams.get('customerAccountId');
const cid = raw ? parseInt(raw, 10) : NaN;
if (!Number.isFinite(cid) || cid <= 0) return;
didPrefillCustomerRef.current = true;
(async () => {
try {
const c = await customerAdminService.get(cid);
if (!customerId) {
setCustomerId(c.id);
setCustomerLabel(c.companyName || c.displayName || c.email);
setCustomerIsPassive(Boolean(c.isPassive));
}
} catch {
// Silent fail — admin can still pick the customer manually.
}
})();
}, [isEdit, searchParams, customerId]);
// Pre-fill from a fully-signed contract when the editor is opened
// via `?fromContractId=<id>` (the "New invoice" link on
// ContractDetailPage's header, used after the contract has been
// converted to event / invoices-only and the admin wants to mint
// an ad-hoc invoice on top of the scheduled ones).
//
// Pulls customer + event snapshot from the contract itself, then
// the line items + currency + VAT from the source quote (contracts
// don't carry line items themselves — they're composed of free-text
// blocks). Standalone contracts (no source quote) still pre-fill
// the customer + event snapshot; the admin types the line items.
const didPrefillContractRef = useRef(false);
useEffect(() => {
if (isEdit) return;
if (didPrefillContractRef.current) return;
const raw = searchParams.get('fromContractId');
const cid = raw ? parseInt(raw, 10) : NaN;
if (!Number.isFinite(cid) || cid <= 0) return;
didPrefillContractRef.current = true;
(async () => {
try {
const { contract } = await contractsService.get(cid);
if (!customerId) {
setCustomerId(contract.customerAccountId);
setCustomerLabel(
contract.customer.companyName
|| [contract.customer.firstName, contract.customer.lastName].filter(Boolean).join(' ')
|| contract.customer.displayName
|| contract.customer.email
|| '',
);
setCustomerIsPassive(Boolean((contract.customer as any).isPassive));
}
// Event snapshot — contracts copy these from the source quote
// on createFromQuote, then the contract editor can override
// them. Either way we prefer the contract's own values here.
if (contract.eventName) setEventName(contract.eventName);
if (contract.eventDate) setEventDate(contract.eventDate);
if (contract.eventTimeStart) setEventTimeStart(contract.eventTimeStart);
if (contract.eventTimeEnd) setEventTimeEnd(contract.eventTimeEnd);
if (contract.sourceQuoteId) {
const { quote, lineItems: quoteLineItems } = await quotesService.get(contract.sourceQuoteId);
if (quote.currency) setCurrency(quote.currency);
if (quote.vatRate != null) setVatRate(Number(quote.vatRate));
if (quote.shippingAmountMinor != null) {
setShipping(Number(quote.shippingAmountMinor) / 100);
}
// Map the quote's line items into the editor's editable
// shape. ids drop (this is a brand-new invoice; line items
// get fresh ids on save) but position + parent linkage are
// preserved so the hierarchy carries through.
setLineItems((quoteLineItems || []).map((li: any) => ({
position: li.position,
quantity: Number(li.quantity),
description: li.description,
unitPrice: Number(li.unitPriceMinor || 0) / 100,
discountPercent: Number(li.discountPercent || 0),
parentPosition: li.parentPosition ?? null,
detailsText: li.detailsText || '',
})));
}
} catch {
// Silent fail — admin can still author the invoice manually.
}
})();
}, [isEdit, searchParams, customerId]);
// Customer autocomplete moved into <CustomerPicker> (C.5).
// Load the admin-configured CRM defaults (migration 125). The
// editor's prefill prefers these over the hardcoded fallbacks below
// so the per-invoice picker is a true override.
const { data: appSettings } = useQuery({
queryKey: ['app-settings'],
queryFn: () => settingsService.getAllSettings(),
enabled: !isEdit,
});
// Auto-default both pickers on a brand-new invoice. Resolution
// order: configured setting → Net 30 / first-timing fallback → first.
const didPrefillPaymentRef = useRef(false);
useEffect(() => {
if (isEdit) return;
if (didPrefillPaymentRef.current) return;
if (!netDaysTemplates?.templates?.length || !timingTemplates?.templates?.length) return;
if (appSettings === undefined) return;
const settingNetDaysId = appSettings?.crm_invoices_default_payment_net_days_template_id;
const settingTimingId = appSettings?.crm_invoices_default_payment_timing_template_id;
const settingNetDays = settingNetDaysId
? netDaysTemplates.templates.find((t) => t.id === settingNetDaysId)
: null;
const settingTiming = settingTimingId
? timingTemplates.templates.find((t) => t.id === settingTimingId)
: null;
const defaultNetDays = settingNetDays
|| netDaysTemplates.templates.find((t) => t.netDays === 30)
|| netDaysTemplates.templates[0];
const defaultTiming = settingTiming
|| timingTemplates.templates[0];
didPrefillPaymentRef.current = true;
setPaymentNetDaysTemplateId((prev) => prev ?? defaultNetDays.id);
setPaymentTimingTemplateId((prev) => prev ?? defaultTiming.id);
}, [isEdit, netDaysTemplates, timingTemplates, appSettings]);
const buildPayload = (): InvoiceCreatePayload => ({
customerAccountId: customerId || 0,
currency,
issueDate,
dueDate: dueDate || undefined,
scheduledSendAt: scheduledSendAt || undefined,
// Omit when null so the server inherits the profile default;
// including null would persist as an explicit "no preference"
// which is the same outcome but pollutes the column.
qrFormat: qrFormat || undefined,
vatRate,
shippingAmountMinor: toMinor(shipping),
ccPdfEmail: ccPdfEmail || undefined,
// Payment-term template id (migration 113). null = no template
// selected; backend falls back to source-quote snapshot or the
// global crm_invoices_* defaults.
paymentTermTemplateId: paymentTermTemplateId ?? undefined,
// Migration 124 — split picker. Backend engages the new path only
// when both are present; legacy single-FK is still accepted.
paymentNetDaysTemplateId: paymentNetDaysTemplateId ?? undefined,
paymentTimingTemplateId: paymentTimingTemplateId ?? undefined,
// Per-invoice bank-account override. Sending `null` explicitly
// (instead of stripping the key with `?? undefined`) ensures the
// PUT handler can detect "clear this override" — JSON serialises
// undefined out of the body, so an omitted key would otherwise
// leave the previously-pinned bank in place. A number pins the
// chosen account; the renderer reads it from
// invoices.business_bank_account_id at PDF time, no further
// lookup needed.
businessBankAccountId: businessBankAccountId,
// Per-invoice Skonto opt-out (migration 126). Always send so the
// PUT handler can clear a previously-set opt-out by unchecking.
skontoDisabled,
// Ad-hoc installments (commit #6). When set with ≥2 rows backend
// spawns N invoices via spawnInstallmentInvoices and returns
// { invoiceIds: [...] }; single-row or null → single invoice.
installments: installments || undefined,
// Inline event snapshot (migration 123). Empty string → undefined
// so the backend can distinguish "not provided" from a deliberate
// clear (which the route's `optional({ values: 'falsy' })` already
// treats identically — falsy values bypass validation entirely).
eventName: eventName || undefined,
eventDate: eventDate || undefined,
eventTimeStart: eventTimeStart || undefined,
eventTimeEnd: eventTimeEnd || undefined,
lineItems: lineItems.map((li) => ({
position: li.position,
quantity: li.quantity,
description: li.description,
unitPriceMinor: toMinor(li.unitPrice),
discountPercent: li.discountPercent,
// Migration 119 — sub-items + details survive save → reload.
parentPosition: li.parentPosition ?? null,
detailsText: li.detailsText || null,
})),
});
const handleSave = async (then?: 'preview') => {
if (!customerId) { toast.error(t('bills.errors.customerRequired', 'Pick a customer first.')); return; }
// Open the preview tab synchronously so popup blockers don't kill
// it (they reject any window.open that runs after an `await`).
const previewWindow = then === 'preview' ? window.open('about:blank', '_blank') : null;
if (then === 'preview' && !previewWindow) {
toast.error(t('bills.errors.popupBlocked', 'Allow pop-ups for this site to preview the PDF.'));
return;
}
setBusy(true);
try {
const payload = buildPayload();
const saved = isEdit
? await billsService.update(parseInt(id!, 10), payload)
: await billsService.create(payload);
qc.invalidateQueries({ queryKey: ['invoices'] });
if (then === 'preview') {
const url = await billsService.pdfUrl(saved.invoice.id);
if (previewWindow) previewWindow.location.href = url;
} else if (saved.invoiceIds && saved.invoiceIds.length > 1) {
// Multi-installment spawn: backend created N invoices. Toast
// the count and redirect to the first sibling.
toast.success(t('bills.savedToastMulti',
'Created {{n}} invoices.', { n: saved.invoiceIds.length }));
} else {
toast.success(t('bills.savedToast', 'Invoice saved.'));
}
// Redirect to the first invoice (single case = the only invoice;
// multi case = first installment, admin navigates to siblings
// via the lineage card).
navigate(`/admin/clients/bills/${saved.invoice.id}`);
} catch (err: any) {
if (previewWindow) previewWindow.close();
toast.error(err?.response?.data?.error || err.message || 'Save failed');
} finally { setBusy(false); }
};
const handlePreviewUnsaved = async () => {
if (!customerId) { toast.error(t('bills.errors.customerRequired', 'Pick a customer first.')); return; }
const previewWindow = window.open('about:blank', '_blank');
if (!previewWindow) {
toast.error(t('bills.errors.popupBlocked', 'Allow pop-ups for this site to preview the PDF.'));
return;
}
try {
const url = await billsService.previewPdfUrl(buildPayload());
previewWindow.location.href = url;
} catch (err: any) {
previewWindow.close();
toast.error(err?.response?.data?.error || 'Preview failed');
}
};
if (isEdit && isLoading) return <Loading />;
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<div>
<button onClick={() => navigate('/admin/clients/bills')}
className="text-sm text-neutral-600 dark:text-neutral-400 hover:underline mb-1 inline-flex items-center gap-1">
<ArrowLeft className="w-4 h-4" /> {t('common.back', 'Back')}
</button>
<h2 className="text-xl font-bold">{isEdit ? `${t('bills.edit', 'Edit invoice')} ${existing?.invoice.invoiceNumber || ''}` : t('bills.new', 'New invoice')}</h2>
</div>
<div className="flex gap-2">
<Button variant="outline" onClick={handlePreviewUnsaved} disabled={busy}>
<Eye className="w-4 h-4 mr-1" />{t('common.preview', 'Preview')}
</Button>
<Button onClick={() => handleSave()} disabled={busy || !installmentsValid}>
<SaveIcon className="w-4 h-4 mr-1" />
{installments && installments.length > 1
? t('bills.saveAndSpawn', 'Create {{n}} invoices', { n: installments.length })
: t('common.save', 'Save')}
</Button>
</div>
</div>
<Card>
<h3 className="font-semibold mb-2">{t('bills.section.customer', 'Customer')}</h3>
<CustomerPicker
value={customerId}
label={customerLabel}
isPassive={customerIsPassive}
onSelect={(c) => {
setCustomerId(c.id);
setCustomerLabel(c.companyName || c.displayName || c.email);
setCustomerIsPassive(Boolean(c.isPassive));
}}
onCreate={(c) => {
setCustomerId(c.id);
setCustomerLabel(c.companyName || c.displayName || c.email);
setCustomerIsPassive(Boolean(c.isPassive));
}}
onClear={() => {
setCustomerId(null);
setCustomerLabel('');
setCustomerIsPassive(false);
}}
searchPlaceholder={t('bills.customerSearch', 'Search by email or company…') as string}
/>
</Card>
{/* Event snapshot section (migration 123). Mirrors the quote
editor's Event section — free-text label that doesn't require
a linked events row. The values flow into the invoice email
template's {{event_name}} placeholder, the customer portal
row heading, the admin list "Event" column, and the tax
report event column. */}
<Card>
<h3 className="font-semibold mb-2">{t('bills.section.event', 'Event details')}</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
<Input label={t('bills.field.eventName', 'Event') as string}
value={eventName} onChange={(e) => setEventName(e.target.value)} />
<Input type="date" label={t('bills.field.eventDate', 'Event date') as string}
value={eventDate} onChange={(e) => setEventDate(e.target.value)} />
<Input type="time" lang={timeInputLang} label={t('bills.field.eventTimeStart', 'Start time') as string}
value={eventTimeStart} onChange={(e) => setEventTimeStart(e.target.value)} />
<Input type="time" lang={timeInputLang} label={t('bills.field.eventTimeEnd', 'End time') as string}
value={eventTimeEnd} onChange={(e) => setEventTimeEnd(e.target.value)} />
</div>
</Card>
<Card>
<h3 className="font-semibold mb-2">{t('bills.section.details', 'Details')}</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
<Input type="date" label={t('bills.field.issueDate', 'Issue date') as string} value={issueDate} onChange={(e) => setIssueDate(e.target.value)} />
<Input type="date" label={t('bills.field.dueDate', 'Due date') as string} value={dueDate} onChange={(e) => setDueDate(e.target.value)} />
<Input type="datetime-local" label={t('bills.field.scheduledSendAt', 'Scheduled send (optional)') as string}
value={scheduledSendAt} onChange={(e) => setScheduledSendAt(e.target.value)} />
<div>
<label className="block text-sm font-medium mb-1">{t('bills.field.qrFormat', 'Payment QR format')}</label>
<select
value={qrFormat || ''}
onChange={(e) => setQrFormat((e.target.value || null) as any)}
className="w-full px-3 py-2 rounded-md border border-neutral-300 dark:border-neutral-600 bg-white dark:bg-neutral-800 text-sm">
{/* Empty value = use the business-profile default. Server
resolves the actual format at render time, so admins
who curate it once in Settings Business profile
never have to think about it per-invoice. */}
<option value="">{t('bills.qrFormat.profileDefault', 'Use business profile default')}</option>
<option value="none">{t('bills.qrFormat.none', 'None (override)')}</option>
<option value="swiss">{t('bills.qrFormat.swiss', 'Swiss QR-bill')}</option>
<option value="epc">{t('bills.qrFormat.epc', 'EPC QR (SEPA)')}</option>
</select>
</div>
{/* Per-invoice bank-account override (migration 102 column
business_bank_account_id). Empty value = let the server
pick the default account for the invoice currency at
save time. Showing every configured bank lets the admin
pin one for this invoice without changing the global
defaults in Settings Business profile. */}
<div className="md:col-span-2">
<label className="block text-sm font-medium mb-1">
{t('bills.field.businessBankAccount', 'Payment account')}
</label>
<select
value={businessBankAccountId == null ? '' : String(businessBankAccountId)}
onChange={(e) => setBusinessBankAccountId(e.target.value ? Number(e.target.value) : null)}
className="w-full px-3 py-2 rounded-md border border-neutral-300 dark:border-neutral-600 bg-white dark:bg-neutral-800 text-sm">
<option value="">
{t('bills.field.bankAccountProfileDefault',
'Use business profile default for {{currency}}', { currency })}
</option>
{bankAccounts.map((b) => {
const labelParts = [
b.label || b.accountHolder || b.iban,
b.currency,
b.isDefault ? t('bills.field.bankAccountDefaultBadge', '(default)') : null,
].filter(Boolean);
return (
<option key={b.id} value={b.id}>
{labelParts.join(' · ')} {b.iban}
</option>
);
})}
</select>
<p className="text-xs text-neutral-500 mt-1">
{t('bills.field.bankAccountHelp',
'Overrides the profile default for this invoice only. Leave on default to inherit the currency-matched account from Settings → Business profile.')}
</p>
</div>
</div>
</Card>
<Card>
<h3 className="font-semibold mb-2">{t('bills.section.lineItems', 'Line items')}</h3>
<LineItemsTable items={lineItems} currency={currency} showDiscount={false}
vatRate={vatRate / 100} shippingAmount={shipping} onChange={setLineItems} />
<div className="grid grid-cols-1 md:grid-cols-3 gap-3 mt-4">
<Input type="number" step="0.1" label={t('bills.field.vatRate', 'VAT rate %') as string}
value={vatRate} onChange={(e) => setVatRate(Number(e.target.value))} />
<Input type="number" step="0.01" label={t('bills.field.shipping', 'Shipping') as string}
value={shipping} onChange={(e) => setShipping(Number(e.target.value))} />
<div>
<label className="block text-sm font-medium mb-1">{t('bills.field.currency', 'Currency')}</label>
<select value={currency} onChange={(e) => setCurrency(e.target.value)}
className="w-full px-3 py-2 rounded-md border border-neutral-300 dark:border-neutral-600 bg-white dark:bg-neutral-800 text-sm">
<option>CHF</option><option>EUR</option><option>USD</option><option>GBP</option>
</select>
</div>
</div>
</Card>
{/* Payment conditions picks net-days + Skonto from the shared
payment-term templates (same dropdown the quote editor uses).
Optional: leave at "— Select —" to let the renderer fall back
to the source quote's snapshot, or to the global
crm_invoices_* defaults for ad-hoc invoices. */}
{/* Migration 124 split payment-term picker. Two side-by-side
dropdowns replace the legacy single one. The `ptTemplates`
query is still mounted above so legacy invoices whose state
only has the single FK still resolve their preview text. */}
<Card>
<h3 className="font-semibold mb-2">{t('bills.section.payment', 'Payment conditions')}</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
<div>
<label className="block text-sm font-medium mb-1">{t('bills.field.paymentNetDays', 'Net days')}</label>
<select
className="w-full px-3 py-2 rounded-md border border-neutral-300 dark:border-neutral-600 bg-white dark:bg-neutral-800 text-sm"
value={paymentNetDaysTemplateId || ''}
onChange={(e) => setPaymentNetDaysTemplateId(e.target.value ? Number(e.target.value) : null)}
>
<option value="">{t('bills.field.selectNetDays', '— Select net days —')}</option>
{netDaysTemplates?.templates.map((tpl) => (
<option key={tpl.id} value={tpl.id}>{tpl.name}</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-medium mb-1">{t('bills.field.paymentTiming', 'Payment schedule')}</label>
<select
className="w-full px-3 py-2 rounded-md border border-neutral-300 dark:border-neutral-600 bg-white dark:bg-neutral-800 text-sm"
value={paymentTimingTemplateId || ''}
onChange={(e) => setPaymentTimingTemplateId(e.target.value ? Number(e.target.value) : null)}
>
<option value="">{t('bills.field.selectTiming', '— Select schedule —')}</option>
{timingTemplates?.templates.map((tpl) => (
<option key={tpl.id} value={tpl.id}>{tpl.name}</option>
))}
</select>
</div>
</div>
{/* Per-invoice Skonto opt-out (migration 126). Surfaced
alongside the payment-term pickers because it's a peer
override of the same CRM defaults. Admin ticks this for
invoices that shouldn't qualify even when the global
default offers Skonto (e.g. Storni, instalments, retainers). */}
<label className="flex items-start gap-2 text-sm mt-3 cursor-pointer">
<input
type="checkbox"
className="mt-1"
checked={skontoDisabled}
onChange={(e) => setSkontoDisabled(e.target.checked)}
/>
<span>
{t('bills.field.skontoDisabled',
'Disable Skonto for this invoice (suppresses the early-payment-discount block on the PDF and the "Paid with Skonto" buttons in the admin email / record-payment dialog).')}
</span>
</label>
<p className="text-xs text-neutral-500 mt-2">
{t('bills.field.paymentTermHelp',
'Net days + Skonto for this invoice. Leave blank to inherit from the source quote or the global CRM defaults.')}
</p>
{/* Ad-hoc installments panel (commit #6). When the admin builds
a multi-row plan and clicks Save, the backend spawns one
invoice per row via spawnInstallmentInvoices (commit #4)
and returns the array of new ids. */}
<div className="mt-4 pt-4 border-t border-neutral-200 dark:border-neutral-700">
<InstallmentsPanel
value={installments}
onChange={setInstallments}
onValidityChange={setInstallmentsValid}
eventDate={eventDate || null}
/>
</div>
</Card>
<Card>
{/* CC PDF admin email prefilled, with a picker when more than
one admin exists. Mirrors the quote editor + CreateEventPage
so the muscle memory carries over. */}
<div className="space-y-1">
<Input type="email"
label={t('bills.field.ccPdfEmail', 'CC PDF to (extra recipient)') as string}
placeholder={t('bills.field.ccPdfEmailPlaceholder', '[email protected]') as string}
value={ccPdfEmail} onChange={(e) => setCcPdfEmail(e.target.value)} />
{activeAdmins.length > 1 && (
<div className="flex items-center gap-2">
<label htmlFor="bill-cc-pdf-picker" className="text-xs text-neutral-600 dark:text-neutral-400 whitespace-nowrap">
{t('bills.field.ccPdfPickFromAdmins', 'Pick from admins:')}
</label>
<select
id="bill-cc-pdf-picker"
value={activeAdmins.some((a: any) => a.email === ccPdfEmail) ? ccPdfEmail : ''}
onChange={(e) => {
const email = e.target.value;
if (email) setCcPdfEmail(email);
}}
className="text-xs px-2 py-1 border border-neutral-300 dark:border-neutral-600 bg-white dark:bg-neutral-800 text-neutral-900 dark:text-neutral-100 rounded focus:ring-2 focus:ring-primary-500 focus:border-accent-dark"
>
<option value="">{t('bills.field.ccPdfCustom', 'Custom email')}</option>
{activeAdmins.map((a: any) => (
<option key={a.id} value={a.email}>{a.email}</option>
))}
</select>
</div>
)}
</div>
</Card>
</div>
);
};
@@ -0,0 +1,504 @@
/**
* Bills (invoices) list. Mirrors QuotesListPage shape; adds an
* "unpaid only" toggle and sorts including by due-date.
*/
import React, { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Link, useNavigate } from 'react-router-dom';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { Plus, Search, Upload, X } from 'lucide-react';
import { billsService, type InvoiceStatus, type InvoiceSort } from '../../../services/bills.service';
import { Button, Card, Input, Loading } from '../../../components/common';
import { formatMoney } from '../../../components/admin/LineItemsTable';
import { customerAdminService } from '../../../services/customerAdmin.service';
import { useLocalizedDate } from '../../../hooks/useLocalizedDate';
import { toast } from 'react-toastify';
const STATUSES: InvoiceStatus[] = ['scheduled', 'pending_delivery', 'sent', 'paid', 'overdue', 'cancelled', 'skipped'];
export const BillsListPage: React.FC = () => {
const { t } = useTranslation();
const navigate = useNavigate();
const { format: fmtDate } = useLocalizedDate();
const [search, setSearch] = useState('');
const [statusFilter, setStatusFilter] = useState<InvoiceStatus[]>([]);
const [unpaidOnly, setUnpaidOnly] = useState(false);
const [sort, setSort] = useState<InvoiceSort>('newest');
const [page, setPage] = useState(1);
const [importOpen, setImportOpen] = useState(false);
const { data, isLoading } = useQuery({
queryKey: ['invoices', { search, statusFilter, unpaidOnly, sort, page }],
queryFn: () => billsService.list({
q: search || undefined,
status: statusFilter.length ? statusFilter : undefined,
unpaidOnly,
sort, page, pageSize: 25,
}),
});
const toggleStatus = (s: InvoiceStatus) => {
setStatusFilter((prev) => prev.includes(s) ? prev.filter((x) => x !== s) : [...prev, s]);
setPage(1);
};
return (
<div className="container py-6">
<div className="flex items-center justify-between mb-6">
<div>
<div className="flex items-center gap-2">
<h1 className="text-2xl font-bold text-theme">{t('bills.title', 'Invoices')}</h1>
{/* Beta badge — matches the Customers + Quotes pages. */}
<span
className="text-[10px] uppercase tracking-wider px-1.5 py-0.5 rounded font-semibold bg-amber-100 text-amber-800 dark:bg-amber-900/40 dark:text-amber-300"
title="Beta — feature is functional but still evolving"
>
{t('navigation.betaTag', 'Beta')}
</span>
</div>
<p className="text-sm text-muted-theme mt-1">
{t('bills.subtitle', 'Schedule, send, track payments and chase late invoices.')}
</p>
</div>
<div className="flex gap-2">
<Button variant="outline" onClick={() => setImportOpen(true)}>
<Upload className="w-4 h-4 mr-1" />
{t('bills.import', 'Import historical')}
</Button>
<Link to="/admin/clients/bills/new">
<Button><Plus className="w-4 h-4 mr-1" />{t('bills.new', 'New invoice')}</Button>
</Link>
</div>
</div>
{importOpen && (
<ImportHistoricalInvoiceModal onClose={() => setImportOpen(false)} />
)}
<Card padding="lg">
<div className="flex flex-wrap items-center gap-3">
<div className="relative flex-1 min-w-[200px]">
<Search className="w-4 h-4 absolute left-3 top-1/2 -translate-y-1/2 text-neutral-400" />
<input
type="text"
placeholder={t('bills.searchPlaceholder', 'Search by number or customer…') as string}
className="w-full pl-9 pr-3 py-2 rounded-md border border-neutral-300 dark:border-neutral-600 bg-white dark:bg-neutral-800 text-sm"
value={search}
onChange={(e) => { setSearch(e.target.value); setPage(1); }}
/>
</div>
<select
className="px-3 py-2 rounded-md border border-neutral-300 dark:border-neutral-600 bg-white dark:bg-neutral-800 text-sm"
value={sort}
onChange={(e) => setSort(e.target.value as InvoiceSort)}
>
<option value="newest">{t('bills.sort.newest', 'Newest first')}</option>
<option value="due_asc">{t('bills.sort.dueAsc', 'Due soon first')}</option>
<option value="due_desc">{t('bills.sort.dueDesc', 'Due latest first')}</option>
<option value="customer_asc">{t('bills.sort.customerAsc', 'Customer A→Z')}</option>
<option value="value_asc">{t('bills.sort.valueAsc', 'Value low→high')}</option>
<option value="value_desc">{t('bills.sort.valueDesc', 'Value high→low')}</option>
</select>
<label className="flex items-center gap-2 text-sm">
<input type="checkbox" checked={unpaidOnly} onChange={(e) => setUnpaidOnly(e.target.checked)} />
{t('bills.filter.unpaidOnly', 'Unpaid only')}
</label>
</div>
<div className="mt-3 flex flex-wrap gap-1">
{STATUSES.map((s) => {
const active = statusFilter.includes(s);
return (
<button key={s} type="button" onClick={() => toggleStatus(s)}
className={`px-2.5 py-1 rounded-full text-xs font-medium border transition-colors ${
active ? 'bg-accent-dark text-white border-accent-dark' : 'bg-white dark:bg-neutral-800 text-neutral-700 dark:text-neutral-300 border-neutral-300 dark:border-neutral-600'
}`}
>{t(`bills.status.${s}`, s)}</button>
);
})}
</div>
{/* Body inside the same card (matches Customers + Quotes). */}
<div className="mt-4">
{isLoading ? <Loading /> : !data || data.invoices.length === 0 ? (
<p className="text-center text-muted-theme py-8">{t('bills.empty', 'No invoices yet.')}</p>
) : (
<div className="rounded-lg border border-neutral-200 dark:border-neutral-700 overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead className="bg-neutral-50 dark:bg-neutral-800 text-neutral-700 dark:text-neutral-300">
<tr>
<th className="px-3 py-2 text-left">#</th>
<th className="px-3 py-2 text-left">{t('bills.table.customer', 'Customer')}</th>
<th className="px-3 py-2 text-left">{t('bills.table.event', 'Event')}</th>
<th className="px-3 py-2 text-left">{t('bills.table.installment', 'Installment')}</th>
<th className="px-3 py-2 text-left">{t('bills.table.dueDate', 'Due')}</th>
<th className="px-3 py-2 text-right">{t('bills.table.total', 'Total')}</th>
<th className="px-3 py-2 text-left">{t('bills.table.status', 'Status')}</th>
</tr>
</thead>
<tbody>
{data.invoices.map((inv) => (
<tr key={inv.id}
className="border-t border-neutral-200 dark:border-neutral-700 cursor-pointer hover:bg-neutral-50 dark:hover:bg-neutral-800"
onClick={() => navigate(`/admin/clients/bills/${inv.id}`)}
>
<td className="px-3 py-2 font-mono text-xs">
{inv.invoiceNumber}
{/* Storno discriminator badge list rows
don't have a kind column today, so
tucking it inline with the number keeps
the table layout stable while making
cancellation documents instantly
recognisable. */}
{inv.kind === 'storno' && (
<span className="ml-2 text-[10px] font-semibold px-1.5 py-0.5 rounded bg-purple-100 text-purple-800 uppercase tracking-wide">
{t('bills.kind.storno', 'Storno')}
</span>
)}
{/* Reissue marker kind='invoice' rows with
replacesInvoiceId set were created from a
Cancel & reissue flow. Distinct colour
from Storno (blue vs purple) so the two
kinds are visually unambiguous. */}
{inv.kind !== 'storno' && inv.replacesInvoiceId && (
<span className="ml-2 text-[10px] font-semibold px-1.5 py-0.5 rounded bg-blue-100 text-blue-800 uppercase tracking-wide">
{t('bills.kind.reissue', 'Reissue')}
</span>
)}
</td>
<td className="px-3 py-2">{inv.customer.companyName || inv.customer.displayName || inv.customer.email}</td>
<td className="px-3 py-2 truncate max-w-xs">{inv.eventName || '—'}</td>
<td className="px-3 py-2 text-xs text-muted-theme">
{inv.installmentTotal > 1 ? `${inv.installmentIndex + 1}/${inv.installmentTotal} · ${inv.installmentLabel || ''}` : '—'}
</td>
<td className="px-3 py-2">{fmtDate(inv.dueDate)}</td>
<td className="px-3 py-2 text-right tabular-nums">
{formatMoney(Number(inv.totalAmountMinor) / 100, inv.currency)}
</td>
<td className="px-3 py-2">
<span className={`px-2 py-0.5 rounded text-xs font-medium ${
inv.status === 'paid' ? 'bg-green-100 text-green-800'
: inv.status === 'overdue' ? 'bg-red-100 text-red-800'
: inv.status === 'sent' ? 'bg-blue-100 text-blue-800'
: inv.status === 'cancelled' ? 'bg-neutral-200 text-neutral-600'
: inv.status === 'skipped' ? 'bg-neutral-100 text-neutral-500 italic'
: 'bg-amber-100 text-amber-800'
}`}>{t(`bills.status.${inv.status}`, inv.status)}</span>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
</div>
</Card>
</div>
);
};
/**
* Modal for attaching a historical invoice PDF to a customer's
* account. Stores the original document on disk; the customer sees
* the same bytes via their portal's "View PDF" button. Useful when
* migrating from a previous billing system invoice number, date,
* and total are entered by hand exactly as they appeared in the
* source system (sequential numbering is the admin's responsibility
* for this path; the renderer's auto-numbering doesn't apply).
*/
interface ImportModalProps { onClose: () => void }
const ImportHistoricalInvoiceModal: React.FC<ImportModalProps> = ({ onClose }) => {
const { t } = useTranslation();
const qc = useQueryClient();
const [customerSearch, setCustomerSearch] = useState('');
const [customerId, setCustomerId] = useState<number | null>(null);
const [customerLabel, setCustomerLabel] = useState('');
const [invoiceNumber, setInvoiceNumber] = useState('');
const [issueDate, setIssueDate] = useState(new Date().toISOString().slice(0, 10));
const [dueDate, setDueDate] = useState('');
const [totalMajor, setTotalMajor] = useState('');
const [currency, setCurrency] = useState('CHF');
const [status, setStatus] = useState<'sent' | 'paid' | 'overdue'>('paid');
const [paidMajor, setPaidMajor] = useState('');
const [file, setFile] = useState<File | null>(null);
const [submitting, setSubmitting] = useState(false);
const { data: customerOptions } = useQuery({
queryKey: ['customer-search', customerSearch],
queryFn: () => customerAdminService.search(customerSearch),
enabled: customerSearch.length >= 2,
});
const canSubmit = customerId && invoiceNumber && issueDate && totalMajor && file && !submitting;
const onSubmit = async () => {
if (!canSubmit || !customerId || !file) return;
setSubmitting(true);
try {
await billsService.importHistorical({
customerAccountId: customerId,
invoiceNumber,
issueDate,
dueDate: dueDate || undefined,
totalAmountMinor: Math.round(Number(totalMajor) * 100),
currency: currency || undefined,
status,
paidAmountMinor: status === 'paid' && paidMajor
? Math.round(Number(paidMajor) * 100)
: undefined,
file,
});
toast.success(t('bills.importedToast', 'Invoice imported.'));
qc.invalidateQueries({ queryKey: ['invoices'] });
onClose();
} catch (err: any) {
toast.error(err?.response?.data?.error || t('bills.importError', 'Import failed'));
} finally {
setSubmitting(false);
}
};
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50"
onClick={onClose}>
<div className="bg-white dark:bg-neutral-900 rounded-lg shadow-xl max-w-2xl w-full max-h-[90vh] overflow-y-auto"
onClick={(e) => e.stopPropagation()}>
<div className="flex items-center justify-between px-5 py-4 border-b border-neutral-200 dark:border-neutral-700">
<h2 className="text-lg font-semibold">
{t('bills.importTitle', 'Import historical invoice')}
</h2>
<button type="button" onClick={onClose} className="p-1 hover:bg-neutral-100 dark:hover:bg-neutral-800 rounded">
<X className="w-4 h-4" />
</button>
</div>
<div className="p-5 space-y-4">
<p className="text-xs text-neutral-500">
{t('bills.importHelp',
'Attach a PDF from a previous billing system. The customer sees this original document in their portal — picpeak does not regenerate it.')}
</p>
{/* Customer picker */}
<div>
<label className="block text-sm font-medium mb-1">
{t('bills.field.customer', 'Customer')}
</label>
{customerId ? (
<div className="flex items-center gap-2 text-sm">
<span className="px-2 py-1 rounded bg-neutral-100 dark:bg-neutral-800">{customerLabel}</span>
<button type="button" onClick={() => { setCustomerId(null); setCustomerLabel(''); }}
className="text-xs text-neutral-500 hover:underline">
{t('common.change', 'Change')}
</button>
</div>
) : (
<>
<Input placeholder={t('bills.customerSearch', 'Search by email or company…') as string}
value={customerSearch}
onChange={(e) => setCustomerSearch(e.target.value)} />
{customerSearch.length >= 2 && customerOptions && (
<ul className="mt-1 max-h-40 overflow-y-auto border border-neutral-200 dark:border-neutral-700 rounded">
{(customerOptions as any[]).map((c) => (
<li key={c.id}>
<button type="button"
onClick={() => {
setCustomerId(c.id);
setCustomerLabel(c.companyName || c.displayName || c.email);
}}
className="w-full text-left px-3 py-1.5 text-sm hover:bg-neutral-100 dark:hover:bg-neutral-800">
{c.companyName || c.displayName || c.email}
<span className="text-xs text-neutral-500 ml-2">{c.email}</span>
</button>
</li>
))}
{(customerOptions as any[]).length === 0 && (
<li className="px-3 py-2 text-xs text-neutral-500">{t('bills.noMatch', 'No matches')}</li>
)}
</ul>
)}
</>
)}
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
<Input label={t('bills.field.invoiceNumber', 'Invoice number') as string}
value={invoiceNumber}
placeholder="R-2024-0001"
onChange={(e) => setInvoiceNumber(e.target.value)} />
<Input label={t('bills.field.currency', 'Currency') as string}
value={currency}
maxLength={3}
onChange={(e) => setCurrency(e.target.value.toUpperCase())} />
<LocalizedDateField
label={t('bills.field.issueDate', 'Issued') as string}
value={issueDate}
onChange={setIssueDate}
/>
<LocalizedDateField
label={t('bills.field.dueDate', 'Due') as string}
value={dueDate}
onChange={setDueDate}
/>
<Input type="number" step="0.01" min="0"
label={t('bills.field.total', 'Total') as string}
value={totalMajor}
placeholder="543.00"
onChange={(e) => setTotalMajor(e.target.value)} />
<div>
<label className="block text-sm font-medium mb-1">{t('bills.field.status', 'Status')}</label>
<select value={status}
onChange={(e) => setStatus(e.target.value as any)}
className="w-full px-3 py-2 rounded-md border border-neutral-300 dark:border-neutral-600 bg-white dark:bg-neutral-800 text-sm">
<option value="paid">{t('bills.status.paid', 'Paid')}</option>
<option value="sent">{t('bills.status.sent', 'Sent')}</option>
<option value="overdue">{t('bills.status.overdue', 'Overdue')}</option>
</select>
</div>
{status === 'paid' && (
<Input type="number" step="0.01" min="0"
label={t('bills.field.paidAmount', 'Paid amount (optional)') as string}
value={paidMajor}
placeholder={totalMajor || '0.00'}
onChange={(e) => setPaidMajor(e.target.value)} />
)}
</div>
{/* PDF picker */}
<div>
<label className="block text-sm font-medium mb-1">
{t('bills.field.pdfFile', 'PDF file')}
</label>
<input
type="file"
accept="application/pdf"
onChange={(e) => setFile(e.target.files?.[0] || null)}
className="text-sm file:mr-3 file:rounded-md file:border-0 file:bg-primary-600 file:px-3 file:py-1.5 file:text-sm file:font-medium file:text-white hover:file:bg-primary-700"
/>
{file && (
<p className="text-xs text-neutral-500 mt-1">{file.name} · {(file.size / 1024).toFixed(1)} KB</p>
)}
</div>
</div>
<div className="flex justify-end gap-2 px-5 py-4 border-t border-neutral-200 dark:border-neutral-700">
<Button variant="outline" onClick={onClose} disabled={submitting}>
{t('common.cancel', 'Cancel')}
</Button>
<Button onClick={onSubmit} disabled={!canSubmit}>
<Upload className="w-4 h-4 mr-1" />
{submitting ? t('bills.importing', 'Importing…') : t('bills.import', 'Import')}
</Button>
</div>
</div>
</div>
);
};
/**
* Date field that displays + accepts values in the admin-configured
* format from Settings General (`general_date_format`). Stores
* + emits ISO (YYYY-MM-DD) so the rest of the form / API surface
* keeps the canonical shape.
*
* Native `<input type="date">` always renders in the browser's
* locale (en-US users see MM/DD/YYYY), which mismatched what
* customers + the rest of the app see elsewhere. This component
* uses a plain text input + parses on blur, with the configured
* format shown as both placeholder and helper text. A small
* shadow native date input next to the field gives the click-to-
* open calendar without affecting the displayed format.
*/
interface LocalizedDateFieldProps {
label: string;
value: string;
onChange: (iso: string) => void;
}
const LocalizedDateField: React.FC<LocalizedDateFieldProps> = ({ label, value, onChange }) => {
const { dateFormat } = useLocalizedDate();
// Normalise the configured format down to the four shapes our
// parser understands. Defaults to DD.MM.YYYY (the maintainer's
// primary locale) when unknown.
const normalisedFormat = ((): 'DD.MM.YYYY' | 'DD/MM/YYYY' | 'MM/DD/YYYY' | 'YYYY-MM-DD' => {
const f = String(dateFormat || 'dd.MM.yyyy').toLowerCase();
if (f.startsWith('mm/dd')) return 'MM/DD/YYYY';
if (f.startsWith('yyyy')) return 'YYYY-MM-DD';
if (f.includes('/')) return 'DD/MM/YYYY';
return 'DD.MM.YYYY';
})();
const placeholder = normalisedFormat.toLowerCase();
// ISO → display
const toDisplay = (iso: string): string => {
if (!iso) return '';
const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(iso);
if (!m) return iso;
const [, y, mo, d] = m;
switch (normalisedFormat) {
case 'MM/DD/YYYY': return `${mo}/${d}/${y}`;
case 'YYYY-MM-DD': return `${y}-${mo}-${d}`;
case 'DD/MM/YYYY': return `${d}/${mo}/${y}`;
case 'DD.MM.YYYY':
default: return `${d}.${mo}.${y}`;
}
};
// display → ISO (accepts variant separators leniently)
const toIso = (raw: string): string => {
const s = raw.trim();
if (!s) return '';
// Already ISO?
if (/^\d{4}-\d{2}-\d{2}$/.test(s)) return s;
// Split on . / -
const parts = s.split(/[./-]/);
if (parts.length !== 3) return '';
let [a, b, c] = parts;
let y: string, mo: string, d: string;
if (normalisedFormat === 'YYYY-MM-DD' || a.length === 4) {
[y, mo, d] = [a, b, c];
} else if (normalisedFormat === 'MM/DD/YYYY') {
[mo, d, y] = [a, b, c];
} else {
// DD.MM.YYYY or DD/MM/YYYY
[d, mo, y] = [a, b, c];
}
if (!/^\d{1,2}$/.test(d) || !/^\d{1,2}$/.test(mo) || !/^\d{4}$/.test(y)) return '';
return `${y}-${mo.padStart(2, '0')}-${d.padStart(2, '0')}`;
};
const [text, setText] = React.useState(toDisplay(value));
React.useEffect(() => { setText(toDisplay(value)); /* eslint-disable-next-line react-hooks/exhaustive-deps */ }, [value]);
return (
<div>
<label className="block text-sm font-medium mb-1">{label}</label>
<div className="flex gap-2">
<Input
value={text}
placeholder={placeholder}
onChange={(e) => setText(e.target.value)}
onBlur={() => {
const iso = toIso(text);
if (iso) {
onChange(iso);
setText(toDisplay(iso));
} else if (!text.trim()) {
onChange('');
}
}}
/>
{/* Tiny native date picker shortcut gives the calendar
without polluting the visible text input. Hidden value
stays in ISO so it's always parseable. */}
<input
type="date"
value={value || ''}
onChange={(e) => onChange(e.target.value)}
aria-label={label}
className="text-sm px-2 rounded-md border border-neutral-300 dark:border-neutral-600 bg-white dark:bg-neutral-800"
style={{ width: 36 }}
/>
</div>
<p className="text-xs text-neutral-500 mt-1">{placeholder}</p>
</div>
);
};
@@ -0,0 +1,737 @@
/**
* /admin/clients/calendar admin calendar (read-only skeleton for E.6).
*
* Renders four layers fetched in one shot from /api/admin/calendar/items
* (backend route adminCalendar.js, E.3):
*
* - events (galleries) blue solid
* - hour entries green solid (greyed when locked)
* - pending quotes (not converted) amber dashed
* - pending contracts (not converted) purple dashed
*
* View toggle (Month / Week) persists per admin via localStorage
* (`utils/calendarPrefs.ts`).
*
* Click behaviour:
* - event /admin/events/:slug
* - quote /admin/clients/quotes/:id
* - contract /admin/clients/contracts/:id
* - hours noop in E.6; E.7 adds the inline edit popover.
*
* Interactions deferred to E.7:
* - drag-create on empty slots (hour-entry create modal)
* - drag-resize / drag-move on unlocked hour entries
* - inline edit popover on hour entries
*
* Bundle note: this file is the entry point of the `fullcalendar` chunk
* carved in vite.config.ts (E.5). FullCalendar's plugin imports flow
* through here only the calling App.tsx loads this module lazily.
*/
import React, { useEffect, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { useNavigate } from 'react-router-dom';
import { toast } from 'react-toastify';
import { useLocalizedDate } from '../../../hooks/useLocalizedDate';
import { useFeatureFlags } from '../../../contexts/FeatureFlagsContext';
import FullCalendar from '@fullcalendar/react';
import type {
EventInput,
DatesSetArg,
EventClickArg,
EventDropArg,
DateSelectArg,
} from '@fullcalendar/core';
import type { EventResizeDoneArg } from '@fullcalendar/interaction';
import dayGridPlugin from '@fullcalendar/daygrid';
import timeGridPlugin from '@fullcalendar/timegrid';
import interactionPlugin from '@fullcalendar/interaction';
// F.4 — bundle locales for the languages picpeak speaks. Importing
// the locale module registers it with FC's locale registry; the
// `locale` prop then resolves to it by code. Other locales (fr/nl/
// pt/ru) fall back to English day/month names.
import deLocale from '@fullcalendar/core/locales/de';
import { Card, Button, Loading } from '../../../components/common';
import {
calendarService,
type CalendarItem,
type CalendarHoursItem,
} from '../../../services/calendar.service';
import { businessProfileService } from '../../../services/businessProfile.service';
import { customerAdminService } from '../../../services/customerAdmin.service';
import { getCalendarView, setCalendarView, type CalendarView } from '../../../utils/calendarPrefs';
import { HourEntryDragCreateModal } from './HourEntryDragCreateModal';
import { HourEntryInlinePopover } from './HourEntryInlinePopover';
// Color tokens. Hex literals (rather than tailwind utility classes)
// because FullCalendar applies these as inline `background-color` /
// `border-color` styles on the rendered event chips — tailwind classes
// wouldn't take effect.
const COLOR_EVENT = '#3B82F6'; // blue-500
const COLOR_HOURS = '#10B981'; // emerald-500
const COLOR_HOURS_LOCKED = '#9CA3AF'; // gray-400 (greyed)
const COLOR_QUOTE_BORDER = '#F59E0B'; // amber-500
const COLOR_CONTRACT_BORDER = '#A855F7'; // purple-500
/**
* Convert a backend CalendarItem to a FullCalendar EventInput. Embeds
* the original item in `extendedProps` so the event-click handler can
* read the `kind` discriminator without re-fetching.
*/
function mapItemToFcEvent(item: CalendarItem): EventInput {
// Only unlocked hour entries are draggable/resizable. Everything
// else stays inert — click-to-deep-link is the only interaction.
const editable = item.kind === 'hours' && !item.locked;
const base: EventInput = {
id: `${item.kind}-${item.id}`,
extendedProps: { item },
editable,
durationEditable: editable,
startEditable: editable,
};
// Backend should return YYYY-MM-DD, but a prior bug (a pg date
// column round-tripping as a full ISO timestamp like
// "2026-05-18T00:00:00.000Z") slipped through and ended up
// concatenated into FC start strings as
// "2026-05-18T00:00:00.000ZT09:00", which FC parsed to NaN and
// silently dropped. Slice to 10 chars here as a safety belt so any
// future Date-vs-string drift on the backend can't repeat that.
const rawDate = (item as { eventDate?: string; entryDate?: string }).eventDate
|| (item as { entryDate?: string }).entryDate
|| '';
const dateStr = typeof rawDate === 'string' ? rawDate.slice(0, 10) : '';
if (item.kind === 'event') {
base.title = item.eventName || 'Event';
base.backgroundColor = COLOR_EVENT;
base.borderColor = COLOR_EVENT;
if (item.isFullDay || !item.eventTimeStart) {
base.start = dateStr;
base.allDay = true;
} else {
base.start = `${dateStr}T${item.eventTimeStart}`;
base.end = item.eventTimeEnd ? `${dateStr}T${item.eventTimeEnd}` : undefined;
}
} else if (item.kind === 'hours') {
// Hours always have HH:MM start + end (entered via the form).
base.title = item.customerName
? `${item.customerName}${item.description || ''}`.trim().replace(/—\s*$/, '')
: item.description || 'Hours';
if (item.locked) {
base.backgroundColor = COLOR_HOURS_LOCKED;
base.borderColor = COLOR_HOURS_LOCKED;
base.classNames = ['cal-hours-locked'];
} else {
base.backgroundColor = COLOR_HOURS;
base.borderColor = COLOR_HOURS;
}
base.start = `${dateStr}T${item.startTime}`;
base.end = `${dateStr}T${item.endTime}`;
} else if (item.kind === 'quote') {
base.title = item.eventName
? `${item.quoteNumber}${item.eventName}`
: item.quoteNumber;
base.backgroundColor = 'transparent';
base.borderColor = COLOR_QUOTE_BORDER;
base.textColor = COLOR_QUOTE_BORDER;
base.classNames = ['cal-dashed'];
if (item.eventTimeStart) {
base.start = `${dateStr}T${item.eventTimeStart}`;
base.end = item.eventTimeEnd ? `${dateStr}T${item.eventTimeEnd}` : undefined;
} else {
base.start = dateStr;
base.allDay = true;
}
} else if (item.kind === 'contract') {
base.title = item.eventName
? `${item.contractNumber}${item.eventName}`
: item.contractNumber;
base.backgroundColor = 'transparent';
base.borderColor = COLOR_CONTRACT_BORDER;
base.textColor = COLOR_CONTRACT_BORDER;
base.classNames = ['cal-dashed'];
if (item.eventTimeStart) {
base.start = `${dateStr}T${item.eventTimeStart}`;
base.end = item.eventTimeEnd ? `${dateStr}T${item.eventTimeEnd}` : undefined;
} else {
base.start = dateStr;
base.allDay = true;
}
}
return base;
}
/**
* Derive an ISO YYYY-MM-DD pair covering the calendar's current visible
* range plus a 1-week buffer on each side. The buffer keeps the cache
* hot during week-by-week navigation so the user doesn't see a
* loading flicker each step.
*/
function bufferedRange(active: { start: Date; end: Date }) {
const startMs = active.start.getTime() - 7 * 24 * 60 * 60 * 1000;
const endMs = active.end.getTime() + 7 * 24 * 60 * 60 * 1000;
const from = new Date(startMs).toISOString().slice(0, 10);
const to = new Date(endMs).toISOString().slice(0, 10);
return { from, to };
}
export const CalendarPage: React.FC = () => {
const { t, i18n } = useTranslation();
const navigate = useNavigate();
const queryClient = useQueryClient();
const calendarRef = useRef<FullCalendar | null>(null);
// I.3 — master `hoursLogging` flag gates ALL hour-entry interactions
// on the calendar (drag-create / drag-move / drag-resize / inline
// edit). When off, the calendar stays read-only: existing entries
// still render so the admin sees their history, but no new ones can
// be created and existing ones can't be moved. The Customers detail
// page is where the per-customer flag is set; THIS gate is the
// global Settings → Features master switch.
const { flags } = useFeatureFlags();
const hoursLoggingEnabled = !!flags.hoursLogging;
// F.4 — admin's general_time_format drives FC's time labels.
// dateFormat is consumed only for the (deferred) custom title
// formatter; FC's built-in title/header formatting follows the
// `locale` prop below for now.
const { timeFormat } = useLocalizedDate();
const fcHour12 = timeFormat === '12h';
// Single Intl.DateTimeFormatOptions object reused by slotLabelFormat
// and eventTimeFormat so the time-grid axis and event-chip prefixes
// stay in sync. 24h installs see "14:00"; 12h installs see "2:00 PM".
const fcTimeFormat: Intl.DateTimeFormatOptions = useMemo(() => ({
hour: '2-digit',
minute: '2-digit',
hour12: fcHour12,
// 24h installs prefer no leading zero on the hour ("9:00" not "09:00");
// FC accepts the `meridiem: false` shortcut for that.
meridiem: fcHour12 ? 'short' : false,
} as Intl.DateTimeFormatOptions), [fcHour12]);
// View persisted in localStorage (E.5 / E.6 — utils/calendarPrefs.ts).
const [view, setView] = useState<CalendarView>(() => getCalendarView());
// Interaction state (E.7): drag-create payload + the hour entry the
// inline popover is open against. Each is mutually exclusive; opening
// one closes the other through the consumer's onClose.
const [dragCreateState, setDragCreateState] = useState<{
entryDate: string; startTime: string; endTime: string;
} | null>(null);
const [activeHoursItem, setActiveHoursItem] = useState<CalendarHoursItem | null>(null);
// Range owned by the calendar instance — initialised lazily once
// FullCalendar fires its first `datesSet`. Until then the query is
// disabled so we don't fire a request for a guessed range.
const [range, setRange] = useState<{ from: string; to: string } | null>(null);
const { data: itemsResp, isLoading: itemsLoading } = useQuery({
queryKey: ['calendar-items', range?.from, range?.to],
queryFn: () => calendarService.list(range as { from: string; to: string }),
enabled: !!range,
// I.5 — staleTime bumped to 5 min so navigation away from the
// calendar and back within a reasonable window reads cache only;
// no refetch fires that could (per the bug the user keeps
// reporting) overwrite the local optimistic-merged items with a
// server response that's missing the freshly-saved entry. After
// 5 min the natural staleness kicks in + the entry is in the DB
// anyway by then. gcTime stays at the default 5 min so the cache
// entry survives a brief nav away.
staleTime: 5 * 60_000,
// Don't refetch on every remount — the cache is the source of
// truth for the duration of the staleTime. datesSet (a real range
// change) still triggers a fresh fetch via the queryKey change.
refetchOnMount: false,
});
// Resolve the timezone the calendar should render in. business_profile
// .timezone wins; fall back to the browser's IANA tz.
const { data: bpSnapshot } = useQuery({
queryKey: ['business-profile-for-calendar'],
queryFn: () => businessProfileService.get(),
staleTime: 5 * 60_000, // Settings change infrequently — long stale window.
});
const resolvedTz = bpSnapshot?.profile.timezone
|| Intl.DateTimeFormat().resolvedOptions().timeZone
|| 'UTC';
// Map backend → FC event shape. Recomputed on every items change;
// the cost is tiny relative to FC's render path.
const fcEvents = useMemo(
() => (itemsResp?.items || []).map(mapItemToFcEvent),
[itemsResp],
);
// Persist view changes. We don't use FC's viewClassNames or similar
// — the explicit toggle buttons drive both the FC instance + the
// stored pref.
useEffect(() => {
setCalendarView(view);
const api = calendarRef.current?.getApi();
if (api && api.view.type !== view) {
api.changeView(view);
}
}, [view]);
const handleDatesSet = (arg: DatesSetArg) => {
const next = bufferedRange({ start: arg.start, end: arg.end });
setRange((prev) => {
// Avoid a re-render storm: only update when the buffered range
// actually shifts past its window.
if (prev && prev.from === next.from && prev.to === next.to) return prev;
return next;
});
};
const handleEventClick = (arg: EventClickArg) => {
const item = arg.event.extendedProps.item as CalendarItem | undefined;
if (!item) return;
if (item.kind === 'event') navigate(`/admin/events/${item.slug}`);
else if (item.kind === 'quote') navigate(`/admin/clients/quotes/${item.id}`);
else if (item.kind === 'contract') navigate(`/admin/clients/contracts/${item.id}`);
else if (item.kind === 'hours') setActiveHoursItem(item);
};
/**
* Drag-select on the time grid open the create modal pre-filled
* with the selected range. In month view a single-click select gets a
* full-day range (no times) guard against that so we don't open the
* modal with HH:MM=00:0000:00 across a multi-day span. Single-day,
* sub-24h selects with explicit times are the supported case.
*
* I.3 refuse if master hoursLogging flag is off. The backend would
* 409 the save anyway; this prevents the modal from opening in the
* first place. Toast tells the admin where to enable it.
*/
const handleDateSelect = (sel: DateSelectArg) => {
if (sel.allDay) return;
// Use local-component extraction (matches handleEventDrop /
// handleEventResize). The previous `.toISOString().slice(0, 10)`
// path read UTC, so a drag-select on Wed 01:00 in Berlin (UTC+2)
// shifted entryDate back to Tuesday's UTC date — the entry was
// saved under the wrong day and "disappeared" from the visible
// week on the next refresh.
const pad = (n: number) => String(n).padStart(2, '0');
const ymd = (d: Date) => `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
const startDay = ymd(sel.start);
const endDay = ymd(sel.end);
if (startDay !== endDay) return;
if (!hoursLoggingEnabled) {
toast.error(t('calendar.hourEntry.featureOffToast', 'Hour logging is disabled.') as string);
calendarRef.current?.getApi().unselect();
return;
}
const hh = (d: Date) => `${pad(d.getHours())}:${pad(d.getMinutes())}`;
setDragCreateState({ entryDate: startDay, startTime: hh(sel.start), endTime: hh(sel.end) });
};
/**
* Drag-move: admin drags the whole block to a different day or slot.
* FullCalendar gives us the new start / end as Date objects. We
* translate to (entryDate, startTime, endTime) and PUT through the
* existing hour-entry update route. On 4xx/5xx, FC's `revert()` puts
* the chip back where it was so the UI never lies about persistence.
*/
const handleEventDrop = async (arg: EventDropArg) => {
const item = arg.event.extendedProps.item as CalendarItem | undefined;
if (!item || item.kind !== 'hours' || item.locked) {
arg.revert();
return;
}
const start = arg.event.start;
const end = arg.event.end;
if (!start || !end) {
arg.revert();
return;
}
const pad = (n: number) => String(n).padStart(2, '0');
const entryDate = `${start.getFullYear()}-${pad(start.getMonth() + 1)}-${pad(start.getDate())}`;
const startTime = `${pad(start.getHours())}:${pad(start.getMinutes())}`;
const endTime = `${pad(end.getHours())}:${pad(end.getMinutes())}`;
try {
await customerAdminService.updateHourEntry(item.customerAccountId, item.id, {
entryDate, startTime, endTime,
});
toast.success(t('calendar.hourEntry.movedToast', 'Hours updated.'));
// F.7 — refetch (not just invalidate) so the calendar visibly
// reflects the move/resize before any subsequent user action.
await Promise.all([
queryClient.refetchQueries({ queryKey: ['calendar-items'] }),
queryClient.refetchQueries({
queryKey: ['admin-customer-hour-entries', item.customerAccountId],
}),
]);
} catch (err) {
// I.2 — friendly toast when the hour-logging flag is off
// server-side; raw axios message otherwise.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const e = err as any;
const code = e?.response?.data?.code;
const serverMsg = e?.response?.data?.error;
if (code === 'FEATURE_OFF') {
toast.error(t('calendar.hourEntry.featureOffToast',
'Hour logging is disabled for this customer. Enable it on the customer detail page first.') as string);
} else {
const msg = serverMsg || (err instanceof Error ? err.message : String(err));
toast.error(t('calendar.hourEntry.moveFailed', { message: msg, defaultValue: `Couldn't move: ${msg}` }) as string);
}
arg.revert();
}
};
/**
* Drag-resize: same as drop, but the start tends to be preserved and
* only the end shifts (or vice-versa for the leading edge). The PUT
* payload doesn't care which edge moved we just send the canonical
* triple.
*/
const handleEventResize = async (arg: EventResizeDoneArg) => {
const item = arg.event.extendedProps.item as CalendarItem | undefined;
if (!item || item.kind !== 'hours' || item.locked) {
arg.revert();
return;
}
const start = arg.event.start;
const end = arg.event.end;
if (!start || !end) {
arg.revert();
return;
}
const pad = (n: number) => String(n).padStart(2, '0');
const entryDate = `${start.getFullYear()}-${pad(start.getMonth() + 1)}-${pad(start.getDate())}`;
const startTime = `${pad(start.getHours())}:${pad(start.getMinutes())}`;
const endTime = `${pad(end.getHours())}:${pad(end.getMinutes())}`;
try {
await customerAdminService.updateHourEntry(item.customerAccountId, item.id, {
entryDate, startTime, endTime,
});
toast.success(t('calendar.hourEntry.resizedToast', 'Hours updated.'));
// F.7 — refetch (not just invalidate) so the calendar visibly
// reflects the move/resize before any subsequent user action.
await Promise.all([
queryClient.refetchQueries({ queryKey: ['calendar-items'] }),
queryClient.refetchQueries({
queryKey: ['admin-customer-hour-entries', item.customerAccountId],
}),
]);
} catch (err) {
// I.2 — friendly toast on FEATURE_OFF (per-customer hour
// logging disabled server-side); raw message otherwise.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const e = err as any;
const code = e?.response?.data?.code;
const serverMsg = e?.response?.data?.error;
if (code === 'FEATURE_OFF') {
toast.error(t('calendar.hourEntry.featureOffToast',
'Hour logging is disabled for this customer. Enable it on the customer detail page first.') as string);
} else {
const msg = serverMsg || (err instanceof Error ? err.message : String(err));
toast.error(t('calendar.hourEntry.resizeFailed', { message: msg, defaultValue: `Couldn't resize: ${msg}` }) as string);
}
arg.revert();
}
};
return (
<div className="space-y-4">
<div className="flex items-center justify-between flex-wrap gap-3">
<div>
<h1 className="text-2xl font-bold text-theme">
{t('calendar.pageTitle', 'Calendar')}
</h1>
<p className="text-sm text-muted-theme">
{t('calendar.subtitle',
'Events, logged hours, and pending quotes/contracts in one view.')}
</p>
</div>
<div className="flex items-center gap-2">
<Button
variant={view === 'dayGridMonth' ? 'primary' : 'outline'}
size="sm"
onClick={() => setView('dayGridMonth')}
>
{t('calendar.viewMonth', 'Month')}
</Button>
<Button
variant={view === 'timeGridWeek' ? 'primary' : 'outline'}
size="sm"
onClick={() => setView('timeGridWeek')}
>
{t('calendar.viewWeek', 'Week')}
</Button>
</div>
</div>
<Legend />
<Card padding="md">
{itemsLoading && !itemsResp && (
<div className="mb-3 flex items-center gap-2 text-sm text-muted-theme">
<Loading />
<span>{t('calendar.loading', 'Loading items…')}</span>
</div>
)}
<FullCalendar
ref={calendarRef}
plugins={[dayGridPlugin, timeGridPlugin, interactionPlugin]}
initialView={view}
timeZone={resolvedTz}
// F.4 — language + time format come from picpeak settings.
// `locales` registers the locales we ship; `locale` picks the
// active one by i18next language code. Unknown languages
// fall back to FC's default English. Times are gated on the
// admin's general_time_format via the shared fcTimeFormat
// object below.
locales={[deLocale]}
locale={i18n.language || 'en'}
slotLabelFormat={fcTimeFormat}
eventTimeFormat={fcTimeFormat}
// Week-view column headers. FC's default in en-US renders as
// "Thu 5/21" (M/D), which is wrong for DE / CH operators who
// expect day.month. dayHeaderContent (NOT dayHeaderFormat,
// which only accepts an Intl options object in FC v6 and
// throws if handed a function) returns the rendered string
// directly. Locale-aware short weekday + explicit DD.MM. to
// match the project-wide useLocalizedDate convention (per
// feedback_respect_general_format_settings.md).
dayHeaderContent={(arg) => {
const d = arg.date;
const day = String(d.getUTCDate()).padStart(2, '0');
const month = String(d.getUTCMonth() + 1).padStart(2, '0');
const weekday = d.toLocaleDateString(i18n.language || 'en', {
weekday: 'short',
timeZone: 'UTC',
});
return `${weekday} ${day}.${month}.`;
}}
headerToolbar={{
left: 'prev,next today',
center: 'title',
right: '',
}}
// Week starts on Monday for the operator's EU market.
firstDay={1}
// Per user spec: full 24h in week view (was 06:00-22:00).
slotMinTime="00:00:00"
slotMaxTime="24:00:00"
slotDuration="00:30:00"
height="auto"
expandRows
events={fcEvents}
eventClick={handleEventClick}
datesSet={handleDatesSet}
// E.7 — interactions enabled. Per-event `editable` already
// filters the draggable surface to unlocked hour entries
// (mapItemToFcEvent above). `selectable` enables the
// drag-to-create gesture on empty slots in week view.
// I.3 — master hoursLogging gates all drag interactions
// (drag-to-create, drag-move, drag-resize). When off, the
// calendar is read-only; existing entries still render so
// history stays visible.
selectable={hoursLoggingEnabled}
selectMirror
editable={hoursLoggingEnabled}
// FullCalendar quirk: selecting on the all-day row fires
// with allDay=true; the drag-create handler ignores those.
eventDrop={handleEventDrop}
eventResize={handleEventResize}
select={handleDateSelect}
/>
</Card>
{dragCreateState && (
<HourEntryDragCreateModal
entryDate={dragCreateState.entryDate}
startTime={dragCreateState.startTime}
endTime={dragCreateState.endTime}
onClose={() => {
calendarRef.current?.getApi().unselect();
setDragCreateState(null);
}}
onCreated={(created) => {
// H.1 — push the new chip into FC's eventStore directly so
// it appears immediately, independent of the useQuery cache
// and the events-prop diffing that was silently dropping
// the new entry. The background invalidate from the modal
// will eventually replace this with the server-shaped row.
calendarRef.current?.getApi().addEvent({
id: `hours-${created.id}`,
title: created.customerName
? `${created.customerName}${created.description || ''}`.trim().replace(/—\s*$/, '')
: created.description || 'Hours',
start: `${created.entryDate}T${created.startTime}`,
end: `${created.entryDate}T${created.endTime}`,
backgroundColor: '#10B981',
borderColor: '#10B981',
editable: true,
durationEditable: true,
startEditable: true,
extendedProps: {
item: {
kind: 'hours' as const,
id: created.id,
customerAccountId: created.customerAccountId,
entryDate: created.entryDate,
startTime: created.startTime,
endTime: created.endTime,
description: created.description,
status: 'unbilled' as const,
invoiceId: null,
invoiceStatus: null,
locked: false,
customerName: created.customerName,
},
},
});
calendarRef.current?.getApi().unselect();
setDragCreateState(null);
}}
/>
)}
{activeHoursItem && (
<HourEntryInlinePopover
item={activeHoursItem}
onClose={() => setActiveHoursItem(null)}
onMutated={() => setActiveHoursItem(null)}
/>
)}
{/* Per-instance CSS:
- dashed quote/contract chips (Tailwind can't reach inline FC
chip styles, so we override with a small local rule).
- F.5 FC's prev/next/today buttons styled to match picpeak's
.btn-outline / .btn-primary tokens. FC ships its own
.fc-button-primary class; we override the relevant rules
and bind to the CSS variables the rest of the admin theme
uses (--color-accent-dark, --color-surface-border,
--color-muted-text). The "today" / disabled-edge buttons
and the active state (selected view) all read from these
vars so the calendar respects custom branding palettes. */}
<style>{`
.cal-dashed {
border-style: dashed !important;
background-color: transparent !important;
}
.cal-dashed .fc-event-title,
.cal-dashed .fc-event-time {
font-style: italic;
}
.cal-hours-locked {
opacity: 0.7;
}
/* FC button restyle — match picpeak admin .btn-outline shape. */
.fc .fc-button-primary {
background-color: transparent;
border: 1px solid var(--color-surface-border);
color: var(--color-muted-text);
text-transform: none;
font-weight: 500;
box-shadow: none;
transition: opacity 0.15s, background-color 0.15s;
}
.fc .fc-button-primary:hover:not(:disabled) {
opacity: 0.8;
background-color: transparent;
border-color: var(--color-surface-border);
color: var(--color-muted-text);
}
.fc .fc-button-primary:focus,
.fc .fc-button-primary:focus-visible {
box-shadow: 0 0 0 2px var(--color-accent-dark);
outline: none;
}
/* "today" / active view button — picpeak's primary fill. */
.fc .fc-button-primary:not(:disabled).fc-button-active,
.fc .fc-button-primary:not(:disabled):active {
background-color: var(--color-accent-dark);
border-color: var(--color-accent-dark);
color: white;
}
.fc .fc-button-primary:disabled {
opacity: 0.5;
background-color: transparent;
border-color: var(--color-surface-border);
color: var(--color-muted-text);
}
/* Toolbar title (month / week range) reads the regular text
colour rather than FC's hardcoded #333 so dark mode looks
right. */
.fc .fc-toolbar-title {
color: var(--color-text);
font-size: 1.125rem;
font-weight: 600;
}
/* Week-view day dividers.
Pin --fc-border-color so FC's header cells + horizontal
slot lines pick up the admin theme. The vertical dividers
in the time-grid body need a separate trick: FC v6 either
merges the day columns into one wide cell or sets a zero
border more specifically than our rule can override, so
plain border-left doesn't render. Use box-shadow with
"inset 1px 0 0" instead it draws a 1px line inside the
column without affecting layout, survives border-collapse,
and isn't clipped by overflow:hidden. Apply on every
selector that maps to a day column so the rule works
regardless of which DOM shape FC ends up rendering. */
.fc {
--fc-border-color: var(--color-surface-border);
}
.fc .fc-timegrid-col:not(:first-of-type),
.fc .fc-day:not(:first-child),
.fc .fc-timegrid-cols > table > tbody > tr > td:not(:first-child) {
box-shadow: inset 1px 0 0 var(--color-surface-border);
}
`}</style>
</div>
);
};
/**
* Color-legend strip rendered above the calendar. Kept inline rather
* than as a sibling component because it never reuses elsewhere and
* sharing the COLOR_* tokens with the mapper above is cheap.
*/
const Legend: React.FC = () => {
const { t } = useTranslation();
return (
<Card padding="sm">
<div className="flex flex-wrap gap-4 text-xs text-muted-theme">
<LegendSwatch color={COLOR_EVENT} label={t('calendar.legend.events', 'Events')} />
<LegendSwatch color={COLOR_HOURS} label={t('calendar.legend.hours', 'Hours')} />
<LegendSwatch
color={COLOR_QUOTE_BORDER}
label={t('calendar.legend.pendingQuotes', 'Pending quotes')}
dashed
/>
<LegendSwatch
color={COLOR_CONTRACT_BORDER}
label={t('calendar.legend.pendingContracts', 'Pending contracts')}
dashed
/>
<LegendSwatch
color={COLOR_HOURS_LOCKED}
label={t('calendar.legend.hoursLocked', 'Locked (billed)')}
/>
</div>
</Card>
);
};
const LegendSwatch: React.FC<{ color: string; label: string; dashed?: boolean }> = ({
color, label, dashed,
}) => (
<div className="flex items-center gap-2">
<span
aria-hidden
className="inline-block w-4 h-3 rounded-sm"
style={{
backgroundColor: dashed ? 'transparent' : color,
border: dashed ? `1.5px dashed ${color}` : `1px solid ${color}`,
}}
/>
<span>{label}</span>
</div>
);
@@ -0,0 +1,319 @@
/**
* CRM Development sub-page.
*
* Houses internal-use dev tools for the CRM area. Hidden behind the
* `crmDevelopment` feature flag (Settings Features).
*
* Current tools:
* - "Test admin payment-check email" fires the full
* payment-check email flow on any sent/overdue invoice,
* bypassing the 24h throttle.
* - "Send any CRM email to me" fires any of the seeded CRM
* email templates to the currently-logged-in admin's mailbox
* with mock data (real PDF attached when a matching record
* exists). Lets the maintainer eyeball every template's
* rendered output in seconds.
*/
import React, { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useQuery } from '@tanstack/react-query';
import { Wrench, MailCheck, AlertTriangle, Mail } from 'lucide-react';
import { Button, Card, Loading } from '../../../components/common';
import { billsService, type InvoiceStatus } from '../../../services/bills.service';
import { devToolsService, type CrmEmailTemplateKey } from '../../../services/devTools.service';
import { toast } from 'react-toastify';
// Translation keys for each template's display title + description.
// The lookup table holds (titleKey, titleFallback, descKey, descFallback)
// so the t() call at render time can do the EN-fallback dance without
// every key being typed out inline. Adding a template means: add the
// key here AND the matching translation pair to en.json/de.json.
const TEMPLATE_LABEL_KEYS: Record<
CrmEmailTemplateKey,
{ titleKey: string; titleFallback: string; descKey: string; descFallback: string }
> = {
quote_sent: {
titleKey: 'crmDev.templates.label.quote_sent.title',
titleFallback: 'Quote sent (to customer)',
descKey: 'crmDev.templates.label.quote_sent.description',
descFallback: 'Fires when a quote is sent — includes the PDF attachment + accept/decline links.',
},
quote_accepted_customer: {
titleKey: 'crmDev.templates.label.quote_accepted_customer.title',
titleFallback: 'Quote accepted — customer confirmation',
descKey: 'crmDev.templates.label.quote_accepted_customer.description',
descFallback: 'Sent to the customer after they (or the admin on their behalf) accept the quote. Includes PDF.',
},
quote_accepted_admin: {
titleKey: 'crmDev.templates.label.quote_accepted_admin.title',
titleFallback: 'Quote accepted — admin notification',
descKey: 'crmDev.templates.label.quote_accepted_admin.description',
descFallback: 'Notifies the admin that a quote was accepted by the customer.',
},
quote_declined_admin: {
titleKey: 'crmDev.templates.label.quote_declined_admin.title',
titleFallback: 'Quote declined — admin notification',
descKey: 'crmDev.templates.label.quote_declined_admin.description',
descFallback: 'Notifies the admin that a quote was declined by the customer.',
},
invoice_sent: {
titleKey: 'crmDev.templates.label.invoice_sent.title',
titleFallback: 'Invoice sent (to customer)',
descKey: 'crmDev.templates.label.invoice_sent.description',
descFallback: 'Fires when an invoice is sent — includes the PDF attachment.',
},
invoice_reminder_first: {
titleKey: 'crmDev.templates.label.invoice_reminder_first.title',
titleFallback: 'Invoice reminder — first',
descKey: 'crmDev.templates.label.invoice_reminder_first.description',
descFallback: 'First reminder to the customer. No late fee yet.',
},
invoice_reminder_second: {
titleKey: 'crmDev.templates.label.invoice_reminder_second.title',
titleFallback: 'Invoice reminder — second (with Mahngebühr)',
descKey: 'crmDev.templates.label.invoice_reminder_second.description',
descFallback: 'Second reminder with the late fee surcharge added.',
},
invoice_payment_check_admin: {
titleKey: 'crmDev.templates.label.invoice_payment_check_admin.title',
titleFallback: 'Payment-check (admin)',
descKey: 'crmDev.templates.label.invoice_payment_check_admin.description',
descFallback: 'Admin email with the three signed-token action buttons.',
},
contract_sent: {
titleKey: 'crmDev.templates.label.contract_sent.title',
titleFallback: 'Contract sent (to customer)',
descKey: 'crmDev.templates.label.contract_sent.description',
descFallback: 'Admin → customer send with the unsigned contract PDF attached.',
},
contract_signed_admin_notification: {
titleKey: 'crmDev.templates.label.contract_signed_admin_notification.title',
titleFallback: 'Contract signed — admin notification',
descKey: 'crmDev.templates.label.contract_signed_admin_notification.description',
descFallback: 'Customer-signed ping back to the admin. No attachment.',
},
contract_fully_signed: {
titleKey: 'crmDev.templates.label.contract_fully_signed.title',
titleFallback: 'Contract fully signed (dual-party)',
descKey: 'crmDev.templates.label.contract_fully_signed.description',
descFallback: 'Dual-party send once both signatures are in. Stamped contract PDF attached.',
},
};
export const CrmDevelopmentPage: React.FC = () => {
const { t } = useTranslation();
// ---- Test payment-check email picker -------------------------
const { data: invoiceList, isLoading: invoiceListLoading } = useQuery({
queryKey: ['invoices', 'dev-payment-check'],
queryFn: () => billsService.list({
status: ['sent', 'overdue'] as InvoiceStatus[],
sort: 'newest',
pageSize: 50,
}),
});
// Shared error inspector — the security env-gate added in
// commit abd50e4 returns 403 with code 'CRM_DEV_ENV_DISABLED'
// whenever PICPEAK_ENABLE_DEV_TOOLS isn't set. Without surfacing
// this, the page just rendered an empty templates list and a
// silent useQuery error — admin couldn't tell why the tools
// appeared "broken".
const isEnvDisabled = (err: unknown): boolean => {
const e = err as { response?: { data?: { code?: string } } } | null;
return e?.response?.data?.code === 'CRM_DEV_ENV_DISABLED';
};
const [selectedInvoiceId, setSelectedInvoiceId] = useState<number | null>(null);
const [paymentCheckBusy, setPaymentCheckBusy] = useState(false);
const handleSendPaymentCheck = async () => {
if (!selectedInvoiceId) return;
setPaymentCheckBusy(true);
try {
await billsService.testPaymentCheck(selectedInvoiceId);
toast.success(t('crmDev.paymentCheck.sentToast',
'Test email queued. Check the recipient\'s inbox.'));
} catch (e: any) {
toast.error(e?.response?.data?.error || 'Failed to queue test email');
} finally {
setPaymentCheckBusy(false);
}
};
// ---- Email-template self-tester -----------------------------
const { data: templates, isLoading: templatesLoading, error: templatesError } = useQuery({
queryKey: ['dev', 'email-templates'],
queryFn: () => devToolsService.listEmailTemplates(),
// Don't retry env-disabled — the env var won't appear mid-session.
retry: (failureCount, err) => !isEnvDisabled(err) && failureCount < 2,
});
const [sendingKey, setSendingKey] = useState<CrmEmailTemplateKey | null>(null);
const handleSendTemplate = async (key: CrmEmailTemplateKey) => {
setSendingKey(key);
try {
const result = await devToolsService.sendTestEmail(key);
toast.success(t('crmDev.templates.sentToast',
'Queued to {{to}}.', { to: result.to }));
} catch (e: any) {
toast.error(e?.response?.data?.error || 'Failed to queue test email');
} finally {
setSendingKey(null);
}
};
return (
<div>
<div className="mb-6">
<h2 className="text-xl font-bold text-neutral-900 dark:text-neutral-100 flex items-center gap-2">
<Wrench className="w-5 h-5" />
{t('crmDev.title', 'CRM Development')}
</h2>
<p className="text-sm text-neutral-600 dark:text-neutral-400 mt-1">
{t('crmDev.subtitle',
'Internal tools for verifying CRM flows. Hidden by default — enabled via Settings → Features → Development.')}
</p>
</div>
<div className="rounded-md border border-amber-300 bg-amber-50 dark:bg-amber-900/20 dark:border-amber-700 p-3 mb-5 flex items-start gap-2">
<AlertTriangle className="w-4 h-4 text-amber-700 dark:text-amber-300 mt-0.5 shrink-0" />
<p className="text-sm text-amber-800 dark:text-amber-200">
{t('crmDev.warning',
'These tools fire real side effects (emails, status changes). Use against test data.')}
</p>
</div>
{/* Env-gate banner. The /admin/dev routes are hard-gated by
PICPEAK_ENABLE_DEV_TOOLS=1 (security fix abd50e4) a
feature-flag flip alone isn't enough. Without this, the
templates list silently shows up empty and admins assume the
tools are broken. */}
{isEnvDisabled(templatesError) && (
<div className="rounded-md border border-red-300 bg-red-50 dark:bg-red-900/20 dark:border-red-700 p-3 mb-5 flex items-start gap-2">
<AlertTriangle className="w-4 h-4 text-red-700 dark:text-red-300 mt-0.5 shrink-0" />
<div className="text-sm text-red-800 dark:text-red-200">
<p className="font-semibold mb-1">
{t('crmDev.envDisabled.title', 'Dev tools blocked by env gate')}
</p>
<p>
{t('crmDev.envDisabled.body',
'Set PICPEAK_ENABLE_DEV_TOOLS=1 in your backend env file and restart the server. The feature flag alone is not enough — this is a deliberate production-safety guard.')}
</p>
</div>
</div>
)}
{/* Tool: payment-check email against a real invoice. */}
<Card className="mb-5">
<h3 className="font-semibold mb-1 flex items-center gap-2">
<MailCheck className="w-4 h-4" />
{t('crmDev.paymentCheck.title', 'Test payment-check email (real invoice)')}
</h3>
<p className="text-sm text-muted-theme mb-4">
{t('crmDev.paymentCheck.help',
'Fires the admin payment-check email for a real sent/overdue invoice, bypassing the 24h throttle. The three buttons in the email are real signed tokens — clicking them will affect the invoice status.')}
</p>
{invoiceListLoading ? <Loading /> : (
<>
<label className="block text-xs uppercase tracking-wider text-muted-theme mb-1">
{t('crmDev.paymentCheck.selectInvoice', 'Sent or overdue invoice')}
</label>
<select
value={selectedInvoiceId || ''}
onChange={(e) => setSelectedInvoiceId(e.target.value ? Number(e.target.value) : null)}
className="w-full px-3 py-2 rounded-md border border-neutral-300 dark:border-neutral-600 bg-white dark:bg-neutral-800 text-sm mb-3"
>
<option value="">{t('crmDev.paymentCheck.selectPlaceholder', '— Pick an invoice —')}</option>
{(invoiceList?.invoices || []).map((inv) => (
<option key={inv.id} value={inv.id}>
{inv.invoiceNumber} · {inv.customer.companyName || inv.customer.displayName || inv.customer.email} · {inv.status}
</option>
))}
</select>
{invoiceList && invoiceList.invoices.length === 0 && (
<p className="text-sm text-muted-theme mb-3">
{t('crmDev.paymentCheck.noneAvailable',
'No sent or overdue invoices in the database.')}
</p>
)}
<div className="flex justify-end">
<Button onClick={handleSendPaymentCheck} disabled={!selectedInvoiceId || paymentCheckBusy}>
<MailCheck className="w-4 h-4 mr-1" />
{paymentCheckBusy
? t('crmDev.paymentCheck.sending', 'Queuing…')
: t('crmDev.paymentCheck.send', 'Send test email')}
</Button>
</div>
</>
)}
</Card>
{/* Tool: send any CRM template to the logged-in admin. */}
<Card>
<h3 className="font-semibold mb-1 flex items-center gap-2">
<Mail className="w-4 h-4" />
{t('crmDev.templates.title', 'Send any CRM email to me (mock data)')}
</h3>
<p className="text-sm text-muted-theme mb-4">
{t('crmDev.templates.help',
'Queues the chosen template to your own admin email with placeholder values. When the install has a real quote / invoice on file, the appropriate PDF is attached so you can verify the full output.')}
</p>
{templatesLoading ? <Loading /> : isEnvDisabled(templatesError) ? (
// Env gate banner above already explains the situation —
// keep this card empty rather than rendering a misleading
// "0 templates" list.
<p className="text-sm text-muted-theme">
{t('crmDev.envDisabled.cardHint',
'Email templates can\'t be listed until the dev-tools env gate is opened.')}
</p>
) : (templates && templates.length === 0) ? (
<p className="text-sm text-muted-theme">
{t('crmDev.templates.empty',
'No CRM email templates found — run migrations to seed them.')}
</p>
) : (
<ul className="divide-y divide-neutral-200 dark:divide-neutral-700">
{(templates || []).map((tpl) => {
const meta = TEMPLATE_LABEL_KEYS[tpl.key];
const busy = sendingKey === tpl.key;
const title = meta ? t(meta.titleKey, meta.titleFallback) : tpl.key;
const description = meta ? t(meta.descKey, meta.descFallback) : '';
return (
<li key={tpl.key} className="py-3 flex items-start gap-3">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="font-mono text-xs text-neutral-500">{tpl.key}</span>
{!tpl.present && (
<span className="text-[10px] uppercase tracking-wider px-1.5 py-0.5 rounded font-semibold bg-amber-100 text-amber-800 dark:bg-amber-900/40 dark:text-amber-300">
{t('crmDev.templates.notSeeded', 'Not seeded')}
</span>
)}
</div>
<div className="text-sm font-medium mt-0.5">{title}</div>
{description && (
<div className="text-xs text-muted-theme mt-0.5">{description}</div>
)}
</div>
<Button
variant="outline"
size="sm"
onClick={() => handleSendTemplate(tpl.key)}
disabled={!tpl.present || busy}
>
{busy
? t('crmDev.templates.sending', 'Queuing…')
: t('crmDev.templates.send', 'Send to me')}
</Button>
</li>
);
})}
</ul>
)}
</Card>
</div>
);
};
export default CrmDevelopmentPage;
@@ -0,0 +1,346 @@
/**
* HourEntryDragCreateModal opens when the admin drag-selects an
* empty time range on the admin calendar (E.7).
*
* Pre-filled fields from the drag:
* - entryDate (the drag's start day, YYYY-MM-DD)
* - startTime (HH:MM)
* - endTime (HH:MM)
*
* Admin picks the customer through the existing CustomerPicker (C.5)
* and optionally adds a description. Submit POST /admin/customers/
* :id/hour-entries (existing route from migration 129 + B.6 permission
* split). On success, the calendar invalidates its `calendar-items`
* query so the new entry appears as a green block.
*
* Per user spec: customer field is BLANK by default no pre-fill from
* the day's events even when there's exactly one event on that day.
* Admin picks every time.
*
* The drag-create UX is gated by the existing route-layer permission:
* the parent page already requires `customers.view`; the backend
* enforces `customers.edit` on the POST. If the admin's role is
* view-only, the modal opens but the submit fails with a clean 403
* from the backend and the toast surfaces the error.
*/
import React, { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { toast } from 'react-toastify';
import { Button, Card, Input } from '../../../components/common';
import {
CustomerPicker,
type CustomerSummary,
} from '../../../components/admin/CustomerPicker';
import {
customerAdminService,
type CustomerAccountDetail,
} from '../../../services/customerAdmin.service';
export interface HourEntryDragCreateModalProps {
/** Drag start day, YYYY-MM-DD. */
entryDate: string;
/** Drag start time, HH:MM. */
startTime: string;
/** Drag end time, HH:MM. */
endTime: string;
/** Close the modal without saving. */
onClose: () => void;
/**
* Called after a successful create. Receives the saved entry's
* details so the parent can imperatively add it to FullCalendar
* (`calendarRef.getApi().addEvent(...)`) bypasses FC's
* events-prop diffing which was silently dropping new entries (H.1).
*/
onCreated: (created: {
id: number;
customerAccountId: number;
customerName: string | null;
entryDate: string;
startTime: string;
endTime: string;
description: string | null;
}) => void;
}
export const HourEntryDragCreateModal: React.FC<HourEntryDragCreateModalProps> = ({
entryDate,
startTime,
endTime,
onClose,
onCreated,
}) => {
const { t } = useTranslation();
const queryClient = useQueryClient();
// Owned by the parent triple per the CustomerPicker contract.
const [customerId, setCustomerId] = useState<number | null>(null);
const [customerLabel, setCustomerLabel] = useState('');
const [customerIsPassive, setCustomerIsPassive] = useState(false);
// H.2 — track the picked customer's hour-logging eligibility so the
// Save button can refuse the click upfront. Backend still 409s on
// save as a defence-in-depth; this is the matching UI guard.
const [customerHoursAllowed, setCustomerHoursAllowed] = useState(true);
const [description, setDescription] = useState('');
const createMutation = useMutation({
mutationFn: () => {
if (!customerId) throw new Error('Customer required');
return customerAdminService.createHourEntry(customerId, {
entryDate,
startTime,
endTime,
description: description.trim() || null,
});
},
// H.1 — Three prior approaches (F.7 await refetch, G.3 setQueriesData
// optimistic) didn't reliably make the new block visible. The user
// reproduced "disappears" on all three. Root cause: FullCalendar's
// React wrapper diffs the `events` prop by content hash and was
// silently dropping the just-added entry.
//
// This commit hands off the created entry to the PARENT (CalendarPage),
// which uses FC's imperative `addEvent` API to push the chip directly
// into FC's eventStore — bypasses React state, the useQuery cache,
// AND the events-prop diffing. The block appears as soon as the
// modal closes.
//
// Background invalidate still runs so a subsequent navigation or
// refresh syncs against the server-computed shape (customerName
// from joins, status updates).
onSuccess: (result) => {
toast.success(t('calendar.hourEntry.created', 'Hours logged.'));
if (customerId) {
// I.4 — merge the new entry into EVERY cached calendar-items
// range. addEvent (in the parent's onCreated handler) gives
// us the immediate visual; this keeps the react-query cache
// honest so when the admin navigates to next week and back,
// the cached payload for the original range still contains
// the new entry — otherwise FC rebuilds its eventStore from
// a stale cache and wipes the chip.
//
// We deliberately do NOT invalidate — invalidate would refetch
// immediately and FC's events-prop diffing has been shown to
// drop the new entry mid-session (the reason H.1 went
// imperative in the first place). setQueriesData mutates the
// cache in-place; the visual is delivered by addEvent now and
// by the cache on every subsequent datesSet.
const optimisticItem = {
kind: 'hours' as const,
id: result.id,
customerAccountId: customerId,
entryDate,
startTime,
endTime,
description: description.trim() || null,
status: result.status,
invoiceId: result.invoiceId ?? null,
invoiceStatus: null,
locked: false,
customerName: customerLabel || null,
};
queryClient.setQueriesData(
{ queryKey: ['calendar-items'] },
(old: { items?: unknown[]; range?: unknown } | undefined) => {
if (!old || !Array.isArray(old.items)) return old;
// Don't double-insert if a competing tab's refetch already
// delivered the entry.
const already = old.items.some((it: unknown) => {
const x = it as { kind?: string; id?: number };
return !!x && x.kind === 'hours' && x.id === result.id;
});
if (already) return old;
return { ...old, items: [...old.items, optimisticItem] };
},
);
onCreated({
id: result.id,
customerAccountId: customerId,
customerName: customerLabel || null,
entryDate,
startTime,
endTime,
description: description.trim() || null,
});
}
// The per-customer hour-entries query (used by the standalone
// hours page) is on a different observer, so invalidating it
// doesn't affect FC.
queryClient.invalidateQueries({ queryKey: ['admin-customer-hour-entries', customerId] });
},
onError: (err: unknown) => {
// I.2 — detect FEATURE_OFF (server-side hour logging disabled)
// and surface a human message instead of the raw axios string.
// The lone backend path that emits this code is
// customerHoursService.createEntry's per-customer + master flag
// check; on drag-move/resize this can fire if the per-customer
// flag changed since the entry was created.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const e = err as any;
const code = e?.response?.data?.code;
const serverMsg = e?.response?.data?.error;
if (code === 'FEATURE_OFF') {
toast.error(t('calendar.hourEntry.featureOffToast',
'Hour logging is disabled for this customer. Enable it on the customer detail page first.') as string);
return;
}
const msg = serverMsg || (err instanceof Error ? err.message : String(err));
toast.error(t('calendar.hourEntry.createFailed', { message: msg, defaultValue: `Couldn't save: ${msg}` }) as string);
},
});
// H.2 — refuse the click when the picked customer has hour logging
// OFF (backend would 409 anyway; this skips the round-trip + toast).
const canSubmit = !!customerId && customerHoursAllowed && !createMutation.isPending;
// Submit handler shared by the Save button + the wrapping form's
// implicit Enter-key submit. Wrapped in a single guard so a stale
// press with no customer selected just no-ops instead of throwing
// through the mutationFn.
const submit = (e?: React.FormEvent) => {
e?.preventDefault();
if (!canSubmit) return;
createMutation.mutate();
};
// I.6 — close on Escape via a document-level listener. A React
// onKeyDown on the modal's outer div ONLY fires when focus is
// already inside the modal subtree — but after the modal opens
// (from FullCalendar's `select` callback), focus stays on FC's
// canvas / body, so the bubbled-up handler never sees the keydown.
// Listening on document catches Escape regardless of where focus
// sits. Guarded against the mutation being in-flight so the admin
// can't cancel mid-save and end up with a saved-but-modal-closed
// race.
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape' && !createMutation.isPending) {
onClose();
}
};
document.addEventListener('keydown', onKey);
return () => document.removeEventListener('keydown', onKey);
}, [createMutation.isPending, onClose]);
return (
<div
role="dialog"
aria-modal="true"
className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 dark:bg-black/60 p-4"
onClick={(e) => {
// Click on the backdrop closes; clicks inside the card stop
// here. (Esc handled via document-level listener above.)
if (e.target === e.currentTarget) onClose();
}}
>
<Card padding="lg" className="w-full max-w-md">
<h2 className="font-semibold text-lg mb-1">
{t('calendar.hourEntry.createTitle', 'Log hours')}
</h2>
<p className="text-xs text-muted-theme mb-4">
{/* The pre-filled range is part of the page state, not editable
from this modal. Admin can edit start/end after creating
via the inline-edit popover (also in this commit). */}
{entryDate} · {startTime}{endTime}
</p>
{/* Wrap fields in a form so pressing Enter inside the
description input fires the submit handler matches the
keyboard expectation on every other admin modal. The Save
button keeps its onClick for users who navigate via mouse. */}
<form onSubmit={submit}>
<div className="space-y-3">
<div>
<label className="block text-sm font-medium mb-1">
{t('calendar.hourEntry.customerLabel', 'Customer')}
</label>
<CustomerPicker
value={customerId}
label={customerLabel}
isPassive={customerIsPassive}
// F.6 — surface the hours-logging-eligible badge so admin
// sees up front that a customer with feature_hours_logging
// OFF would 409 the save.
requireFeature="hoursLogging"
onSelect={(c: CustomerSummary) => {
setCustomerId(c.id);
setCustomerLabel(
c.companyName
|| [c.firstName, c.lastName].filter(Boolean).join(' ')
|| c.displayName
|| c.email
|| `#${c.id}`,
);
setCustomerIsPassive(Boolean(c.isPassive));
// H.2 — refuse Save when feature_hours_logging is OFF.
// featureHoursLogging is optional on the search summary
// (defaults false on un-G.2 backends); treat undefined
// as eligible so older backends don't block all saves.
setCustomerHoursAllowed(c.featureHoursLogging !== false);
}}
onCreate={(c: CustomerAccountDetail) => {
setCustomerId(c.id);
setCustomerLabel(c.companyName || c.displayName || c.email || `#${c.id}`);
setCustomerIsPassive(Boolean(c.isPassive));
// Freshly-created customers default with hour-logging
// disabled until admin flips it on per-customer.
setCustomerHoursAllowed(c.featureHoursLogging !== false);
}}
onClear={() => {
setCustomerId(null);
setCustomerLabel('');
setCustomerIsPassive(false);
setCustomerHoursAllowed(true);
}}
searchPlaceholder={t('calendar.hourEntry.customerSearch', 'Search by email or company…') as string}
/>
{/* H.2 explicit warning when the picked customer is
ineligible. Without this the admin sees only the
badge on the option row, then a 409 toast after Save.
With this, the Save button is disabled and the reason
is visible. */}
{customerId && !customerHoursAllowed && (
<p className="mt-2 text-xs text-amber-700 dark:text-amber-300">
{t('calendar.hourEntry.customerLoggingDisabled',
"This customer has hour logging disabled. Enable it on the customer's detail page to log hours.")}
</p>
)}
</div>
<div>
<label className="block text-sm font-medium mb-1">
{t('calendar.hourEntry.descriptionLabel', 'Description (optional)')}
</label>
<Input
value={description}
onChange={(e) => setDescription(e.target.value)}
maxLength={1000}
placeholder={t('calendar.hourEntry.descriptionPlaceholder', 'Editing / shoot / travel…') as string}
/>
</div>
</div>
<div className="mt-5 flex items-center justify-end gap-2">
<Button
type="button"
variant="outline"
onClick={onClose}
disabled={createMutation.isPending}
>
{t('calendar.hourEntry.cancel', 'Cancel')}
</Button>
<Button
type="submit"
disabled={!canSubmit}
>
{createMutation.isPending
? t('calendar.hourEntry.saving', 'Saving…')
: t('calendar.hourEntry.submit', 'Save hours')}
</Button>
</div>
</form>
</Card>
</div>
);
};
@@ -0,0 +1,243 @@
/**
* HourEntryInlinePopover quick-edit / delete for an hour entry,
* opened by clicking a green chip on the admin calendar.
*
* Two render modes:
* - editable (item.locked === false): start/end/description fields
* + Save + Delete buttons. Save PUT /admin/customers/:id/
* hour-entries/:entryId. Delete DELETE same.
* - locked (item.locked === true): read-only summary + a lock
* badge + tooltip "Billed — invoice X locked. Storno to edit."
*
* Lock predicate is computed by the backend (customerHoursService.
* _internal.isEntryLocked) and surfaced on each item via E.3. The
* popover trusts that flag for the UI gate; the backend STILL enforces
* the rule on every mutation (PUT/DELETE return 409 ENTRY_LOCKED when
* the row's invoice has shipped), so even if a stale flag slipped
* through, the user-visible state stays correct.
*
* Why a portal-style fixed overlay rather than a positioned bubble:
* FullCalendar doesn't ship a popover anchor, and the admin calendar
* already uses a similar full-screen overlay for the drag-create
* modal. Reusing the pattern keeps the visual language consistent.
*/
import React, { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { Lock, Trash2 } from 'lucide-react';
import { toast } from 'react-toastify';
import { Button, Card, Input } from '../../../components/common';
import { customerAdminService } from '../../../services/customerAdmin.service';
import type { CalendarHoursItem } from '../../../services/calendar.service';
export interface HourEntryInlinePopoverProps {
item: CalendarHoursItem;
onClose: () => void;
/** Called after a successful mutate so the parent can invalidate queries. */
onMutated: () => void;
}
export const HourEntryInlinePopover: React.FC<HourEntryInlinePopoverProps> = ({
item,
onClose,
onMutated,
}) => {
const { t } = useTranslation();
const queryClient = useQueryClient();
// Pre-fill from the item. The form stays uncontrolled-ish — local
// state holds the working copy, only sent on Save.
const [startTime, setStartTime] = useState(item.startTime);
const [endTime, setEndTime] = useState(item.endTime);
const [description, setDescription] = useState(item.description || '');
// F.7 — refetch before the modal closes so the calendar shows the
// updated entry immediately (avoids the brief "block disappears"
// gap reported on drag-create). Returns the promise so onSuccess
// can await it.
const refetchAll = () => Promise.all([
queryClient.refetchQueries({ queryKey: ['calendar-items'] }),
queryClient.refetchQueries({
queryKey: ['admin-customer-hour-entries', item.customerAccountId],
}),
]);
const updateMutation = useMutation({
mutationFn: () => customerAdminService.updateHourEntry(
item.customerAccountId,
item.id,
{
startTime,
endTime,
description: description.trim() || null,
},
),
onSuccess: async () => {
toast.success(t('calendar.hourEntry.saved', 'Hours updated.'));
await refetchAll();
onMutated();
},
onError: (err: unknown) => {
// I.2 — friendly toast on FEATURE_OFF; backend would have
// 409'd if the customer's flag flipped since this entry was
// created.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const e = err as any;
const code = e?.response?.data?.code;
const serverMsg = e?.response?.data?.error;
if (code === 'FEATURE_OFF') {
toast.error(t('calendar.hourEntry.featureOffToast',
'Hour logging is disabled for this customer. Enable it on the customer detail page first.') as string);
return;
}
const msg = serverMsg || (err instanceof Error ? err.message : String(err));
toast.error(t('calendar.hourEntry.saveFailed', { message: msg, defaultValue: `Couldn't save: ${msg}` }) as string);
},
});
const deleteMutation = useMutation({
mutationFn: () => customerAdminService.deleteHourEntry(item.customerAccountId, item.id),
onSuccess: async () => {
toast.success(t('calendar.hourEntry.deleted', 'Hours deleted.'));
await refetchAll();
onMutated();
},
onError: (err: unknown) => {
const msg = err instanceof Error ? err.message : String(err);
toast.error(t('calendar.hourEntry.deleteFailed', { message: msg, defaultValue: `Couldn't delete: ${msg}` }) as string);
},
});
const busy = updateMutation.isPending || deleteMutation.isPending;
const submit = (e?: React.FormEvent) => {
e?.preventDefault();
if (item.locked || busy) return;
updateMutation.mutate();
};
// I.6 — document-level Escape listener (same reasoning as the
// drag-create modal: focus is usually on FC's canvas when this
// popover opens, so the onKeyDown handler on the outer div never
// sees the keydown).
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape' && !busy) onClose();
};
document.addEventListener('keydown', onKey);
return () => document.removeEventListener('keydown', onKey);
}, [busy, onClose]);
return (
<div
role="dialog"
aria-modal="true"
className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 dark:bg-black/60 p-4"
onClick={(e) => {
if (e.target === e.currentTarget) onClose();
}}
>
<Card padding="lg" className="w-full max-w-md">
<div className="flex items-start justify-between mb-2">
<div>
<h2 className="font-semibold text-lg">
{item.customerName || t('calendar.hourEntry.untitledCustomer', 'Hours')}
</h2>
<p className="text-xs text-muted-theme">
{item.entryDate} · {item.startTime}{item.endTime}
</p>
</div>
{item.locked && (
<span
className="inline-flex items-center gap-1 text-[11px] px-2 py-0.5 rounded
bg-neutral-200 dark:bg-neutral-700 text-neutral-700 dark:text-neutral-200"
title={t('calendar.hourEntry.lockedTooltip',
'Already billed — Storno the invoice to edit.') as string}
>
<Lock className="w-3 h-3" aria-hidden />
{t('calendar.hourEntry.lockedBadge', 'Locked')}
</span>
)}
</div>
{item.locked ? (
// Read-only summary. We deliberately don't render any inputs
// here so the admin can't accidentally type into a locked
// entry. The invoice link is omitted for now — clicking
// through to the bill belongs on a follow-up commit.
<p className="text-sm text-muted-theme">
{item.description || t('calendar.hourEntry.noDescription', 'No description.')}
</p>
) : (
<form onSubmit={submit} className="space-y-3" id="hour-entry-edit-form">
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-sm font-medium mb-1">
{t('calendar.hourEntry.startLabel', 'Start')}
</label>
<Input
type="time"
step={900}
value={startTime}
onChange={(e) => setStartTime(e.target.value)}
/>
</div>
<div>
<label className="block text-sm font-medium mb-1">
{t('calendar.hourEntry.endLabel', 'End')}
</label>
<Input
type="time"
step={900}
value={endTime}
onChange={(e) => setEndTime(e.target.value)}
/>
</div>
</div>
<div>
<label className="block text-sm font-medium mb-1">
{t('calendar.hourEntry.descriptionLabel', 'Description (optional)')}
</label>
<Input
value={description}
onChange={(e) => setDescription(e.target.value)}
maxLength={1000}
/>
</div>
</form>
)}
<div className="mt-5 flex items-center justify-between gap-2">
{item.locked ? <span /> : (
<Button
variant="outline"
onClick={() => {
if (window.confirm(t('calendar.hourEntry.confirmDelete',
'Delete these logged hours? This cannot be undone.') as string)) {
deleteMutation.mutate();
}
}}
disabled={busy}
>
<Trash2 className="w-4 h-4 mr-1" aria-hidden />
{t('calendar.hourEntry.delete', 'Delete')}
</Button>
)}
<div className="flex items-center gap-2 ml-auto">
<Button type="button" variant="outline" onClick={onClose} disabled={busy}>
{t('calendar.hourEntry.close', 'Close')}
</Button>
{!item.locked && (
<Button type="submit" form="hour-entry-edit-form" disabled={busy}>
{updateMutation.isPending
? t('calendar.hourEntry.saving', 'Saving…')
: t('calendar.hourEntry.submit', 'Save')}
</Button>
)}
</div>
</div>
</Card>
</div>
);
};
@@ -0,0 +1,130 @@
/**
* /admin/clients/hours standalone time-logging surface.
*
* Lives below the Invoices sub-nav entry, gated by the `hoursLogging`
* master feature flag. Admin picks any customer that has the
* per-customer `feature_hours_logging` toggle on, then logs entries
* via the shared HoursSection component. For monthly-mode customers
* entries auto-append to the running monthly draft; for per-event
* customers a "Bill these hours" button mints a standalone invoice
* (both behaviours live inside HoursSection this page is just the
* customer picker on top).
*/
import React, { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useQuery } from '@tanstack/react-query';
import { Card } from '../../../components/common';
import { HoursSection } from '../../../components/admin/HoursSection';
import {
CustomerPicker,
type CustomerSummary,
} from '../../../components/admin/CustomerPicker';
import {
customerAdminService,
type CustomerAccountDetail,
} from '../../../services/customerAdmin.service';
export const HoursLoggingPage: React.FC = () => {
const { t } = useTranslation();
// Picker state mirrors the contract from CustomerPicker — parent owns
// the id + display label + passive flag triple. This makes the page
// consistent with the Quote / Bill / Contract editors (#9) instead of
// the old plain <select> that scrolled through ALL customers.
const [selectedId, setSelectedId] = useState<number | null>(null);
const [customerLabel, setCustomerLabel] = useState('');
const [customerIsPassive, setCustomerIsPassive] = useState(false);
// Tracks the picked customer's hour-logging eligibility so the
// HoursSection only renders for eligible customers (matches the
// calendar drag-create modal's guard).
const [customerHoursAllowed, setCustomerHoursAllowed] = useState(true);
// Fetch the full customer record once a selection is made — needed
// for billingCadence + hourlyRateMinor.
const { data: selectedDetail } = useQuery({
queryKey: ['admin-customer', selectedId],
queryFn: () => customerAdminService.get(selectedId as number),
enabled: !!selectedId,
});
return (
<div className="container py-6 space-y-6">
<div className="flex items-center justify-between">
<div>
<div className="flex items-center gap-2">
<h1 className="text-2xl font-bold text-theme">
{t('hoursLogging.title', 'Hours logging')}
</h1>
{/* Beta badge matches Customers + Quotes + Contracts +
Invoices so the whole /admin/clients tab reads as one
product. */}
<span
className="text-[10px] uppercase tracking-wider px-1.5 py-0.5 rounded font-semibold bg-amber-100 text-amber-800 dark:bg-amber-900/40 dark:text-amber-300"
title="Beta — feature is functional but still evolving"
>
{t('navigation.betaTag', 'Beta')}
</span>
</div>
<p className="text-sm text-muted-theme mt-1">
{t('hoursLogging.subtitle',
'Pick a customer and log billable time blocks. Entries flow into the next monthly bill or are billed on demand for per-event customers.')}
</p>
</div>
</div>
<Card padding="lg">
<label className="block text-sm font-medium text-theme mb-2">
{t('hoursLogging.pickCustomer', 'Customer')}
</label>
<CustomerPicker
value={selectedId}
label={customerLabel}
isPassive={customerIsPassive}
requireFeature="hoursLogging"
onSelect={(c: CustomerSummary) => {
setSelectedId(c.id);
setCustomerLabel(
c.companyName
|| [c.firstName, c.lastName].filter(Boolean).join(' ')
|| c.displayName
|| c.email
|| `#${c.id}`,
);
setCustomerIsPassive(Boolean(c.isPassive));
setCustomerHoursAllowed(c.featureHoursLogging !== false);
}}
onCreate={(c: CustomerAccountDetail) => {
setSelectedId(c.id);
setCustomerLabel(c.companyName || c.displayName || c.email || `#${c.id}`);
setCustomerIsPassive(Boolean(c.isPassive));
setCustomerHoursAllowed(c.featureHoursLogging !== false);
}}
onClear={() => {
setSelectedId(null);
setCustomerLabel('');
setCustomerIsPassive(false);
setCustomerHoursAllowed(true);
}}
searchPlaceholder={t('hoursLogging.searchPlaceholder',
'Search by email or company…') as string}
/>
{selectedId && !customerHoursAllowed && (
<p className="mt-2 text-xs text-amber-700 dark:text-amber-300">
{t('hoursLogging.customerLoggingDisabled',
"This customer has hour logging disabled. Enable it on the customer's detail page to log hours.")}
</p>
)}
</Card>
{selectedId && customerHoursAllowed && (
<HoursSection
customerId={selectedId}
customerHourlyRateMinor={selectedDetail?.hourlyRateMinor ?? null}
billingCadence={(selectedDetail?.billingCadence as any) || 'per_event'}
/>
)}
</div>
);
};
export default HoursLoggingPage;
@@ -0,0 +1,449 @@
/**
* CRM Tax / Steuer Report sub-page.
*
* Period-scoped revenue listing for tax filing. Filters live in the
* URL? No they live in component state for now (kept simple). Three
* actions on the page:
*
* 1. Filter: period preset + currency react-query refetch
* 2. Export PDF (landscape A4, company letterhead)
* 3. Export CSV (RFC 4180, accountant-friendly)
*
* The table shows cancelled invoices in grey + a "Storniert" badge so
* the invoice-number sequence stays gap-free (DE/CH/AT audit trail
* requirement). Their amounts are visible but excluded from the
* totals card on the right.
*/
import React, { useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useQuery } from '@tanstack/react-query';
import { Calculator, Download, FileDown, AlertCircle } from 'lucide-react';
import { Button, Card, Loading, Input } from '../../../components/common';
// Lightweight native select styled to match Input — the common barrel
// doesn't export a Select component, and the form pieces here are
// small enough that a plain styled <select> is the right call.
const selectClassName =
'w-full rounded-md border border-neutral-300 dark:border-neutral-600 bg-white dark:bg-neutral-800 px-3 py-2 text-sm text-neutral-900 dark:text-neutral-100 focus:outline-none focus:ring-2 focus:ring-primary-500';
import { taxReportService, type TaxReportParams } from '../../../services/taxReport.service';
import { useLocalizedDate } from '../../../hooks/useLocalizedDate';
import { toast } from 'react-toastify';
type PeriodPreset = 'thisYear' | 'lastYear' | 'thisQuarter' | 'lastQuarter' | 'custom';
function isoDate(d: Date): string {
// YYYY-MM-DD in local time. Tax reports are user-facing — a Swiss
// admin asking for "this quarter" means their local Q, not UTC.
const y = d.getFullYear();
const m = String(d.getMonth() + 1).padStart(2, '0');
const day = String(d.getDate()).padStart(2, '0');
return `${y}-${m}-${day}`;
}
function periodForPreset(preset: PeriodPreset, today = new Date()): { from: string; to: string } {
const y = today.getFullYear();
if (preset === 'thisYear') return { from: `${y}-01-01`, to: `${y}-12-31` };
if (preset === 'lastYear') return { from: `${y - 1}-01-01`, to: `${y - 1}-12-31` };
const quarter = Math.floor(today.getMonth() / 3); // 0..3
if (preset === 'thisQuarter') {
const startMonth = quarter * 3;
const endMonth = startMonth + 2;
const lastDay = new Date(y, endMonth + 1, 0).getDate();
return {
from: `${y}-${String(startMonth + 1).padStart(2, '0')}-01`,
to: `${y}-${String(endMonth + 1).padStart(2, '0')}-${String(lastDay).padStart(2, '0')}`,
};
}
// lastQuarter — handle Q1 rollover into previous year's Q4.
let lastQYear = y;
let lastQ = quarter - 1;
if (lastQ < 0) { lastQ = 3; lastQYear = y - 1; }
const startMonth = lastQ * 3;
const endMonth = startMonth + 2;
const lastDay = new Date(lastQYear, endMonth + 1, 0).getDate();
return {
from: `${lastQYear}-${String(startMonth + 1).padStart(2, '0')}-01`,
to: `${lastQYear}-${String(endMonth + 1).padStart(2, '0')}-${String(lastDay).padStart(2, '0')}`,
};
}
function formatMinor(minor: number, currency: string, locale: string): string {
return new Intl.NumberFormat(locale, {
style: 'currency', currency, minimumFractionDigits: 2, maximumFractionDigits: 2,
}).format((minor || 0) / 100);
}
function triggerBrowserDownload(url: string, filename: string) {
// Off-DOM anchor + click is the cross-browser idiom for blob
// downloads. The blob URL is revoked after a short delay so Safari
// has time to start the actual download (revoking too early breaks it).
const a = document.createElement('a');
a.href = url;
a.download = filename;
a.style.display = 'none';
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
setTimeout(() => URL.revokeObjectURL(url), 4000);
}
export const TaxReportPage: React.FC = () => {
const { t, i18n } = useTranslation();
const { format: fmtDate, dateInputLang } = useLocalizedDate();
const [preset, setPreset] = useState<PeriodPreset>('thisYear');
const initialPeriod = useMemo(() => periodForPreset('thisYear'), []);
const [from, setFrom] = useState(initialPeriod.from);
const [to, setTo] = useState(initialPeriod.to);
const [currency, setCurrency] = useState<string>('CHF');
const [isExporting, setIsExporting] = useState<'pdf' | 'csv' | null>(null);
const onPresetChange = (next: PeriodPreset) => {
setPreset(next);
if (next !== 'custom') {
const p = periodForPreset(next);
setFrom(p.from);
setTo(p.to);
}
};
const params: TaxReportParams = { from, to, currency, locale: i18n.language };
const { data: report, isLoading, isError, error, refetch } = useQuery({
queryKey: ['tax-report', params],
queryFn: () => taxReportService.getReport(params),
enabled: Boolean(from && to && currency),
// Tax data is slow-moving — no need to re-fetch on focus.
staleTime: 60_000,
refetchOnWindowFocus: false,
});
const handleExport = async (format: 'pdf' | 'csv') => {
setIsExporting(format);
try {
const { url, filename } = format === 'pdf'
? await taxReportService.downloadPdfUrl(params)
: await taxReportService.downloadCsvUrl(params);
triggerBrowserDownload(url, filename);
} catch (err) {
toast.error(t('taxReport.exportFailed', 'Export failed. Please try again.'));
// eslint-disable-next-line no-console
console.error(err);
} finally {
setIsExporting(null);
}
};
// Per maintainer: every CH/LI/DE/AT-based business writes 1'000.00
// regardless of document language, so we default to de-CH (the only
// Intl locale producing apostrophe thousands). Non-DACH operators
// can override later if needed.
const intlLocale = 'de-CH';
// Show the per-VAT-rate breakdown only when there are 2+ distinct
// rates in the period. With a single rate the breakdown is just a
// restatement of the grand totals — pure noise.
const showPerRateBreakdown = (report?.totalsByVatRate.length || 0) > 1;
const exportsDisabled = isLoading || isExporting !== null || !report || report.rows.length === 0;
return (
<div className="space-y-6">
{/* Top row filter card on the left (stacked rows, narrower
footprint), compact totals card on the right. Both cards sit
above the table so the table gets the full content width. */}
<div className="grid grid-cols-1 lg:grid-cols-[1fr_300px] gap-6 items-start">
{/* Filter card (left) */}
<Card padding="md">
<div className="flex items-start gap-3 mb-4">
<div className="w-10 h-10 rounded-lg bg-accent-soft text-on-accent-soft flex items-center justify-center flex-shrink-0">
<Calculator className="w-5 h-5" />
</div>
<div className="min-w-0">
<h1 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100">
{t('taxReport.title', 'Tax report')}
</h1>
<p className="text-sm text-neutral-600 dark:text-neutral-400 mt-0.5">
{t(
'taxReport.intro',
'Period-scoped revenue list with net + VAT breakdown grouped by VAT rate. Cancelled invoices stay visible for audit-trail continuity but are excluded from totals.',
)}
</p>
</div>
</div>
{/* Filters stacked vertically per the agreed layout:
Row 1: period preset (full width)
Row 2: from / to (side-by-side)
Row 3: currency (full width)
Row 4: export buttons (right-aligned) */}
<div className="space-y-3">
<div>
<label htmlFor="period-preset" className="block text-xs font-medium text-neutral-700 dark:text-neutral-300 mb-1">
{t('taxReport.filters.period', 'Period')}
</label>
<select
id="period-preset"
value={preset}
onChange={(e) => onPresetChange(e.target.value as PeriodPreset)}
className={selectClassName}
>
<option value="thisYear">{t('taxReport.filters.thisYear', 'This year')}</option>
<option value="lastYear">{t('taxReport.filters.lastYear', 'Last year')}</option>
<option value="thisQuarter">{t('taxReport.filters.thisQuarter', 'This quarter')}</option>
<option value="lastQuarter">{t('taxReport.filters.lastQuarter', 'Last quarter')}</option>
<option value="custom">{t('taxReport.filters.custom', 'Custom range')}</option>
</select>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label htmlFor="period-from" className="block text-xs font-medium text-neutral-700 dark:text-neutral-300 mb-1">
{t('taxReport.filters.from', 'From')}
</label>
<Input
id="period-from"
type="date"
lang={dateInputLang}
value={from}
onChange={(e) => { setFrom(e.target.value); setPreset('custom'); }}
/>
</div>
<div>
<label htmlFor="period-to" className="block text-xs font-medium text-neutral-700 dark:text-neutral-300 mb-1">
{t('taxReport.filters.to', 'To')}
</label>
<Input
id="period-to"
type="date"
lang={dateInputLang}
value={to}
onChange={(e) => { setTo(e.target.value); setPreset('custom'); }}
/>
</div>
</div>
<div>
<label htmlFor="currency" className="block text-xs font-medium text-neutral-700 dark:text-neutral-300 mb-1">
{t('taxReport.filters.currency', 'Currency')}
</label>
<select
id="currency"
value={currency}
onChange={(e) => setCurrency(e.target.value)}
className={selectClassName}
>
<option value="CHF">CHF</option>
<option value="EUR">EUR</option>
<option value="USD">USD</option>
<option value="GBP">GBP</option>
</select>
</div>
<div className="flex flex-wrap items-center justify-end gap-2 pt-1">
<Button
variant="outline"
onClick={() => handleExport('csv')}
disabled={exportsDisabled}
isLoading={isExporting === 'csv'}
leftIcon={<FileDown className="w-4 h-4" />}
>
{t('taxReport.exportCsv', 'Export CSV')}
</Button>
<Button
variant="primary"
onClick={() => handleExport('pdf')}
disabled={exportsDisabled}
isLoading={isExporting === 'pdf'}
leftIcon={<Download className="w-4 h-4" />}
>
{t('taxReport.exportPdf', 'Export PDF')}
</Button>
</div>
</div>
</Card>
{/* Totals card (right) compact summary. Headline is the
grand totals; the per-VAT-rate breakdown appears only when
there are 2+ rates in the period (otherwise it duplicates
the grand totals). Cancelled footnote at the bottom when
applicable. */}
{report && report.rows.length > 0 && (
<Card padding="md">
<div className="space-y-1.5 text-sm">
<div className="flex justify-between gap-3">
<span className="text-neutral-700 dark:text-neutral-300">{t('taxReport.grandTotalNet', 'Total net')}</span>
<span className="tabular-nums font-medium text-neutral-900 dark:text-neutral-100">
{formatMinor(report.grandTotalNet, report.currency, intlLocale)}
</span>
</div>
<div className="flex justify-between gap-3">
<span className="text-neutral-700 dark:text-neutral-300">{t('taxReport.grandTotalVat', 'Total VAT')}</span>
<span className="tabular-nums font-medium text-neutral-900 dark:text-neutral-100">
{formatMinor(report.grandTotalVat, report.currency, intlLocale)}
</span>
</div>
<div className="flex justify-between gap-3 pt-1.5 border-t border-neutral-200 dark:border-neutral-700">
<span className="font-semibold text-neutral-900 dark:text-neutral-100">{t('taxReport.grandTotalGross', 'Total gross')}</span>
<span className="tabular-nums font-semibold text-neutral-900 dark:text-neutral-100">
{formatMinor(report.grandTotal, report.currency, intlLocale)}
</span>
</div>
</div>
{showPerRateBreakdown && (
<div className="mt-4 pt-3 border-t border-neutral-200 dark:border-neutral-700">
<h2 className="text-xs font-semibold uppercase tracking-wider text-neutral-500 dark:text-neutral-400 mb-2">
{t('taxReport.totalsByVatRate', 'Totals by VAT rate')}
</h2>
<div className="space-y-2 text-sm">
{report.totalsByVatRate.map((b) => (
<div key={b.vatRate}>
<div className="text-xs text-neutral-500 dark:text-neutral-400">
{Number(b.vatRate).toFixed(1)}%
</div>
<div className="flex justify-between gap-3 tabular-nums">
<span className="text-neutral-700 dark:text-neutral-300">{t('taxReport.col.net', 'Net')}</span>
<span>{formatMinor(b.netMinor, report.currency, intlLocale)}</span>
</div>
<div className="flex justify-between gap-3 tabular-nums">
<span className="text-neutral-700 dark:text-neutral-300">{t('taxReport.col.vat', 'VAT')}</span>
<span>{formatMinor(b.vatMinor, report.currency, intlLocale)}</span>
</div>
</div>
))}
</div>
</div>
)}
{report.cancelledCount > 0 && (
<p className="mt-3 pt-3 border-t border-neutral-200 dark:border-neutral-700 text-xs text-neutral-500 dark:text-neutral-400">
{t('taxReport.cancelledFootnote', '{{count}} cancelled invoice(s) — amounts excluded from totals (shown for audit-trail continuity).', { count: report.cancelledCount })}
</p>
)}
</Card>
)}
</div>
{/* Results */}
{isLoading ? (
<Card padding="lg"><Loading /></Card>
) : isError ? (
<Card padding="lg">
<div className="flex items-start gap-3 text-amber-700 dark:text-amber-400">
<AlertCircle className="w-5 h-5 flex-shrink-0 mt-0.5" />
<div>
<p className="font-medium">{t('taxReport.errorTitle', 'Could not load tax report')}</p>
<p className="text-sm text-neutral-600 dark:text-neutral-400 mt-1">
{(error as Error)?.message || String(error)}
</p>
<Button variant="outline" size="sm" onClick={() => refetch()} className="mt-3">
{t('common.retry', 'Retry')}
</Button>
</div>
</div>
</Card>
) : !report || report.rows.length === 0 ? (
<Card padding="lg">
<p className="text-center text-sm text-neutral-600 dark:text-neutral-400">
{t('taxReport.empty', 'No invoices in this period.')}
</p>
</Card>
) : (
<>
{/* Table full width below the filter + totals row above.
The totals card now lives in the top-right of the page
header so this section is purely the invoice list. */}
<Card padding="none">
{/* Two nested wrappers: the OUTER clips the header row's
solid fill so the top corners stay rounded (matches
the Card's own rounded-xl). The INNER provides
horizontal scroll when the table is wider than the
viewport. Combining `overflow-hidden` + `overflow-x-auto`
on a single element would cancel the auto-scroll. */}
<div className="rounded-xl overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead className="bg-neutral-50 dark:bg-neutral-900 text-neutral-700 dark:text-neutral-300">
<tr>
<th className="px-2 py-2 text-right font-medium w-10">#</th>
<th className="px-2 py-2 text-left font-medium whitespace-nowrap">{t('taxReport.col.date', 'Date')}</th>
<th className="px-2 py-2 text-left font-medium whitespace-nowrap">{t('taxReport.col.invoice', 'Invoice')}</th>
<th className="px-2 py-2 text-left font-medium">{t('taxReport.col.customer', 'Customer')}</th>
<th className="px-2 py-2 text-left font-medium">{t('taxReport.col.event', 'Event')}</th>
<th className="px-2 py-2 text-right font-medium whitespace-nowrap">{t('taxReport.col.vatRate', 'VAT %')}</th>
<th className="px-2 py-2 text-right font-medium whitespace-nowrap">{t('taxReport.col.net', 'Net')}</th>
<th className="px-2 py-2 text-right font-medium whitespace-nowrap">{t('taxReport.col.vat', 'VAT')}</th>
<th className="px-2 py-2 text-right font-medium whitespace-nowrap">{t('taxReport.col.total', 'Gross')}</th>
<th className="px-2 py-2 text-right font-medium whitespace-nowrap">{t('taxReport.col.skonto', 'Skonto')}</th>
</tr>
</thead>
<tbody className="divide-y divide-neutral-200 dark:divide-neutral-800">
{report.rows.map((row, i) => (
<tr
key={row.id}
className={row.isCancelled
? 'text-neutral-400 dark:text-neutral-500 italic'
: 'text-neutral-900 dark:text-neutral-100'}
>
<td className="px-2 py-1.5 text-right tabular-nums">{i + 1}</td>
<td className="px-2 py-1.5 whitespace-nowrap tabular-nums">{fmtDate(row.issueDate.slice(0, 10))}</td>
<td className="px-2 py-1.5 whitespace-nowrap">
<span className="font-medium">{row.invoiceNumber}</span>
{row.isCancelled && (
<span className="ml-2 inline-block px-1.5 py-0.5 text-[10px] uppercase tracking-wider rounded bg-neutral-200 dark:bg-neutral-700 text-neutral-700 dark:text-neutral-300 font-semibold not-italic">
{t('taxReport.statusCancelled', 'Cancelled')}
</span>
)}
{/* Storno + Reissue lineage markers parity
with the admin invoices list so the same
colour scheme distinguishes the three row
kinds at a glance across both surfaces. */}
{row.kind === 'storno' && (
<span className="ml-2 inline-block px-1.5 py-0.5 text-[10px] uppercase tracking-wider rounded bg-purple-100 text-purple-800 font-semibold not-italic">
{t('bills.kind.storno', 'Storno')}
</span>
)}
{row.isReissue && (
<span className="ml-2 inline-block px-1.5 py-0.5 text-[10px] uppercase tracking-wider rounded bg-blue-100 text-blue-800 font-semibold not-italic">
{t('bills.kind.reissue', 'Reissue')}
</span>
)}
{row.replacedByInvoiceNumber && (
<span className="ml-1 text-xs text-neutral-500 dark:text-neutral-400 not-italic">
{row.replacedByInvoiceNumber}
</span>
)}
</td>
<td className="px-2 py-1.5 truncate max-w-[180px]" title={row.customerLabel}>{row.customerLabel}</td>
<td className="px-2 py-1.5 truncate max-w-[180px]" title={row.eventName}>{row.eventName}</td>
<td className="px-2 py-1.5 text-right tabular-nums whitespace-nowrap">{Number(row.vatRate).toFixed(1)}%</td>
<td className="px-2 py-1.5 text-right tabular-nums whitespace-nowrap">
{formatMinor(row.netMinor, row.currency, intlLocale)}
</td>
<td className="px-2 py-1.5 text-right tabular-nums whitespace-nowrap">
{formatMinor(row.vatMinor, row.currency, intlLocale)}
</td>
<td className="px-2 py-1.5 text-right tabular-nums whitespace-nowrap font-medium">
{formatMinor(row.totalMinor, row.currency, intlLocale)}
</td>
<td className="px-2 py-1.5 text-right tabular-nums whitespace-nowrap"
title={row.skontoApplied
? t('taxReport.skontoTooltip', 'Paid with Skonto') as string
: undefined}>
{row.skontoApplied ? (
<span className="text-teal-700 dark:text-teal-300">
{formatMinor(row.skontoAmountMinor, row.currency, intlLocale)}
</span>
) : ''}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</Card>
</>
)}
</div>
);
};
@@ -0,0 +1,535 @@
/**
* Admin Contract block library.
*
* Two-column layout intentionally mirroring EmailConfigPage's Templates
* tab so admins navigate one shape across all template-style editors:
* - Left: sticky sidebar with sections (basics / scope / privacy /
* commercial / nda / closing) as uppercase headings, blocks listed
* below each, "+ New block" button at the top. Selected block
* uses `tile-selected`; non-selected blocks use the same
* `bg-neutral-50 dark:bg-neutral-700` palette as the email tabs.
* Inactive blocks render `opacity-50`.
* - Right: edit panel with language tabs (EN/DE/RU/PT/NL/FR) at the
* top, then Section + Name + Description + Body fields. Active
* toggle + Save + Delete actions. New-block mode renders the same
* form with empty values and an enabled Section dropdown.
*
* Schema: contract_blocks carries body_text + body_text_<locale> for
* each of the six locales (migration 130 seeded EN+DE; migration 131
* added RU/PT/NL/FR as nullable). Translation completeness shown in
* the sidebar pill (e.g. "3/6") matches the email-templates display.
*/
import React, { useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Link } from 'react-router-dom';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { toast } from 'react-toastify';
import { ArrowLeft, Plus, Trash2, Save } from 'lucide-react';
import { Button, Card, Loading } from '../../../components/common';
import { SUPPORTED_LANGUAGES } from '../../../components/common/LanguageSelector';
import {
contractsService,
type ContractBlock,
type ContractBlockSection,
CONTRACT_SECTIONS,
} from '../../../services/contracts.service';
// Locale → block column mapping. Keys mirror SUPPORTED_LANGUAGES.code;
// values are the ContractBlock field names. Used to resolve which body
// to show / save when the language tab changes.
const BODY_FIELD_BY_LOCALE: Record<string, keyof ContractBlock> = {
en: 'bodyText',
de: 'bodyTextDe',
ru: 'bodyTextRu',
pt: 'bodyTextPt',
nl: 'bodyTextNl',
fr: 'bodyTextFr',
};
/** Payload-key version of the same map what the create/update API
* expects on POST/PUT. EN uses `bodyText`, others use `bodyText<Lang>`. */
const PAYLOAD_KEY_BY_LOCALE: Record<string, string> = {
en: 'bodyText',
de: 'bodyTextDe',
ru: 'bodyTextRu',
pt: 'bodyTextPt',
nl: 'bodyTextNl',
fr: 'bodyTextFr',
};
/** Selection state either editing an existing block or composing
* a new one. Null = empty right panel (initial state). */
type Selection =
| { mode: 'edit'; block: ContractBlock }
| { mode: 'new' }
| null;
export const BlockLibraryPage: React.FC = () => {
const { t } = useTranslation();
const queryClient = useQueryClient();
const [selection, setSelection] = useState<Selection>(null);
const [editingLang, setEditingLang] = useState<string>('en');
// The block library is the admin's authoring surface — inactive
// blocks are always shown so admins can find blocks they previously
// deactivated (the "Inactive" badge keeps the state obvious). The
// toggle below now lets admins HIDE inactive blocks if they want a
// cleaner view, but defaults to false so nothing disappears.
const [hideInactive, setHideInactive] = useState(false);
// Local form state mirrors the active selection. Reset whenever the
// selection changes (via the useEffect below) so editing one block,
// switching to another, doesn't leak unsaved changes.
const [section, setSection] = useState<ContractBlockSection>('basics');
const [name, setName] = useState('');
const [description, setDescription] = useState('');
const [bodies, setBodies] = useState<Record<string, string>>({});
const { data, isLoading } = useQuery({
queryKey: ['contracts', 'blocks', { hideInactive }],
// Always fetch every block; client-side filter is purely for
// visual hide. Keeps the cache consistent across toggle flips
// and means the new-block flow can land in any section without
// re-fetching.
queryFn: () => contractsService.listBlocks({ includeInactive: true }),
});
const blocks = data?.blocks || [];
// Auto-select the first block on initial load so the right panel
// isn't empty by default — matches the email templates UX.
useEffect(() => {
if (selection === null && blocks.length > 0) {
setSelection({ mode: 'edit', block: blocks[0] });
}
}, [blocks, selection]);
// Sync local form state to the selection. On mode='new', clear
// everything. On mode='edit', copy fields from the block.
useEffect(() => {
if (selection?.mode === 'edit') {
const b = selection.block;
setSection(b.section);
setName(b.name);
setDescription(b.description || '');
setBodies({
en: b.bodyText || '',
de: b.bodyTextDe || '',
ru: b.bodyTextRu || '',
pt: b.bodyTextPt || '',
nl: b.bodyTextNl || '',
fr: b.bodyTextFr || '',
});
} else if (selection?.mode === 'new') {
setSection('basics');
setName('');
setDescription('');
setBodies({ en: '', de: '', ru: '', pt: '', nl: '', fr: '' });
}
setEditingLang('en');
}, [selection]);
const createMutation = useMutation({
mutationFn: (payload: any) => contractsService.createBlock(payload),
onSuccess: (res) => {
toast.success(t('contracts.blocks.createdToast', 'Block created.') as string);
queryClient.invalidateQueries({ queryKey: ['contracts', 'blocks'] });
if (res.block) setSelection({ mode: 'edit', block: res.block });
},
onError: (err: any) => toast.error(err?.response?.data?.error || t('contracts.blocks.createError', 'Create failed') as string),
});
const updateMutation = useMutation({
mutationFn: ({ id, payload }: { id: number; payload: any }) => contractsService.updateBlock(id, payload),
onSuccess: (res) => {
toast.success(t('contracts.blocks.updatedToast', 'Block updated.') as string);
queryClient.invalidateQueries({ queryKey: ['contracts', 'blocks'] });
if (res.block) setSelection({ mode: 'edit', block: res.block });
},
onError: (err: any) => toast.error(err?.response?.data?.error || t('contracts.blocks.updateError', 'Update failed') as string),
});
const deleteMutation = useMutation({
mutationFn: (id: number) => contractsService.deleteBlock(id),
onSuccess: () => {
toast.success(t('contracts.blocks.deletedToast', 'Block deleted.') as string);
queryClient.invalidateQueries({ queryKey: ['contracts', 'blocks'] });
setSelection(null);
},
onError: (err: any) => toast.error(err?.response?.data?.error || t('contracts.blocks.deleteError', 'Delete failed') as string),
});
// Group blocks by section for sidebar rendering. Empty sections are
// skipped so the sidebar doesn't show stub headings. Inactive blocks
// included by default; hideInactive=true filters them out for admins
// who want a cleaner view (the selection survives the filter — if
// the selected block becomes hidden, the right panel still shows it
// so the admin can re-activate without re-finding it).
const grouped = useMemo(() => {
const g: Record<ContractBlockSection, ContractBlock[]> = {
basics: [], scope: [], privacy: [], commercial: [], nda: [], closing: [],
};
for (const b of blocks) {
if (hideInactive && !b.isActive) continue;
g[b.section]?.push(b);
}
return g;
}, [blocks, hideInactive]);
// Count populated bodies for the sidebar pill (e.g. "3/6" = three
// locales have body text, three are blank). Mirrors the email
// templates' translationCount badge.
const translationCount = (b: ContractBlock) =>
SUPPORTED_LANGUAGES.filter((lang) => {
const field = BODY_FIELD_BY_LOCALE[lang.code];
return field && !!(b[field] as string | null | undefined)?.toString().trim();
}).length;
const buildPayload = () => {
const payload: Record<string, any> = {
section,
name: name.trim(),
description: description.trim() || null,
isActive: selection?.mode === 'edit' ? selection.block.isActive : true,
};
for (const lang of SUPPORTED_LANGUAGES) {
const key = PAYLOAD_KEY_BY_LOCALE[lang.code];
const val = (bodies[lang.code] || '').trim();
// EN is required; other locales are sent only when non-empty
// so clearing a textarea reliably nulls the column.
if (lang.code === 'en') {
payload[key] = bodies.en;
} else {
payload[key] = val ? bodies[lang.code] : null;
}
}
return payload;
};
const canSave = !!name.trim() && !!(bodies.en || '').trim();
const isPending = createMutation.isPending || updateMutation.isPending;
const handleSave = () => {
if (!canSave) return;
const payload = buildPayload();
if (selection?.mode === 'edit') {
updateMutation.mutate({ id: selection.block.id, payload });
} else if (selection?.mode === 'new') {
createMutation.mutate(payload);
}
};
const handleToggleActive = () => {
if (selection?.mode !== 'edit') return;
updateMutation.mutate({
id: selection.block.id,
payload: { isActive: !selection.block.isActive },
});
};
const currentBody = bodies[editingLang] || '';
const setCurrentBody = (v: string) => setBodies((prev) => ({ ...prev, [editingLang]: v }));
return (
// No `container py-6` wrapper — this page is rendered inside the
// System Settings shell, which already provides outer padding +
// its own width. The email-templates tab next door uses the same
// bare layout; adding our own container made the contracts grid
// measurably narrower than the email grid.
<div>
<div className="mb-4 flex items-center gap-3 flex-wrap">
<Link
to="/admin/clients/contracts"
className="inline-flex items-center gap-1 text-sm text-neutral-600 dark:text-neutral-400 hover:text-accent-dark"
>
<ArrowLeft className="w-4 h-4" />
{t('contracts.blocks.back', 'Back to contracts')}
</Link>
<h1 className="text-2xl font-bold flex-1 text-theme">
{t('contracts.blocks.title', 'Contract block library')}
</h1>
<label className="flex items-center gap-2 text-sm text-muted-theme">
<input type="checkbox" checked={hideInactive} onChange={(e) => setHideInactive(e.target.checked)} />
{t('contracts.blocks.hideInactive', 'Hide inactive')}
</label>
</div>
{/* Disclaimer banner kept; the seeded blocks come with a legal
disclaimer per the maintainer's "legal/financial defaults are
examples only" rule. */}
<div className="mb-4 p-3 rounded-md border border-amber-200 dark:border-amber-800 bg-amber-50 dark:bg-amber-950/30 text-sm text-amber-900 dark:text-amber-200">
<p className="font-medium mb-1">
{t('contracts.blocks.disclaimerTitle', 'Examples only — have your lawyer review')}
</p>
<p className="text-xs">
{t(
'contracts.blocks.disclaimerBody',
'The seeded "System" blocks are written by the picpeak maintainer, not by a lawyer. They are intended as starting points only — review and adapt every block you intend to send with your own lawyer. Edits to system blocks are persisted; replace the seeded body text with the lawyer-reviewed version in place.',
)}
</p>
</div>
{isLoading ? <Loading /> : (
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* Sidebar same shape as EmailConfigPage templates sidebar:
Card padding="sm" + h3 + +New button up top, then a
section-grouped list of block tiles. */}
<Card padding="sm">
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100">
{t('contracts.blocks.sidebarHeading', 'Blocks')}
</h3>
<Button
variant="primary"
size="sm"
onClick={() => setSelection({ mode: 'new' })}
leftIcon={<Plus className="w-4 h-4" />}
>
{t('contracts.blocks.new', 'New block')}
</Button>
</div>
<div className="space-y-5">
{CONTRACT_SECTIONS.map((sec) => {
const items = grouped[sec];
if (!items || items.length === 0) return null;
return (
<div key={sec}>
<h4 className="px-1 mb-2 text-[11px] font-semibold uppercase tracking-wider text-neutral-500 dark:text-neutral-400">
{t(`contracts.sections.${sec}`, sec)}
</h4>
<div className="space-y-2">
{items.map((b) => {
const isSelected = selection?.mode === 'edit' && selection.block.id === b.id;
const count = translationCount(b);
return (
<button
key={b.id}
onClick={() => setSelection({ mode: 'edit', block: b })}
className={`w-full text-left p-3 rounded-lg transition-colors ${
isSelected
? 'tile-selected'
: 'bg-neutral-50 dark:bg-neutral-700 border-2 border-transparent hover:bg-neutral-100 dark:hover:bg-neutral-600'
} ${!b.isActive ? 'opacity-50' : ''}`}
>
<div className="flex items-center justify-between gap-2">
<p className="font-medium text-neutral-900 dark:text-neutral-100 truncate">
{b.name}
</p>
<div className="flex items-center gap-1.5 flex-shrink-0">
{b.isSystem && (
<span
className="text-[10px] uppercase tracking-wider px-1.5 py-0.5 rounded font-semibold bg-neutral-200 dark:bg-neutral-600 text-neutral-700 dark:text-neutral-300"
title={t('contracts.blocks.systemBadge', 'System') as string}
>
{t('contracts.blocks.systemBadge', 'System')}
</span>
)}
<span className="text-xs px-2 py-0.5 rounded-full bg-neutral-200 dark:bg-neutral-600 text-neutral-600 dark:text-neutral-300">
{count}/{SUPPORTED_LANGUAGES.length}
</span>
</div>
</div>
{b.description && (
<p className="text-sm text-neutral-500 dark:text-neutral-400 mt-1 truncate">
{b.description}
</p>
)}
</button>
);
})}
</div>
</div>
);
})}
{blocks.length === 0 && (
<p className="text-center text-sm text-muted-theme py-6">
{t('contracts.blocks.empty', 'No blocks yet.')}
</p>
)}
</div>
</Card>
{/* Right panel — edit / create form. lg:col-span-2 matches the
email-templates layout exactly. */}
<div className="lg:col-span-2">
{selection === null ? (
<Card padding="md">
<p className="text-center text-muted-theme py-8">
{t('contracts.blocks.selectPrompt', 'Select a block on the left or create a new one to start editing.')}
</p>
</Card>
) : (
<Card padding="md">
<div className="flex items-center justify-between mb-4 flex-wrap gap-2">
<h3 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100">
{selection.mode === 'new'
? t('contracts.blocks.dialog.createTitle', 'New block')
: t('contracts.blocks.dialog.editTitle', 'Edit block')}
</h3>
<div className="flex gap-2 items-center flex-wrap">
{selection.mode === 'edit' && (
<label className="flex items-center gap-2 text-sm text-muted-theme mr-2">
<input
type="checkbox"
checked={selection.block.isActive}
onChange={handleToggleActive}
/>
{selection.block.isActive
? t('contracts.blocks.active', 'Active')
: t('contracts.blocks.inactive', 'Inactive')}
</label>
)}
{selection.mode === 'edit' && !selection.block.isSystem && (
<Button
variant="outline"
size="sm"
onClick={() => {
if (window.confirm(t('contracts.blocks.deleteConfirm', 'Delete this block?') as string)) {
deleteMutation.mutate(selection.block.id);
}
}}
leftIcon={<Trash2 className="w-4 h-4" />}
>
{t('contracts.blocks.delete', 'Delete')}
</Button>
)}
<Button
variant="primary"
size="sm"
onClick={handleSave}
isLoading={isPending}
disabled={!canSave}
leftIcon={<Save className="w-4 h-4" />}
>
{t('contracts.blocks.save', 'Save')}
</Button>
</div>
</div>
{/* Language tabs — identical pill row to EmailConfigPage. */}
<div className="flex flex-wrap gap-1 mb-4 p-1 bg-neutral-100 dark:bg-neutral-700 rounded-lg">
{SUPPORTED_LANGUAGES.map((lang) => {
const filled = !!(bodies[lang.code] || '').trim();
return (
<button
key={lang.code}
onClick={() => setEditingLang(lang.code)}
className={`px-3 py-1.5 text-sm font-medium rounded-md transition-colors flex items-center gap-1.5 ${
editingLang === lang.code
? 'bg-white dark:bg-neutral-800 text-accent-dark shadow-sm'
: 'text-neutral-600 dark:text-neutral-400 hover:text-neutral-800 dark:hover:text-neutral-200'
}`}
>
<lang.Flag />
<span>{lang.name}</span>
{!filled && lang.code !== 'en' && (
<span
className="w-1.5 h-1.5 rounded-full bg-amber-400"
title={t('contracts.blocks.noTranslation', 'No translation yet') as string}
/>
)}
</button>
);
})}
</div>
<div className="space-y-4">
{/* Name above Section, stacked vertically Name is
the primary identifier and reads best at full
width; Section is a one-of-six dropdown that
doesn't need to share row space. Section stays
locked when editing a system block. */}
<div>
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">
{t('contracts.blocks.dialog.name', 'Name')}
</label>
<input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
className="w-full px-3 py-2 rounded-md border border-neutral-300 dark:border-neutral-600 bg-white dark:bg-neutral-800 text-sm"
/>
</div>
<div>
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">
{t('contracts.blocks.dialog.section', 'Section')}
</label>
<select
value={section}
onChange={(e) => setSection(e.target.value as ContractBlockSection)}
disabled={selection.mode === 'edit' && selection.block.isSystem}
className="w-full px-3 py-2 rounded-md border border-neutral-300 dark:border-neutral-600 bg-white dark:bg-neutral-800 text-sm disabled:opacity-50"
>
{CONTRACT_SECTIONS.map((s) => (
<option key={s} value={s}>{t(`contracts.sections.${s}`, s)}</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">
{t('contracts.blocks.dialog.description', 'Description (admin hint)')}
</label>
<input
type="text"
value={description}
onChange={(e) => setDescription(e.target.value)}
className="w-full px-3 py-2 rounded-md border border-neutral-300 dark:border-neutral-600 bg-white dark:bg-neutral-800 text-sm"
/>
</div>
<div>
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">
{t('contracts.blocks.dialog.body', 'Body')} ({SUPPORTED_LANGUAGES.find((l) => l.code === editingLang)?.name || editingLang})
</label>
<textarea
rows={14}
value={currentBody}
onChange={(e) => setCurrentBody(e.target.value)}
className="w-full px-3 py-2 rounded-md border border-neutral-300 dark:border-neutral-600 bg-white dark:bg-neutral-800 text-sm font-mono"
/>
</div>
{/* Special-block preview: quote_line_items_table
generates an actual PDF table from the source
quote's line items at render time. The block's
body text is just the intro paragraph above the
table; without this callout the admin had no way
to know the table existed (it appears in the PDF
but nowhere in the block editor). */}
{selection.mode === 'edit' && selection.block.slug === 'quote_line_items_table' && (
<div className="rounded-md border border-blue-200 dark:border-blue-800 bg-blue-50 dark:bg-blue-950/30 p-3 text-sm text-blue-900 dark:text-blue-200">
<p className="font-medium mb-1">
{t('contracts.blocks.quoteLineItems.calloutTitle',
'Auto-generated table follows the body')}
</p>
<p className="text-xs">
{t('contracts.blocks.quoteLineItems.calloutBody',
'When this block is included in a contract that was created from a quote, the PDF inserts a real table of the source quote\'s line items (#, Description, Qty, Unit, Total) immediately after the body text above. Sub-items render indented with a ↳ marker. Contracts without a source quote skip the table and render only the body.')}
</p>
<p className="text-xs mt-2 opacity-80">
{t('contracts.blocks.quoteLineItems.previewExample',
'Example rendered output:')}
</p>
<pre className="mt-1 text-[11px] font-mono bg-white/50 dark:bg-neutral-900/40 rounded p-2 overflow-x-auto">
{`# Description Qty Unit Total
1 Photography session 1 CHF 800 CHF 800
2 Photo prints 2 CHF 15 CHF 30
Extra retouching 1 CHF 20 CHF 20`}
</pre>
</div>
)}
<p className="text-xs text-neutral-500 dark:text-neutral-400">
{t(
'contracts.blocks.dialog.placeholderHint',
'You can use {{customer_name}}, {{event_name}}, {{event_date}}, {{net_days}}, {{skonto_percent}}, {{skonto_within_days}}, {{cancellation_30d_percent}}, {{currency}}, {{issuer_company_name}}, {{issuer_address}}, {{contract_number}}, {{source_quote_number}} as placeholders — substituted when the contract is rendered.',
)}
</p>
</div>
</Card>
)}
</div>
</div>
)}
</div>
);
};
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,535 @@
/**
* Admin Contract editor.
*
* Two modes:
* - /admin/clients/contracts/new create a fresh draft after
* picking a customer (server seeds inclusions from active system
* blocks).
* - /admin/clients/contracts/:id/edit edit an existing draft
* (scalars + block on/off + within-section ordering).
*
* Sent contracts can't be edited (locked at the service layer); admin
* cancels + creates a fresh one for amendments.
*/
import React, { useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useNavigate, useParams, Link } from 'react-router-dom';
import { useQuery, useMutation } from '@tanstack/react-query';
import { toast } from 'react-toastify';
import { ArrowLeft, Eye, Save } from 'lucide-react';
import { Button, Card, Input, Loading } from '../../../components/common';
import {
contractsService,
type ContractBlockSection,
CONTRACT_SECTIONS,
} from '../../../services/contracts.service';
import { CustomerPicker } from '../../../components/admin/CustomerPicker';
import { useLocalizedDate } from '../../../hooks/useLocalizedDate';
interface BlockRow {
blockId: number;
section: ContractBlockSection;
name: string;
description: string | null;
isSystem: boolean;
included: boolean;
position: number;
}
export const ContractEditorPage: React.FC = () => {
const { t } = useTranslation();
const { timeFormat } = useLocalizedDate();
const timeInputLang = timeFormat === '12h' ? 'en-US' : 'de-DE';
const { id } = useParams<{ id?: string }>();
const navigate = useNavigate();
const isEdit = Boolean(id);
const numericId = id ? parseInt(id, 10) : null;
const [customerAccountId, setCustomerAccountId] = useState<number | null>(null);
// Customer label + passive flag mirror the QuoteEditorPage chip so
// the admin sees the real name (company / first+last / display name)
// and a "Passive — admin only" badge when the customer has no
// portal access. Without these the chip would just say "#3".
const [customerLabel, setCustomerLabel] = useState('');
const [customerIsPassive, setCustomerIsPassive] = useState(false);
// Customer search state moved into <CustomerPicker> (C.5).
const [title, setTitle] = useState('');
// Event snapshot fields. Mirror the quote editor so the same
// "Wedding Doe / Müller" label flows quote → contract → invoice.
// Standalone contracts (no source quote) set these directly here.
const [eventName, setEventName] = useState('');
const [eventDate, setEventDate] = useState('');
const [eventTimeStart, setEventTimeStart] = useState('');
const [eventTimeEnd, setEventTimeEnd] = useState('');
const [introText, setIntroText] = useState('');
const [outroText, setOutroText] = useState('');
const [language, setLanguage] = useState('de');
const [issueDate, setIssueDate] = useState(() => new Date().toISOString().slice(0, 10));
const [validUntil, setValidUntil] = useState('');
const [blocks, setBlocks] = useState<BlockRow[]>([]);
// Load existing contract on edit.
const { data: existing, isLoading: existingLoading } = useQuery({
queryKey: ['contract', numericId],
queryFn: () => contractsService.get(numericId as number),
enabled: isEdit && numericId !== null,
});
// For new contracts, fetch every active block in the library so the
// admin can opt-IN to non-system blocks (system blocks are pre-toggled
// on by the server on create). We still show them all here for parity
// with the edit flow.
const { data: blockLibrary } = useQuery({
queryKey: ['contracts', 'blocks-library'],
queryFn: () => contractsService.listBlocks({ includeInactive: false }),
});
// Customer search moved into <CustomerPicker> (C.5).
// Hydrate state from server when the existing contract loads.
useEffect(() => {
if (!existing) return;
const c = existing.contract;
setCustomerAccountId(c.customerAccountId);
setCustomerLabel(
c.customer.companyName
|| [c.customer.firstName, c.customer.lastName].filter(Boolean).join(' ')
|| c.customer.displayName
|| c.customer.email
|| `#${c.customerAccountId}`,
);
// Backend transformContract doesn't currently surface isPassive
// for contracts. Default to false; the chip just won't show the
// badge in that case. (Quote/Bill detail compute this via the
// customer.password_hash join — wire later if needed.)
setCustomerIsPassive(false);
setTitle(c.title || '');
setEventName(c.eventName || '');
setEventDate(c.eventDate || '');
setEventTimeStart(c.eventTimeStart || '');
setEventTimeEnd(c.eventTimeEnd || '');
setIntroText(c.introText || '');
setOutroText(c.outroText || '');
setLanguage(c.language || 'de');
setIssueDate(c.issueDate);
setValidUntil(c.validUntil || '');
setBlocks((c.inclusions || []).map((inc) => ({
blockId: inc.blockId,
section: inc.section,
name: inc.block?.name || `Block ${inc.blockId}`,
description: inc.block?.description ?? null,
isSystem: inc.block?.isSystem === true,
included: inc.included,
position: inc.position,
})));
}, [existing]);
// When creating new (no existing contract loaded yet), seed blocks
// state from the library once it arrives so the admin can preview
// the inclusion list before saving. The server will replace these
// positions deterministically on create, but the UI shape matches.
useEffect(() => {
if (isEdit || !blockLibrary) return;
if (blocks.length > 0) return;
const rows: BlockRow[] = blockLibrary.blocks.map((b) => ({
blockId: b.id,
section: b.section,
name: b.name,
description: b.description,
isSystem: b.isSystem,
included: b.isSystem, // system blocks toggled on by default
position: b.displayOrder,
}));
setBlocks(rows);
}, [blockLibrary, isEdit, blocks.length]);
const blocksBySection: Record<ContractBlockSection, BlockRow[]> = useMemo(() => {
const out: Record<ContractBlockSection, BlockRow[]> = {
basics: [], scope: [], privacy: [], commercial: [], nda: [], closing: [],
};
for (const b of blocks) out[b.section]?.push(b);
for (const k of Object.keys(out) as ContractBlockSection[]) {
out[k].sort((a, b) => a.position - b.position);
}
return out;
}, [blocks]);
function toggleBlock(blockId: number) {
setBlocks((cur) => cur.map((b) => b.blockId === blockId ? { ...b, included: !b.included } : b));
}
function moveBlock(blockId: number, delta: -1 | 1) {
setBlocks((cur) => {
const idx = cur.findIndex((b) => b.blockId === blockId);
if (idx < 0) return cur;
const target = cur[idx];
const siblings = cur.filter((b) => b.section === target.section).sort((a, b) => a.position - b.position);
const sib = siblings.findIndex((b) => b.blockId === blockId);
const next = sib + delta;
if (next < 0 || next >= siblings.length) return cur;
const swap = siblings[next];
return cur.map((b) => {
if (b.blockId === target.blockId) return { ...b, position: swap.position };
if (b.blockId === swap.blockId) return { ...b, position: target.position };
return b;
});
});
}
const createMutation = useMutation({
mutationFn: async () => {
if (!customerAccountId) throw new Error('Pick a customer first');
const created = await contractsService.create({
customerAccountId,
language,
title: title || null,
eventName: eventName || null,
eventDate: eventDate || null,
eventTimeStart: eventTimeStart || null,
eventTimeEnd: eventTimeEnd || null,
introText: introText || null,
outroText: outroText || null,
issueDate,
validUntil: validUntil || undefined,
});
// Apply block toggles + ordering as an update right after create.
await contractsService.update(created.contract.id, {
blocks: blocks.map((b) => ({
blockId: b.blockId, included: b.included, position: b.position,
})),
});
return created.contract;
},
onSuccess: (created) => {
toast.success(t('contracts.editor.createdToast', 'Contract created.') as string);
navigate(`/admin/clients/contracts/${created.id}`);
},
onError: (err: any) => {
toast.error(err?.response?.data?.error || err?.message || t('contracts.editor.saveError', 'Save failed') as string);
},
});
const updateMutation = useMutation({
mutationFn: async () => {
if (!numericId) return;
await contractsService.update(numericId, {
title: title || null,
eventName: eventName || null,
eventDate: eventDate || null,
eventTimeStart: eventTimeStart || null,
eventTimeEnd: eventTimeEnd || null,
introText: introText || null,
outroText: outroText || null,
language,
issueDate,
validUntil: validUntil || undefined,
blocks: blocks.map((b) => ({
blockId: b.blockId, included: b.included, position: b.position,
})),
});
},
onSuccess: () => {
toast.success(t('contracts.editor.savedToast', 'Contract saved.') as string);
navigate(`/admin/clients/contracts/${numericId}`);
},
onError: (err: any) => {
toast.error(err?.response?.data?.error || err?.message || t('contracts.editor.saveError', 'Save failed') as string);
},
});
async function handlePreview() {
// For new contracts, we'd need to create first to preview; keep it
// simple — disable preview before save.
if (!isEdit || !numericId) {
toast.info(t('contracts.editor.previewAfterSave', 'Save the draft first, then preview.') as string);
return;
}
// Sync-open the placeholder window BEFORE any await so the popup
// blocker treats this as a user gesture, then redirect once the
// blob URL is ready. Same pattern bills/quotes use.
const previewWindow = window.open('about:blank', '_blank');
if (!previewWindow) {
toast.error(t('contracts.editor.popupBlocked', 'Allow pop-ups for this site to preview the PDF.') as string);
return;
}
try {
const url = await contractsService.previewPdfUrl(numericId);
previewWindow.location.href = url;
} catch (err: any) {
previewWindow.close();
toast.error(err?.response?.data?.error || t('contracts.editor.previewError', 'Preview failed') as string);
}
}
if (isEdit && existingLoading) return <Loading />;
if (isEdit && existing && existing.contract.status !== 'draft') {
return (
<Card padding="lg">
<p className="text-sm text-amber-700 dark:text-amber-300">
{t('contracts.editor.locked', 'Sent contracts cannot be edited. Cancel and create a fresh one for amendments.')}
</p>
<div className="mt-3">
<Link to={`/admin/clients/contracts/${numericId}`} className="text-accent-dark hover:underline">
{t('contracts.editor.backToDetail', 'Back to contract')}
</Link>
</div>
</Card>
);
}
return (
<div>
<div className="mb-4 flex items-center gap-3">
<Link
to="/admin/clients/contracts"
className="inline-flex items-center gap-1 text-sm text-neutral-600 dark:text-neutral-400 hover:text-accent-dark"
>
<ArrowLeft className="w-4 h-4" />
{t('contracts.editor.back', 'Back to list')}
</Link>
<h1 className="text-2xl font-bold flex-1">
{isEdit
? t('contracts.editor.titleEdit', 'Edit contract')
: t('contracts.editor.titleNew', 'New contract')}
</h1>
{isEdit && (
<Button variant="outline" onClick={handlePreview}>
<Eye className="w-4 h-4 mr-1" />
{t('contracts.editor.preview', 'Preview PDF')}
</Button>
)}
<Button
onClick={() => isEdit ? updateMutation.mutate() : createMutation.mutate()}
disabled={
(createMutation.isPending || updateMutation.isPending)
|| (!isEdit && !customerAccountId)
}
>
<Save className="w-4 h-4 mr-1" />
{isEdit
? t('contracts.editor.save', 'Save')
: t('contracts.editor.create', 'Create draft')}
</Button>
</div>
{/* Scalars */}
<Card padding="lg" className="mb-4">
{!isEdit && (
<div className="mb-4">
<label className="block text-sm font-medium mb-1">
{t('contracts.editor.customer', 'Customer')}
</label>
<CustomerPicker
value={customerAccountId}
label={customerLabel}
isPassive={customerIsPassive}
onSelect={(c) => {
setCustomerAccountId(c.id);
setCustomerLabel(
c.companyName
|| [c.firstName, c.lastName].filter(Boolean).join(' ')
|| c.displayName
|| c.email
|| `#${c.id}`,
);
setCustomerIsPassive(Boolean(c.isPassive));
}}
onCreate={(c) => {
setCustomerAccountId(c.id);
setCustomerLabel(
c.companyName
|| [c.firstName, c.lastName].filter(Boolean).join(' ')
|| c.displayName
|| c.email
|| `#${c.id}`,
);
setCustomerIsPassive(Boolean(c.isPassive));
}}
onClear={() => { setCustomerAccountId(null); setCustomerLabel(''); setCustomerIsPassive(false); }}
searchPlaceholder={t('contracts.editor.searchCustomer', 'Search by email…') as string}
/>
</div>
)}
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
<div>
<label className="block text-sm font-medium mb-1">
{t('contracts.editor.titleField', 'Contract title')}
</label>
<Input
type="text"
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder={t('contracts.editor.titlePlaceholder', 'e.g. Wedding contract Doe / Müller') as string}
/>
</div>
<div>
<label className="block text-sm font-medium mb-1">
{t('contracts.editor.language', 'Language')}
</label>
<select
value={language}
onChange={(e) => setLanguage(e.target.value)}
className="w-full px-3 py-2 rounded-md border border-neutral-300 dark:border-neutral-600 bg-white dark:bg-neutral-800 text-sm"
>
<option value="de">Deutsch</option>
<option value="en">English</option>
</select>
</div>
<div>
<label className="block text-sm font-medium mb-1">
{t('contracts.editor.issueDate', 'Issue date')}
</label>
<Input type="date" value={issueDate} onChange={(e) => setIssueDate(e.target.value)} />
</div>
<div>
<label className="block text-sm font-medium mb-1">
{t('contracts.editor.validUntil', 'Sign by (optional)')}
</label>
<Input type="date" value={validUntil} onChange={(e) => setValidUntil(e.target.value)} />
</div>
</div>
{/* Event snapshot fields. Match the quote editor so the chain
quote contract invoice carries the same labels. When
createFromQuote drafts a contract from an accepted quote
these come prefilled from the quote. */}
<div className="mt-4 pt-4 border-t border-neutral-200 dark:border-neutral-700">
<h3 className="text-sm font-semibold mb-2">
{t('contracts.editor.eventSection', 'Event (optional)')}
</h3>
<p className="text-xs text-neutral-500 mb-3">
{t('contracts.editor.eventHelp',
'Snapshotted onto the contract and propagated to any event / invoice generated from it. Set this so the customer portal and dunning emails show the right "Wedding Doe / Müller" label.')}
</p>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
<div className="md:col-span-2">
<label className="block text-sm font-medium mb-1">
{t('contracts.editor.eventName', 'Event name')}
</label>
<Input
type="text"
value={eventName}
onChange={(e) => setEventName(e.target.value)}
placeholder={t('contracts.editor.eventNamePlaceholder',
'e.g. Wedding Doe / Müller') as string}
/>
</div>
<div>
<label className="block text-sm font-medium mb-1">
{t('contracts.editor.eventDate', 'Event date')}
</label>
<Input type="date" value={eventDate} onChange={(e) => setEventDate(e.target.value)} />
</div>
<div className="grid grid-cols-2 gap-2">
<div>
<label className="block text-sm font-medium mb-1">
{t('contracts.editor.eventTimeStart', 'Start')}
</label>
<Input type="time" lang={timeInputLang} value={eventTimeStart} onChange={(e) => setEventTimeStart(e.target.value)} />
</div>
<div>
<label className="block text-sm font-medium mb-1">
{t('contracts.editor.eventTimeEnd', 'End')}
</label>
<Input type="time" lang={timeInputLang} value={eventTimeEnd} onChange={(e) => setEventTimeEnd(e.target.value)} />
</div>
</div>
</div>
</div>
<div className="mt-3">
<label className="block text-sm font-medium mb-1">
{t('contracts.editor.intro', 'Intro text (optional)')}
</label>
<textarea
value={introText}
onChange={(e) => setIntroText(e.target.value)}
rows={3}
className="w-full px-3 py-2 rounded-md border border-neutral-300 dark:border-neutral-600 bg-white dark:bg-neutral-800 text-sm"
/>
</div>
<div className="mt-3">
<label className="block text-sm font-medium mb-1">
{t('contracts.editor.outro', 'Closing text (optional)')}
</label>
<textarea
value={outroText}
onChange={(e) => setOutroText(e.target.value)}
rows={2}
className="w-full px-3 py-2 rounded-md border border-neutral-300 dark:border-neutral-600 bg-white dark:bg-neutral-800 text-sm"
/>
</div>
</Card>
{/* Disclaimer banner */}
<div className="mb-4 p-3 rounded-md border border-amber-200 dark:border-amber-800 bg-amber-50 dark:bg-amber-950/30 text-sm text-amber-900 dark:text-amber-200">
<p className="font-medium mb-1">
{t('contracts.editor.disclaimerTitle', 'Lawyer review required')}
</p>
<p className="text-xs">
{t('contracts.editor.disclaimerBody', 'The seeded block bodies are EXAMPLES ONLY — written by the maintainer, not by a lawyer. Have your own lawyer review every block you include before sending the contract. See docs/crm-disclaimers.md.')}
</p>
<p className="text-xs mt-2 pt-2 border-t border-amber-200/60 dark:border-amber-800/60">
{t('contracts.editor.schriftformWarning',
'Signature type: simple electronic signature (SES). Sufficient for routine photography contracts in CH / DE / AT / FL. NOT sufficient for documents that legally require Schriftform / form qualifiée: Bürgschaft (DE § 766 BGB), Verbraucherdarlehensvertrag (DE § 492 BGB), befristete Arbeitsverträge (DE § 14 Abs. 4 TzBfG), and similar. For those, a qualified electronic signature (QES) from a Trust Service Provider is required — picpeak does not provide QES.')}
</p>
</div>
{/* Block accordions */}
{CONTRACT_SECTIONS.map((section) => (
<Card key={section} padding="lg" className="mb-3">
<h2 className="text-lg font-semibold mb-2">
{t(`contracts.sections.${section}`, section)}
</h2>
{blocksBySection[section].length === 0 ? (
<p className="text-sm text-neutral-500">
{t('contracts.editor.noBlocksInSection', 'No blocks for this section yet.')}
</p>
) : (
<ul className="space-y-2">
{blocksBySection[section].map((b) => (
<li
key={b.blockId}
className="flex items-start gap-3 p-2 rounded border border-neutral-200 dark:border-neutral-700"
>
<input
type="checkbox"
className="mt-1"
checked={b.included}
onChange={() => toggleBlock(b.blockId)}
/>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<span className="font-medium text-sm">{b.name}</span>
{b.isSystem && (
<span className="text-[10px] uppercase tracking-wide px-1.5 py-0.5 rounded bg-neutral-200 dark:bg-neutral-700">
{t('contracts.editor.systemBadge', 'System')}
</span>
)}
</div>
{b.description && (
<p className="text-xs text-neutral-500 mt-1">{b.description}</p>
)}
</div>
<div className="flex flex-col gap-1">
<button
type="button"
className="px-2 py-0.5 text-xs rounded border border-neutral-300 dark:border-neutral-600"
onClick={() => moveBlock(b.blockId, -1)}
></button>
<button
type="button"
className="px-2 py-0.5 text-xs rounded border border-neutral-300 dark:border-neutral-600"
onClick={() => moveBlock(b.blockId, 1)}
></button>
</div>
</li>
))}
</ul>
)}
</Card>
))}
</div>
);
};
@@ -0,0 +1,202 @@
/**
* Admin Contracts list page.
*
* Lists every contract (filterable by status + customer + search) with
* a "New contract" button and a "Block library" shortcut. Visual
* shape mirrors BillsListPage / QuotesListPage so the three lists
* under /admin/clients feel like one product same container wrapper,
* same h1 + BETA badge, same Button component for actions, same table
* head + row-hover + click-row-to-open behaviour. Contract-specific
* columns (Number, Customer, Title, Issued, Status) and the per-status
* badge palette are preserved.
*/
import React, { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Link, useNavigate } from 'react-router-dom';
import { useQuery } from '@tanstack/react-query';
import { Plus, Search, BookOpen } from 'lucide-react';
import { Button, Card, Loading } from '../../../components/common';
import {
contractsService,
type ContractStatus,
type ContractSort,
} from '../../../services/contracts.service';
import { useLocalizedDate } from '../../../hooks/useLocalizedDate';
const STATUSES: ContractStatus[] = [
'draft', 'sent', 'signed_by_customer', 'signed_by_admin', 'fully_signed', 'cancelled',
];
export const ContractsListPage: React.FC = () => {
const { t } = useTranslation();
const navigate = useNavigate();
const { format } = useLocalizedDate();
const [search, setSearch] = useState('');
const [statusFilter, setStatusFilter] = useState<ContractStatus[]>([]);
const [sort, setSort] = useState<ContractSort>('newest');
const [page, setPage] = useState(1);
const { data, isLoading } = useQuery({
queryKey: ['contracts', { search, statusFilter, sort, page }],
queryFn: () => contractsService.list({
q: search || undefined,
status: statusFilter.length ? statusFilter : undefined,
sort, page, pageSize: 25,
}),
});
const toggleStatus = (s: ContractStatus) => {
setStatusFilter((prev) => prev.includes(s) ? prev.filter((x) => x !== s) : [...prev, s]);
setPage(1);
};
const totalPages = data ? Math.max(1, Math.ceil(data.total / data.pageSize)) : 1;
return (
<div className="container py-6">
<div className="flex items-center justify-between mb-6">
<div>
<div className="flex items-center gap-2">
<h1 className="text-2xl font-bold text-theme">{t('contracts.title', 'Contracts')}</h1>
{/* Beta badge — matches Customers + Quotes + Invoices. */}
<span
className="text-[10px] uppercase tracking-wider px-1.5 py-0.5 rounded font-semibold bg-amber-100 text-amber-800 dark:bg-amber-900/40 dark:text-amber-300"
title="Beta — feature is functional but still evolving"
>
{t('navigation.betaTag', 'Beta')}
</span>
</div>
<p className="text-sm text-muted-theme mt-1">
{t('contracts.subtitle', 'Compose contracts from reusable blocks and have customers sign in-browser or upload a wet-signed PDF.')}
</p>
</div>
<div className="flex gap-2">
<Link to="/admin/clients/contracts/blocks">
<Button variant="outline">
<BookOpen className="w-4 h-4 mr-1" />
{t('contracts.list.blocksLibrary', 'Block library')}
</Button>
</Link>
<Link to="/admin/clients/contracts/new">
<Button>
<Plus className="w-4 h-4 mr-1" />
{t('contracts.list.new', 'New contract')}
</Button>
</Link>
</div>
</div>
<Card padding="lg">
<div className="flex flex-wrap items-center gap-3">
<div className="relative flex-1 min-w-[200px]">
<Search className="w-4 h-4 absolute left-3 top-1/2 -translate-y-1/2 text-neutral-400" />
<input
type="text"
placeholder={t('contracts.list.searchPlaceholder', 'Search by number, title or customer…') as string}
className="w-full pl-9 pr-3 py-2 rounded-md border border-neutral-300 dark:border-neutral-600 bg-white dark:bg-neutral-800 text-sm"
value={search}
onChange={(e) => { setSearch(e.target.value); setPage(1); }}
/>
</div>
<select
className="px-3 py-2 rounded-md border border-neutral-300 dark:border-neutral-600 bg-white dark:bg-neutral-800 text-sm"
value={sort}
onChange={(e) => setSort(e.target.value as ContractSort)}
>
<option value="newest">{t('contracts.list.sort.newest', 'Newest first')}</option>
<option value="oldest">{t('contracts.list.sort.oldest', 'Oldest first')}</option>
<option value="customer_asc">{t('contracts.list.sort.customer', 'Customer A→Z')}</option>
</select>
</div>
<div className="mt-3 flex flex-wrap gap-1">
{STATUSES.map((s) => {
const active = statusFilter.includes(s);
return (
<button key={s} type="button" onClick={() => toggleStatus(s)}
className={`px-2.5 py-1 rounded-full text-xs font-medium border transition-colors ${
active
? 'bg-accent-dark text-white border-accent-dark'
: 'bg-white dark:bg-neutral-800 text-neutral-700 dark:text-neutral-300 border-neutral-300 dark:border-neutral-600'
}`}
>{t(`contracts.status.${s}`, s)}</button>
);
})}
</div>
{/* Body of the same card table or empty state. Matches the
single-card layout used by Customers / Quotes / Invoices. */}
<div className="mt-4">
{isLoading ? <Loading /> : !data || data.contracts.length === 0 ? (
<p className="text-center text-muted-theme py-8">{t('contracts.list.empty', 'No contracts yet.')}</p>
) : (
<div className="rounded-lg border border-neutral-200 dark:border-neutral-700 overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead className="bg-neutral-50 dark:bg-neutral-800 text-neutral-700 dark:text-neutral-300">
<tr>
<th className="px-3 py-2 text-left">{t('contracts.list.table.number', 'Number')}</th>
<th className="px-3 py-2 text-left">{t('contracts.list.table.customer', 'Customer')}</th>
<th className="px-3 py-2 text-left">{t('contracts.list.table.title', 'Title')}</th>
<th className="px-3 py-2 text-left">{t('contracts.list.table.issueDate', 'Issued')}</th>
<th className="px-3 py-2 text-left">{t('contracts.list.table.status', 'Status')}</th>
</tr>
</thead>
<tbody>
{data.contracts.map((c) => (
<tr key={c.id}
className="border-t border-neutral-200 dark:border-neutral-700 cursor-pointer hover:bg-neutral-50 dark:hover:bg-neutral-800"
onClick={() => navigate(`/admin/clients/contracts/${c.id}`)}
>
<td className="px-3 py-2 font-mono text-xs">{c.contractNumber}</td>
<td className="px-3 py-2">
{c.customer.companyName
|| [c.customer.firstName, c.customer.lastName].filter(Boolean).join(' ')
|| c.customer.displayName
|| c.customer.email
|| '—'}
</td>
<td className="px-3 py-2 truncate max-w-xs">{c.title || '—'}</td>
<td className="px-3 py-2 whitespace-nowrap">{c.issueDate ? format(c.issueDate) : '—'}</td>
<td className="px-3 py-2">
{/* Per-status palette preserved green for the
terminal "fully_signed", blue for either-side
signed, amber for sent (awaiting customer),
grey for cancelled, neutral for draft. */}
<span className={`px-2 py-0.5 rounded text-xs font-medium ${
c.status === 'fully_signed' ? 'bg-green-100 text-green-800'
: c.status === 'signed_by_customer' || c.status === 'signed_by_admin' ? 'bg-blue-100 text-blue-800'
: c.status === 'sent' ? 'bg-amber-100 text-amber-800'
: c.status === 'cancelled' ? 'bg-neutral-200 text-neutral-600'
: 'bg-neutral-100 text-neutral-700'
}`}>{t(`contracts.status.${c.status}`, c.status)}</span>
</td>
</tr>
))}
</tbody>
</table>
</div>
{totalPages > 1 && (
<div className="flex justify-between items-center px-3 py-2 border-t border-neutral-200 dark:border-neutral-700 text-sm">
<span className="text-muted-theme">
{t('contracts.list.pagination', 'Page {{page}} of {{total}} · {{count}} contracts', {
page, total: totalPages, count: data.total,
})}
</span>
<div className="flex gap-2">
<Button variant="outline" size="sm" disabled={page <= 1} onClick={() => setPage((p) => Math.max(1, p - 1))}>
{t('common.previous', 'Previous')}
</Button>
<Button variant="outline" size="sm" disabled={page >= totalPages} onClick={() => setPage((p) => Math.min(totalPages, p + 1))}>
{t('common.next', 'Next')}
</Button>
</div>
</div>
)}
</div>
)}
</div>
</Card>
</div>
);
};
+11 -1
View File
@@ -15,4 +15,14 @@ export { UserManagementPage } from './UserManagementPage';
export { CustomerManagementPage } from './CustomerManagementPage';
export { CustomerDetailPage } from './CustomerDetailPage';
export { EventTypesPage } from './EventTypesPage';
export { WebhookDeliveriesPage } from './WebhookDeliveriesPage';
export { WebhookDeliveriesPage } from './WebhookDeliveriesPage';
// CRM (Quotes + Invoices) — gated by the `quotes` / `bills` feature flags.
export { QuotesListPage } from './quotes/QuotesListPage';
export { QuoteEditorPage } from './quotes/QuoteEditorPage';
export { QuoteDetailPage } from './quotes/QuoteDetailPage';
export { BillsListPage } from './bills/BillsListPage';
export { BillEditorPage } from './bills/BillEditorPage';
export { BillDetailPage } from './bills/BillDetailPage';
export { SettingsBusinessProfilePage } from './settings/SettingsBusinessProfilePage';
export { CrmSettingsPage } from './settings/CrmSettingsPage';
@@ -0,0 +1,267 @@
/**
* Quote detail (read + actions). Renders a summary of the quote plus
* action buttons: Preview PDF / Resend / Duplicate / Convert to event.
* Edit hops back to the editor.
*/
import React from 'react';
import { useTranslation } from 'react-i18next';
import { Link, useNavigate, useParams } from 'react-router-dom';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { ArrowLeft, Eye, Send, Copy, ArrowRightCircle, Edit2, Receipt, CheckCircle2, ScrollText } from 'lucide-react';
import { Button, Card, Loading } from '../../../components/common';
import { DocumentLineageCard } from '../../../components/admin/DocumentLineageCard';
import { quotesService } from '../../../services/quotes.service';
import { billsService } from '../../../services/bills.service';
import { formatMoney } from '../../../components/admin/LineItemsTable';
import { useLocalizedDate } from '../../../hooks/useLocalizedDate';
import { useFeatureFlags } from '../../../contexts/FeatureFlagsContext';
import { toast } from 'react-toastify';
export const QuoteDetailPage: React.FC = () => {
const { t } = useTranslation();
// H.4 / H.5 — hide the convert-to-{contract,invoice} buttons when
// the matching feature flag is off. Backend would refuse the convert
// anyway because the routes are gated, but rendering a button that
// 404s on click is bad UX.
const { flags } = useFeatureFlags();
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const qc = useQueryClient();
const { format: fmtDate, formatDateTime: fmtDateTime, formatTime: fmtTime } = useLocalizedDate();
const { data, isLoading } = useQuery({
queryKey: ['quote', id],
queryFn: () => quotesService.get(parseInt(id!, 10)),
enabled: !!id,
});
// Reciprocal lookup — pulls every invoice whose source_quote_id
if (isLoading || !data) return <Loading />;
const q = data.quote;
const handlePreview = async () => {
// Open the placeholder window synchronously so the browser sees a
// user-gesture-initiated popup; redirect to the blob URL once the
// PDF buffer is fetched. Without this the popup blocker kills it.
const previewWindow = window.open('about:blank', '_blank');
if (!previewWindow) {
toast.error(t('quotes.errors.popupBlocked', 'Allow pop-ups for this site to preview the PDF.'));
return;
}
try {
const url = await quotesService.pdfUrl(q.id);
previewWindow.location.href = url;
} catch (err: any) {
previewWindow.close();
toast.error(err?.response?.data?.error || err.message || 'Preview failed');
}
};
const handleSend = async () => {
if (!window.confirm(t('quotes.confirmSend', 'Send this quote to the customer now?'))) return;
try {
await quotesService.send(q.id);
toast.success(t('quotes.sentToast', 'Quote sent to customer.'));
qc.invalidateQueries({ queryKey: ['quote', id] });
} catch (err: any) {
toast.error(err?.response?.data?.error || 'Send failed');
}
};
const handleConvert = async () => {
if (!window.confirm(t('quotes.confirmConvert', 'Convert this accepted quote into an event + scheduled invoices?'))) return;
try {
const result = await quotesService.convert(q.id);
toast.success(t('quotes.convertedToast', 'Quote converted to event #{{id}}', { id: result.eventId }));
qc.invalidateQueries({ queryKey: ['quote', id] });
} catch (err: any) {
toast.error(err?.response?.data?.error || 'Convert failed');
}
};
const handleConvertToInvoice = async () => {
if (!window.confirm(t('quotes.confirmConvertToInvoice',
'Convert this quote into invoice(s) only? No gallery / event will be created.'))) return;
try {
const result = await quotesService.convertToInvoice(q.id);
toast.success(t('quotes.convertedToInvoiceToast',
'{{count}} invoice(s) created from this quote', { count: result.installmentsCreated }));
qc.invalidateQueries({ queryKey: ['quote', id] });
qc.invalidateQueries({ queryKey: ['invoices'] });
} catch (err: any) {
toast.error(err?.response?.data?.error || 'Convert failed');
}
};
const handleConvertToContract = async () => {
if (!window.confirm(t('quotes.confirmConvertToContract',
'Draft a contract from this quote? The customer + admin will both sign before event / invoice creation.'))) return;
try {
const result = await quotesService.convertToContract(q.id);
toast.success(result.alreadyConverted
? (t('quotes.contractAlreadyLinkedToast', 'A contract was already drafted from this quote.') as string)
: (t('quotes.convertedToContractToast', 'Contract drafted from this quote.') as string));
navigate(`/admin/clients/contracts/${result.contractId}`);
} catch (err: any) {
toast.error(err?.response?.data?.error || 'Convert failed');
}
};
/**
* Admin accept-on-behalf. Used when the customer verbally agrees
* on the phone admin flips the quote to accepted immediately so
* they can convert to an event/invoice without waiting for the
* customer to click the public response link.
*/
const handleAcceptOnBehalf = async () => {
if (!window.confirm(t('quotes.confirmAcceptOnBehalf',
'Mark this quote as accepted on behalf of the customer? Use only when they have verbally agreed (e.g. on the phone).'))) return;
try {
await quotesService.acceptOnBehalf(q.id);
toast.success(t('quotes.acceptedOnBehalfToast', 'Quote marked as accepted.'));
qc.invalidateQueries({ queryKey: ['quote', id] });
qc.invalidateQueries({ queryKey: ['quotes'] });
} catch (err: any) {
toast.error(err?.response?.data?.error || 'Accept failed');
}
};
const handleDuplicate = async () => {
try {
const result = await quotesService.duplicate(q.id);
navigate(`/admin/clients/quotes/${result.id}`);
} catch (err: any) {
toast.error(err?.response?.data?.error || 'Duplicate failed');
}
};
const responseLocked = q.responseLockedAt && new Date(q.responseLockedAt).getTime() < Date.now();
const canSend = ['draft', 'declined', 'expired'].includes(q.status);
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<div>
<button onClick={() => navigate('/admin/clients/quotes')}
className="text-sm text-neutral-600 dark:text-neutral-400 hover:underline mb-1 inline-flex items-center gap-1">
<ArrowLeft className="w-4 h-4" /> {t('common.back', 'Back')}
</button>
<h2 className="text-xl font-bold">
{q.quoteNumber} <span className="ml-2 text-xs font-medium px-2 py-0.5 rounded bg-neutral-100 text-neutral-700">{t(`quotes.status.${q.status}`, q.status)}</span>
</h2>
<p className="text-sm text-neutral-600 dark:text-neutral-400">
{q.customer.companyName || q.customer.displayName || q.customer.email}
</p>
</div>
<div className="flex gap-2 flex-wrap">
<Button variant="outline" onClick={handlePreview}><Eye className="w-4 h-4 mr-1" />{t('common.preview', 'Preview')}</Button>
<Button variant="outline" onClick={() => navigate(`/admin/clients/quotes/${q.id}/edit`)}>
<Edit2 className="w-4 h-4 mr-1" />{t('common.edit', 'Edit')}
</Button>
<Button variant="outline" onClick={handleDuplicate}><Copy className="w-4 h-4 mr-1" />{t('common.duplicate', 'Duplicate')}</Button>
{canSend && <Button onClick={handleSend}><Send className="w-4 h-4 mr-1" />{q.status === 'draft' ? t('quotes.send', 'Send') : t('quotes.resend', 'Resend')}</Button>}
{/* Accept-on-behalf shown while the quote is in a state
that hasn't been responded to yet (draft / sent /
expired). Hidden once accepted / declined / converted. */}
{['draft', 'sent', 'expired'].includes(q.status) && (
<Button variant="outline" onClick={handleAcceptOnBehalf}>
<CheckCircle2 className="w-4 h-4 mr-1" />
{t('quotes.acceptOnBehalf', 'Accept on behalf')}
</Button>
)}
{q.status === 'accepted' && (
<>
<Button onClick={handleConvert}>
<ArrowRightCircle className="w-4 h-4 mr-1" />{t('quotes.convert', 'Convert to event')}
</Button>
{/* Direct convert-to-invoice for engagements without a
photo deliverable (consulting, hire, etc). Hidden
once converted to either an event or to invoices.
H.5 also hidden when the `bills` feature is off. */}
{flags.bills && (
<Button variant="outline" onClick={handleConvertToInvoice}>
<Receipt className="w-4 h-4 mr-1" />{t('quotes.convertToInvoice', 'Convert to invoice only')}
</Button>
)}
{/* Convert to contract drafts a contract from this
quote, leaves the quote 'accepted' so the contract is
the active deliverable. After both parties sign, the
contract detail page exposes its own convert-to-event
/ convert-to-invoice buttons.
H.4 hidden when the `contracts` feature is off. */}
{flags.contracts && (
<Button variant="outline" onClick={handleConvertToContract}>
<ScrollText className="w-4 h-4 mr-1" />{t('quotes.convertToContract', 'Convert to contract')}
</Button>
)}
</>
)}
</div>
</div>
<Card>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 text-sm">
<div><div className="text-neutral-600 dark:text-neutral-300">{t('quotes.field.issueDate', 'Issued')}</div><div>{fmtDate(q.issueDate)}</div></div>
{q.validUntil && <div><div className="text-neutral-600 dark:text-neutral-300">{t('quotes.field.validUntil', 'Valid until')}</div><div>{fmtDate(q.validUntil)}</div></div>}
<div><div className="text-neutral-600 dark:text-neutral-300">{t('quotes.field.eventName', 'Event')}</div><div>{q.eventName || '—'}</div></div>
{q.eventDate && <div><div className="text-neutral-600 dark:text-neutral-300">{t('quotes.field.eventDate', 'Event date')}</div><div>{fmtDate(q.eventDate)}{q.eventTimeStart ? ` ${fmtTime(q.eventTimeStart)}-${q.eventTimeEnd ? fmtTime(q.eventTimeEnd) : ''}` : ''}</div></div>}
{q.sentAt && <div><div className="text-neutral-600 dark:text-neutral-300">{t('quotes.field.sentAt', 'Sent at')}</div><div>{fmtDateTime(q.sentAt)}</div></div>}
{q.acceptedAt && <div><div className="text-neutral-600 dark:text-neutral-300">{t('quotes.field.acceptedAt', 'Accepted at')}</div><div>{fmtDateTime(q.acceptedAt)}</div></div>}
{q.declinedAt && <div><div className="text-neutral-600 dark:text-neutral-300">{t('quotes.field.declinedAt', 'Declined at')}</div><div>{fmtDateTime(q.declinedAt)}</div></div>}
{q.respondedAt && !responseLocked && (
<div><div className="text-neutral-600 dark:text-neutral-300">{t('quotes.field.responseWindow', 'Response window')}</div>
<div className="text-amber-700">{t('quotes.responseWindowOpen', 'Open until {{at}}', { at: q.responseLockedAt ? fmtDateTime(q.responseLockedAt) : '' })}</div></div>
)}
</div>
</Card>
<Card>
<h3 className="font-semibold mb-3">{t('quotes.section.lineItems', 'Line items')}</h3>
<table className="w-full text-sm">
<thead><tr className="border-b border-neutral-200 dark:border-neutral-700">
<th className="text-left py-2">#</th>
<th className="text-left py-2">{t('crm.lineItems.quantity', 'Qty')}</th>
<th className="text-left py-2">{t('crm.lineItems.description', 'Description')}</th>
<th className="text-right py-2">{t('crm.lineItems.unitPrice', 'Unit')}</th>
<th className="text-right py-2">{t('crm.lineItems.total', 'Total')}</th>
</tr></thead>
<tbody>
{data.lineItems.map((li) => (
<tr key={li.id} className="border-b border-neutral-100 dark:border-neutral-800">
<td className="py-2">{li.position}</td>
<td className="py-2">{Number(li.quantity)}</td>
<td className="py-2 whitespace-pre-line">{li.description}</td>
<td className="py-2 text-right tabular-nums">{formatMoney(Number(li.unitPriceMinor || 0) / 100, q.currency)}</td>
<td className="py-2 text-right tabular-nums">{formatMoney(Number(li.lineTotalMinor || 0) / 100, q.currency)}</td>
</tr>
))}
</tbody>
</table>
<div className="flex flex-col items-end gap-1 mt-4 text-sm">
<div className="flex gap-6"><span className="text-neutral-600">{t('crm.lineItems.subtotal', 'Subtotal')}:</span>
<span className="tabular-nums w-28 text-right">{formatMoney(Number(q.netAmountMinor || 0) / 100, q.currency)}</span></div>
<div className="flex gap-6"><span className="text-neutral-600">{t('crm.lineItems.vat', 'VAT')} ({Number(q.vatRate || 0).toFixed(1)}%):</span>
<span className="tabular-nums w-28 text-right">{formatMoney(Number(q.vatAmountMinor || 0) / 100, q.currency)}</span></div>
<div className="flex gap-6 font-semibold text-base"><span>{t('crm.lineItems.total', 'Total')}:</span>
<span className="tabular-nums w-28 text-right">{formatMoney(Number(q.totalAmountMinor || 0) / 100, q.currency)}</span></div>
</div>
</Card>
{/* Cross-document lineage via deal_uuid (migration 140). One UUID
groups every quote / contract / invoice / Storno / reissue
for this engagement; admin sees the full chain in one card.
Replaces the previous per-FK LinkedDocumentsCard. */}
<DocumentLineageCard
dealUuid={q.dealUuid}
current={{ kind: 'quote', id: q.id }}
className="mt-4"
/>
{q.internalNotes && (
<Card>
<h3 className="font-semibold mb-2">{t('quotes.section.internalNotes', 'Internal notes')}</h3>
<p className="text-sm whitespace-pre-line text-neutral-700 dark:text-neutral-300">{q.internalNotes}</p>
</Card>
)}
</div>
);
};
@@ -0,0 +1,650 @@
/**
* Quote editor create or edit. Five sections:
* 1. Customer
* 2. Event data
* 3. Line items (LineItemsTable)
* 4. Payment conditions
* 5. Intro/outro + CC PDF email + internal notes
*
* Send is a deliberate two-step action (confirm dialog) since it emails
* the customer. Preview PDF is available before save (POST /preview)
* and after save (GET /:id/pdf) for the "what does my customer see?"
* check.
*/
import React, { useEffect, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useNavigate, useParams, useSearchParams } from 'react-router-dom';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { ArrowLeft, Eye, Send } from 'lucide-react';
import { Button, Card, Loading, Input } from '../../../components/common';
import {
quotesService,
type QuoteCreatePayload,
type PaymentTermInstallment,
} from '../../../services/quotes.service';
import { LineItemsTable, type EditableLineItem } from '../../../components/admin/LineItemsTable';
import { CustomerPicker } from '../../../components/admin/CustomerPicker';
import { InstallmentsPanel } from '../../../components/admin/InstallmentsPanel';
import { customerAdminService } from '../../../services/customerAdmin.service';
import { userManagementService } from '../../../services/userManagement.service';
import { settingsService } from '../../../services/settings.service';
import { useAdminAuth } from '../../../contexts/AdminAuthContext';
import { useLocalizedDate } from '../../../hooks/useLocalizedDate';
import { toast } from 'react-toastify';
interface FormState {
customerAccountId: number | null;
customerLabel: string;
/** Mirrors the customer.isPassive flag from the API. Drives the
* "Passive — admin only" badge next to the customer label. */
customerIsPassive: boolean;
language: string;
currency: string;
issueDate: string;
validUntil: string;
eventName: string;
eventDate: string;
eventTimeStart: string;
eventTimeEnd: string;
expectedDurationHours: string;
paymentTermTemplateId: number | null;
// Migration 124 — split payment-term picker. Both required when
// saving a new quote; legacy quotes may have these null but their
// status='sent' editor is locked anyway.
paymentNetDaysTemplateId: number | null;
paymentTimingTemplateId: number | null;
vatRate: number;
shippingAmount: number;
introText: string;
outroText: string;
internalNotes: string;
ccPdfEmail: string;
businessBankAccountId: number | null;
lineItems: EditableLineItem[];
// Ad-hoc installments (commit #6). null = use the payment-timing
// template's installments; array = explicit per-quote override.
installments?: import('../../../services/quotes.service').PaymentTermInstallment[] | null;
}
const empty: FormState = {
customerAccountId: null,
customerLabel: '',
customerIsPassive: false,
language: 'de',
currency: 'CHF',
issueDate: new Date().toISOString().slice(0, 10),
validUntil: '',
eventName: '',
eventDate: '',
eventTimeStart: '',
eventTimeEnd: '',
expectedDurationHours: '',
paymentTermTemplateId: null,
paymentNetDaysTemplateId: null,
paymentTimingTemplateId: null,
vatRate: 0,
shippingAmount: 0,
introText: '',
outroText: '',
internalNotes: '',
ccPdfEmail: '',
businessBankAccountId: null,
lineItems: [],
installments: null,
};
function toMinor(amount: number) {
return Math.round((Number(amount) || 0) * 100);
}
function buildPayload(f: FormState): QuoteCreatePayload {
return {
customerAccountId: f.customerAccountId || 0,
language: f.language,
currency: f.currency,
issueDate: f.issueDate,
validUntil: f.validUntil || undefined,
eventName: f.eventName || undefined,
eventDate: f.eventDate || undefined,
eventTimeStart: f.eventTimeStart || undefined,
eventTimeEnd: f.eventTimeEnd || undefined,
expectedDurationHours: f.expectedDurationHours ? Number(f.expectedDurationHours) : undefined,
paymentTermTemplateId: f.paymentTermTemplateId || undefined,
// Migration 124 — split picker. Send both; backend ignores either
// half unless both are set (legacy single FK still works).
paymentNetDaysTemplateId: f.paymentNetDaysTemplateId || undefined,
paymentTimingTemplateId: f.paymentTimingTemplateId || undefined,
// Ad-hoc installments (commit #6) — overrides the template's
// installments on the snapshot. Sent only when populated.
installments: f.installments && f.installments.length > 0 ? f.installments : undefined,
vatRate: f.vatRate,
shippingAmountMinor: toMinor(f.shippingAmount),
introText: f.introText || undefined,
outroText: f.outroText || undefined,
internalNotes: f.internalNotes || undefined,
ccPdfEmail: f.ccPdfEmail || undefined,
businessBankAccountId: f.businessBankAccountId || undefined,
lineItems: f.lineItems.map((li) => ({
position: li.position,
quantity: li.quantity,
description: li.description,
unitPriceMinor: toMinor(li.unitPrice),
discountPercent: li.discountPercent,
// Migration 119 — sub-items + details. Pass through to backend
// so the hierarchy survives save → reload.
parentPosition: li.parentPosition ?? null,
detailsText: li.detailsText || null,
})),
};
}
export const QuoteEditorPage: React.FC = () => {
const { t } = useTranslation();
const { timeFormat } = useLocalizedDate();
const timeInputLang = timeFormat === '12h' ? 'en-US' : 'de-DE';
const { id } = useParams<{ id?: string }>();
const [searchParams] = useSearchParams();
const navigate = useNavigate();
const queryClient = useQueryClient();
const isEdit = id && id !== 'new';
const [form, setForm] = useState<FormState>(empty);
const [installmentsValid, setInstallmentsValid] = useState(true);
const [busy, setBusy] = useState(false);
// Pre-fill the customer when the editor is opened from a customer
// detail page via `?customerAccountId=42`. Runs once on mount, only
// when creating a new quote, and skips if the user has already
// picked a customer.
const didPrefillCustomerRef = useRef(false);
useEffect(() => {
if (isEdit) return;
if (didPrefillCustomerRef.current) return;
const raw = searchParams.get('customerAccountId');
const cid = raw ? parseInt(raw, 10) : NaN;
if (!Number.isFinite(cid) || cid <= 0) return;
didPrefillCustomerRef.current = true;
(async () => {
try {
const c = await customerAdminService.get(cid);
setForm((prev) => prev.customerAccountId ? prev : ({
...prev,
customerAccountId: c.id,
customerLabel: c.companyName || c.displayName || c.email,
customerIsPassive: Boolean(c.isPassive),
// Inherit the customer's preferred language for the quote so
// the email + PDF land in the right locale automatically.
language: prev.language || c.preferredLanguage || 'de',
}));
} catch {
// Silent fail — admin can still pick the customer manually.
}
})();
}, [isEdit, searchParams]);
// Customer search + inline-create state now lives inside
// <CustomerPicker> (migration C.5 extraction).
// Load existing quote
const { data: existing, isLoading } = useQuery({
queryKey: ['quote', id],
queryFn: () => quotesService.get(parseInt(id!, 10)),
enabled: !!isEdit,
});
useEffect(() => {
if (existing) {
const q = existing.quote;
setForm({
customerAccountId: q.customerAccountId,
customerLabel: q.customer.companyName || q.customer.displayName || q.customer.email || '',
customerIsPassive: Boolean(q.customer.isPassive),
language: q.language,
currency: q.currency,
issueDate: q.issueDate,
validUntil: q.validUntil || '',
eventName: q.eventName || '',
eventDate: q.eventDate || '',
eventTimeStart: q.eventTimeStart || '',
eventTimeEnd: q.eventTimeEnd || '',
expectedDurationHours: q.expectedDurationHours?.toString() || '',
paymentTermTemplateId: q.paymentTermTemplateId,
paymentNetDaysTemplateId: q.paymentNetDaysTemplateId,
paymentTimingTemplateId: q.paymentTimingTemplateId,
vatRate: Number(q.vatRate || 0),
shippingAmount: Number(q.shippingAmountMinor || 0) / 100,
introText: q.introText || '',
outroText: q.outroText || '',
internalNotes: q.internalNotes || '',
ccPdfEmail: q.ccPdfEmail || '',
businessBankAccountId: q.businessBankAccountId,
lineItems: existing.lineItems.map((li) => ({
id: li.id,
position: li.position,
quantity: Number(li.quantity),
description: li.description,
unitPrice: Number(li.unitPriceMinor || 0) / 100,
discountPercent: Number(li.discountPercent || 0),
parentPosition: li.parentPosition ?? null,
detailsText: li.detailsText || '',
})),
});
}
}, [existing]);
// Customer autocomplete moved into <CustomerPicker> (C.5).
// Payment-term templates + line-item presets. Migration 124 split
// the legacy single dropdown into Net days + Timing — load both.
// The legacy ptTemplates query stays so the installment-preview
// helper still resolves for old quotes whose state only has the
// legacy FK.
const { data: ptTemplates } = useQuery({
queryKey: ['payment-term-templates'],
queryFn: () => quotesService.listPaymentTermTemplates(),
});
const { data: netDaysTemplates } = useQuery({
queryKey: ['payment-net-days-templates'],
queryFn: () => quotesService.listPaymentNetDaysTemplates(),
});
const { data: timingTemplates } = useQuery({
queryKey: ['payment-timing-templates'],
queryFn: () => quotesService.listPaymentTimingTemplates(),
});
const { data: liPresets } = useQuery({
queryKey: ['line-item-presets'],
queryFn: () => quotesService.listLineItemPresets(),
});
// Admin user list — used to pre-fill + offer a dropdown for the
// "CC PDF to" field, mirroring CreateEventPage's admin_email picker.
// Falls back to an empty list silently if the current user lacks
// users.view permission so basic admins still get the auto-prefill
// from the currently signed-in admin.
const { user: currentAdmin } = useAdminAuth();
const { data: adminUsers } = useQuery({
queryKey: ['admin-users-list'],
queryFn: async () => {
try { return await userManagementService.getUsers(); } catch { return []; }
},
staleTime: 5 * 60 * 1000,
retry: false,
});
const activeAdmins = useMemo(
() => (adminUsers || []).filter((u: any) => u.isActive !== false && !!u.email),
[adminUsers]
);
// Auto-prefill the CC PDF email with the current admin's email, only
// on a brand-new quote and only once. Don't clobber edits or existing
// values from a saved quote.
const didPrefillCcRef = useRef(false);
useEffect(() => {
if (isEdit) return;
if (didPrefillCcRef.current) return;
if (!currentAdmin?.email) return;
didPrefillCcRef.current = true;
setForm((prev) => (prev.ccPdfEmail ? prev : { ...prev, ccPdfEmail: currentAdmin.email }));
}, [currentAdmin?.email, isEdit]);
// Load the CRM defaults (migration 125 seeded; admin-editable on
// the CRM settings page). The prefill effect below prefers these
// over hardcoded fallbacks so the per-quote picker becomes a true
// override of an admin-configurable global default.
const { data: appSettings } = useQuery({
queryKey: ['app-settings'],
queryFn: () => settingsService.getAllSettings(),
enabled: !isEdit,
});
// Auto-default the two new pickers on a brand-new quote. Resolution
// order: settings-configured default → "Net 30" + first-timing
// hardcoded fallback (for fresh installs missing the seed) → first
// available template.
const didPrefillPaymentRef = useRef(false);
useEffect(() => {
if (isEdit) return;
if (didPrefillPaymentRef.current) return;
if (!netDaysTemplates?.templates?.length || !timingTemplates?.templates?.length) return;
// Wait for settings to load so the admin's configured default
// wins over the hardcoded fallback. Empty/missing setting → null.
if (appSettings === undefined) return;
const settingNetDaysId = appSettings?.crm_invoices_default_payment_net_days_template_id;
const settingTimingId = appSettings?.crm_invoices_default_payment_timing_template_id;
const settingNetDays = settingNetDaysId
? netDaysTemplates.templates.find((t) => t.id === settingNetDaysId)
: null;
const settingTiming = settingTimingId
? timingTemplates.templates.find((t) => t.id === settingTimingId)
: null;
const defaultNetDays = settingNetDays
|| netDaysTemplates.templates.find((t) => t.netDays === 30)
|| netDaysTemplates.templates[0];
const defaultTiming = settingTiming
|| timingTemplates.templates[0];
didPrefillPaymentRef.current = true;
setForm((prev) => ({
...prev,
paymentNetDaysTemplateId: prev.paymentNetDaysTemplateId ?? defaultNetDays.id,
paymentTimingTemplateId: prev.paymentTimingTemplateId ?? defaultTiming.id,
}));
}, [isEdit, netDaysTemplates, timingTemplates, appSettings]);
const installmentPreview = useMemo<PaymentTermInstallment[]>(() => {
// Migration 124 — preview now comes from the timing template when
// the new picker is in use. Fall back to the legacy template for
// quotes authored before the split so the preview stays useful.
const timingTpl = timingTemplates?.templates.find((x) => x.id === form.paymentTimingTemplateId);
if (timingTpl) return timingTpl.installments;
const tpl = ptTemplates?.templates.find((x) => x.id === form.paymentTermTemplateId);
return tpl?.installments || [];
}, [timingTemplates, form.paymentTimingTemplateId, ptTemplates, form.paymentTermTemplateId]);
const handleSave = async (then?: 'send' | 'preview') => {
if (!form.customerAccountId) {
toast.error(t('quotes.errors.customerRequired', 'Pick a customer first.'));
return;
}
// If the user asked to preview, confirm/cancel BEFORE async work so
// the popup opens directly off the click (browsers block popups
// that happen after an `await`). We open a blank window now and
// point it at the blob URL once it's ready.
const previewWindow = then === 'preview' ? window.open('about:blank', '_blank') : null;
if (then === 'preview' && !previewWindow) {
toast.error(t('quotes.errors.popupBlocked', 'Allow pop-ups for this site to preview the PDF.'));
return;
}
setBusy(true);
try {
const payload = buildPayload(form);
const saved = isEdit
? await quotesService.update(parseInt(id!, 10), payload)
: await quotesService.create(payload);
queryClient.invalidateQueries({ queryKey: ['quotes'] });
if (then === 'send') {
if (!window.confirm(t('quotes.confirmSend', 'Send this quote to the customer now?'))) {
setBusy(false);
return;
}
await quotesService.send(saved.quote.id);
toast.success(t('quotes.sentToast', 'Quote sent to customer.'));
navigate(`/admin/clients/quotes/${saved.quote.id}`);
} else if (then === 'preview') {
const url = await quotesService.pdfUrl(saved.quote.id);
if (previewWindow) previewWindow.location.href = url;
navigate(`/admin/clients/quotes/${saved.quote.id}`);
} else {
toast.success(t('quotes.savedToast', 'Quote saved as draft.'));
navigate(`/admin/clients/quotes/${saved.quote.id}`);
}
} catch (err: any) {
// Close the placeholder window if the save failed so it doesn't
// sit there showing "about:blank".
if (previewWindow) previewWindow.close();
const msg = err?.response?.data?.error || err.message || 'Save failed';
// Server returns a friendly code for the "customer feature off"
// case — surface a clearer message so admins know to flip the
// toggle on the customer detail page.
if (err?.response?.data?.code === 'CUSTOMER_FEATURE_DISABLED') {
toast.error(t('quotes.errors.customerFeatureDisabled',
'This customer has Quotes disabled. Enable "Quotes" on the customer detail page first.'));
} else if (err?.response?.data?.code === 'VALIDATION_ERROR' && Array.isArray(err?.response?.data?.details)) {
// Show the first field that failed validation so the admin
// knows what to fix instead of just seeing "Validation failed".
const first = err.response.data.details[0];
toast.error(`${first.field}: ${first.message}`);
} else {
toast.error(msg);
}
} finally {
setBusy(false);
}
};
const handlePreviewUnsaved = async () => {
if (!form.customerAccountId) {
toast.error(t('quotes.errors.customerRequired', 'Pick a customer first.'));
return;
}
// Open the placeholder window synchronously to preserve the user
// gesture, then redirect it to the blob URL once the PDF is ready.
// Without this the browser pop-up blocker eats the open() because
// it happens after an `await`.
const previewWindow = window.open('about:blank', '_blank');
if (!previewWindow) {
toast.error(t('quotes.errors.popupBlocked', 'Allow pop-ups for this site to preview the PDF.'));
return;
}
try {
const url = await quotesService.previewPdfUrl(buildPayload(form));
previewWindow.location.href = url;
} catch (err: any) {
previewWindow.close();
toast.error(err?.response?.data?.error || err.message || 'Preview failed');
}
};
if (isEdit && isLoading) return <Loading />;
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<div>
<button onClick={() => navigate('/admin/clients/quotes')}
className="text-sm text-neutral-600 dark:text-neutral-400 hover:underline mb-1 inline-flex items-center gap-1">
<ArrowLeft className="w-4 h-4" /> {t('common.back', 'Back')}
</button>
<h2 className="text-xl font-bold">
{isEdit ? `${t('quotes.edit', 'Edit quote')} ${existing?.quote.quoteNumber || ''}` : t('quotes.new', 'New quote')}
</h2>
</div>
<div className="flex gap-2">
<Button variant="outline" onClick={handlePreviewUnsaved} disabled={busy}>
<Eye className="w-4 h-4 mr-1" />{t('quotes.preview', 'Preview PDF')}
</Button>
<Button variant="outline" onClick={() => handleSave()} disabled={busy || !installmentsValid}>
{t('common.save', 'Save')}
</Button>
<Button onClick={() => handleSave('send')} disabled={busy || !installmentsValid}>
<Send className="w-4 h-4 mr-1" />{t('quotes.saveAndSend', 'Save & send')}
</Button>
</div>
</div>
{/* Section: Customer */}
<Card>
<h3 className="font-semibold mb-2">1. {t('quotes.section.customer', 'Customer')}</h3>
<CustomerPicker
value={form.customerAccountId}
label={form.customerLabel}
isPassive={form.customerIsPassive}
onSelect={(c) => setForm((f) => ({
...f,
customerAccountId: c.id,
customerLabel: c.companyName || c.displayName || c.email,
customerIsPassive: Boolean(c.isPassive),
}))}
onCreate={(c) => setForm((f) => ({
...f,
customerAccountId: c.id,
customerLabel: c.companyName || c.displayName || c.email,
customerIsPassive: Boolean(c.isPassive),
// Inherit the new customer's language so the quote
// gets rendered in their locale by default.
language: f.language || c.preferredLanguage || 'de',
}))}
onClear={() => setForm((f) => ({
...f, customerAccountId: null, customerLabel: '', customerIsPassive: false,
}))}
searchPlaceholder={t('quotes.customerSearch', 'Search customer by email or company…') as string}
/>
</Card>
{/* Section: Event */}
<Card>
<h3 className="font-semibold mb-2">2. {t('quotes.section.event', 'Event details')}</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
<Input label={t('quotes.field.eventName', 'Event name') as string} value={form.eventName}
onChange={(e) => setForm((f) => ({ ...f, eventName: e.target.value }))} />
<Input type="date" label={t('quotes.field.eventDate', 'Event date') as string} value={form.eventDate}
onChange={(e) => setForm((f) => ({ ...f, eventDate: e.target.value }))} />
<Input type="time" lang={timeInputLang} label={t('quotes.field.eventTimeStart', 'Start time') as string} value={form.eventTimeStart}
onChange={(e) => setForm((f) => ({ ...f, eventTimeStart: e.target.value }))} />
<Input type="time" lang={timeInputLang} label={t('quotes.field.eventTimeEnd', 'End time') as string} value={form.eventTimeEnd}
onChange={(e) => setForm((f) => ({ ...f, eventTimeEnd: e.target.value }))} />
<Input type="number" step="0.5" label={t('quotes.field.expectedDuration', 'Expected duration (h)') as string}
value={form.expectedDurationHours}
onChange={(e) => setForm((f) => ({ ...f, expectedDurationHours: e.target.value }))} />
<Input type="date" label={t('quotes.field.validUntil', 'Valid until') as string} value={form.validUntil}
onChange={(e) => setForm((f) => ({ ...f, validUntil: e.target.value }))} />
</div>
</Card>
{/* Section: Line items */}
<Card>
<h3 className="font-semibold mb-2">3. {t('quotes.section.lineItems', 'Line items')}</h3>
<LineItemsTable
items={form.lineItems}
currency={form.currency}
showDiscount={true}
vatRate={form.vatRate / 100}
shippingAmount={form.shippingAmount}
presets={liPresets?.presets || []}
onChange={(items) => setForm((f) => ({ ...f, lineItems: items }))}
/>
<div className="grid grid-cols-1 md:grid-cols-3 gap-3 mt-4">
<Input type="number" step="0.1" label={t('quotes.field.vatRate', 'VAT rate %') as string}
value={form.vatRate}
onChange={(e) => setForm((f) => ({ ...f, vatRate: Number(e.target.value) }))} />
<Input type="number" step="0.01" label={t('quotes.field.shipping', 'Shipping amount') as string}
value={form.shippingAmount}
onChange={(e) => setForm((f) => ({ ...f, shippingAmount: Number(e.target.value) }))} />
<div>
<label className="block text-sm font-medium mb-1">{t('quotes.field.currency', 'Currency')}</label>
<select value={form.currency} onChange={(e) => setForm((f) => ({ ...f, currency: e.target.value }))}
className="w-full px-3 py-2 rounded-md border border-neutral-300 dark:border-neutral-600 bg-white dark:bg-neutral-800 text-sm">
<option>CHF</option><option>EUR</option><option>USD</option><option>GBP</option>
</select>
</div>
</div>
</Card>
{/* Section: Payment migration 124 split picker. Two orthogonal
dropdowns (Net days × Payment timing) replace the single
legacy "Payment conditions" dropdown. The installment preview
below now reads from the timing template. */}
<Card>
<h3 className="font-semibold mb-2">4. {t('quotes.section.payment', 'Payment conditions')}</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
<div>
<label className="block text-sm font-medium mb-1">{t('quotes.field.paymentNetDays', 'Net days')}</label>
<select
className="w-full px-3 py-2 rounded-md border border-neutral-300 dark:border-neutral-600 bg-white dark:bg-neutral-800 text-sm"
value={form.paymentNetDaysTemplateId || ''}
onChange={(e) => setForm((f) => ({ ...f, paymentNetDaysTemplateId: e.target.value ? Number(e.target.value) : null }))}
>
<option value="">{t('quotes.field.selectNetDays', '— Select net days —')}</option>
{netDaysTemplates?.templates.map((tpl) => (
<option key={tpl.id} value={tpl.id}>{tpl.name}</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-medium mb-1">{t('quotes.field.paymentTiming', 'Payment schedule')}</label>
<select
className="w-full px-3 py-2 rounded-md border border-neutral-300 dark:border-neutral-600 bg-white dark:bg-neutral-800 text-sm"
value={form.paymentTimingTemplateId || ''}
onChange={(e) => setForm((f) => ({ ...f, paymentTimingTemplateId: e.target.value ? Number(e.target.value) : null }))}
>
<option value="">{t('quotes.field.selectTiming', '— Select schedule —')}</option>
{timingTemplates?.templates.map((tpl) => (
<option key={tpl.id} value={tpl.id}>{tpl.name}</option>
))}
</select>
</div>
</div>
{installmentPreview.length > 0 && (
<ul className="mt-3 text-sm space-y-1 text-neutral-600 dark:text-neutral-400">
{installmentPreview.map((inst, i) => (
<li key={i}> {inst.percent}% {inst.label} ({t(`quotes.trigger.${inst.trigger}`, inst.trigger)}{inst.offset_days ? `, ${inst.offset_days}d` : ''})</li>
))}
</ul>
)}
{/* Ad-hoc installments panel (commit #6). Overrides the
timing-template preview above when set. The plan is
snapshotted onto the quote and spawns N invoices on
conversion via convertQuoteToInvoices. */}
<div className="mt-4 pt-4 border-t border-neutral-200 dark:border-neutral-700">
<InstallmentsPanel
value={form.installments ?? null}
onChange={(next) => setForm((f) => ({ ...f, installments: next }))}
onValidityChange={setInstallmentsValid}
eventDate={form.eventDate || null}
/>
</div>
</Card>
{/* Section: Extras */}
<Card>
<h3 className="font-semibold mb-2">5. {t('quotes.section.extras', 'Intro / outro / extras')}</h3>
<div className="space-y-3">
<div>
<label className="block text-sm font-medium mb-1">{t('quotes.field.introText', 'Intro text')}</label>
<textarea rows={3} className="w-full rounded-md border border-neutral-300 dark:border-neutral-600 bg-white dark:bg-neutral-800 text-neutral-900 dark:text-neutral-100 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-accent-dark"
value={form.introText} onChange={(e) => setForm((f) => ({ ...f, introText: e.target.value }))} />
</div>
<div>
<label className="block text-sm font-medium mb-1">{t('quotes.field.outroText', 'Outro text')}</label>
<textarea rows={3} className="w-full rounded-md border border-neutral-300 dark:border-neutral-600 bg-white dark:bg-neutral-800 text-neutral-900 dark:text-neutral-100 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-accent-dark"
value={form.outroText} onChange={(e) => setForm((f) => ({ ...f, outroText: e.target.value }))} />
</div>
{/* CC PDF admin email prefilled, with a picker when more
than one admin exists. Mirrors the admin_email field on
CreateEventPage so the muscle memory carries over. */}
<div className="space-y-1">
<Input
type="email"
label={t('quotes.field.ccPdfEmail', 'CC PDF to (extra recipient)') as string}
placeholder={t('quotes.field.ccPdfEmailPlaceholder', '[email protected]') as string}
value={form.ccPdfEmail}
onChange={(e) => setForm((f) => ({ ...f, ccPdfEmail: e.target.value }))}
/>
{activeAdmins.length > 1 && (
<div className="flex items-center gap-2">
<label htmlFor="cc-pdf-picker" className="text-xs text-neutral-600 dark:text-neutral-400 whitespace-nowrap">
{t('quotes.field.ccPdfPickFromAdmins', 'Pick from admins:')}
</label>
<select
id="cc-pdf-picker"
value={activeAdmins.some((a: any) => a.email === form.ccPdfEmail) ? form.ccPdfEmail : ''}
onChange={(e) => {
const email = e.target.value;
if (email) setForm((prev) => ({ ...prev, ccPdfEmail: email }));
}}
className="text-xs px-2 py-1 border border-neutral-300 dark:border-neutral-600 bg-white dark:bg-neutral-800 text-neutral-900 dark:text-neutral-100 rounded focus:ring-2 focus:ring-primary-500 focus:border-accent-dark"
>
<option value="">{t('quotes.field.ccPdfCustom', 'Custom email')}</option>
{activeAdmins.map((a: any) => (
<option key={a.id} value={a.email}>{a.email}</option>
))}
</select>
</div>
)}
</div>
<div>
<label className="block text-sm font-medium mb-1">{t('quotes.field.internalNotes', 'Internal notes (not on PDF)')}</label>
<textarea rows={3} className="w-full rounded-md border border-neutral-300 dark:border-neutral-600 bg-white dark:bg-neutral-800 text-neutral-900 dark:text-neutral-100 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-accent-dark"
value={form.internalNotes} onChange={(e) => setForm((f) => ({ ...f, internalNotes: e.target.value }))} />
</div>
</div>
</Card>
</div>
);
};
@@ -0,0 +1,171 @@
/**
* Quotes list page. Filters by status / customer / search; sortable by
* date / customer / value; paginated.
*/
import React, { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Link, useNavigate } from 'react-router-dom';
import { useQuery } from '@tanstack/react-query';
import { Plus, Search } from 'lucide-react';
import { quotesService, type QuoteStatus, type QuoteSort } from '../../../services/quotes.service';
import { Button, Card, Loading } from '../../../components/common';
import { formatMoney } from '../../../components/admin/LineItemsTable';
import { useLocalizedDate } from '../../../hooks/useLocalizedDate';
const STATUSES: QuoteStatus[] = ['draft', 'sent', 'accepted', 'declined', 'expired', 'converted'];
export const QuotesListPage: React.FC = () => {
const { t } = useTranslation();
const navigate = useNavigate();
const { format: fmtDate } = useLocalizedDate();
const [search, setSearch] = useState('');
const [statusFilter, setStatusFilter] = useState<QuoteStatus[]>([]);
const [sort, setSort] = useState<QuoteSort>('newest');
const [page, setPage] = useState(1);
const { data, isLoading } = useQuery({
queryKey: ['quotes', { search, statusFilter, sort, page }],
queryFn: () => quotesService.list({
q: search || undefined,
status: statusFilter.length ? statusFilter : undefined,
sort, page, pageSize: 25,
}),
});
const toggleStatus = (s: QuoteStatus) => {
setStatusFilter((prev) => prev.includes(s) ? prev.filter((x) => x !== s) : [...prev, s]);
setPage(1);
};
return (
<div className="container py-6">
<div className="flex items-center justify-between mb-6">
<div>
<div className="flex items-center gap-2">
<h1 className="text-2xl font-bold text-theme">{t('quotes.title', 'Quotes')}</h1>
{/* Beta badge feature is functional but the surface is
still evolving (matches Customers + Invoices). */}
<span
className="text-[10px] uppercase tracking-wider px-1.5 py-0.5 rounded font-semibold bg-amber-100 text-amber-800 dark:bg-amber-900/40 dark:text-amber-300"
title="Beta — feature is functional but still evolving"
>
{t('navigation.betaTag', 'Beta')}
</span>
</div>
<p className="text-sm text-muted-theme mt-1">
{t('quotes.subtitle', 'Send, track and convert quotes into events.')}
</p>
</div>
<Link to="/admin/clients/quotes/new">
<Button><Plus className="w-4 h-4 mr-1" />{t('quotes.new', 'New quote')}</Button>
</Link>
</div>
<Card padding="lg">
<div className="flex flex-wrap items-center gap-3">
<div className="relative flex-1 min-w-[200px]">
<Search className="w-4 h-4 absolute left-3 top-1/2 -translate-y-1/2 text-neutral-400" />
<input
type="text"
placeholder={t('quotes.searchPlaceholder', 'Search by number, customer, event…') as string}
className="w-full pl-9 pr-3 py-2 rounded-md border border-neutral-300 dark:border-neutral-600 bg-white dark:bg-neutral-800 text-sm"
value={search}
onChange={(e) => { setSearch(e.target.value); setPage(1); }}
/>
</div>
<select
className="px-3 py-2 rounded-md border border-neutral-300 dark:border-neutral-600 bg-white dark:bg-neutral-800 text-sm"
value={sort}
onChange={(e) => setSort(e.target.value as QuoteSort)}
>
<option value="newest">{t('quotes.sort.newest', 'Newest first')}</option>
<option value="oldest">{t('quotes.sort.oldest', 'Oldest first')}</option>
<option value="customer_asc">{t('quotes.sort.customerAsc', 'Customer A→Z')}</option>
<option value="value_asc">{t('quotes.sort.valueAsc', 'Value low→high')}</option>
<option value="value_desc">{t('quotes.sort.valueDesc', 'Value high→low')}</option>
</select>
</div>
<div className="mt-3 flex flex-wrap gap-1">
{STATUSES.map((s) => {
const active = statusFilter.includes(s);
return (
<button key={s} type="button" onClick={() => toggleStatus(s)}
className={`px-2.5 py-1 rounded-full text-xs font-medium border transition-colors ${
active
? 'bg-accent-dark text-white border-accent-dark'
: 'bg-white dark:bg-neutral-800 text-neutral-700 dark:text-neutral-300 border-neutral-300 dark:border-neutral-600'
}`}
>{t(`quotes.status.${s}`, s)}</button>
);
})}
</div>
{/* Body of the same card table or empty state. Matches the
single-card layout used by Customers/Invitations. */}
<div className="mt-4">
{isLoading ? <Loading /> : !data || data.quotes.length === 0 ? (
<p className="text-center text-muted-theme py-8">{t('quotes.empty', 'No quotes yet.')}</p>
) : (
<div className="rounded-lg border border-neutral-200 dark:border-neutral-700 overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead className="bg-neutral-50 dark:bg-neutral-800 text-neutral-700 dark:text-neutral-300">
<tr>
<th className="px-3 py-2 text-left">#</th>
<th className="px-3 py-2 text-left">{t('quotes.table.customer', 'Customer')}</th>
<th className="px-3 py-2 text-left">{t('quotes.table.event', 'Event')}</th>
<th className="px-3 py-2 text-left">{t('quotes.table.issueDate', 'Issued')}</th>
<th className="px-3 py-2 text-right">{t('quotes.table.total', 'Total')}</th>
<th className="px-3 py-2 text-left">{t('quotes.table.status', 'Status')}</th>
</tr>
</thead>
<tbody>
{data.quotes.map((q) => (
<tr key={q.id}
className="border-t border-neutral-200 dark:border-neutral-700 cursor-pointer hover:bg-neutral-50 dark:hover:bg-neutral-800"
onClick={() => navigate(`/admin/clients/quotes/${q.id}`)}
>
<td className="px-3 py-2 font-mono text-xs">{q.quoteNumber}</td>
<td className="px-3 py-2">{q.customer.companyName || q.customer.displayName || q.customer.email}</td>
<td className="px-3 py-2 truncate max-w-xs">{q.eventName || '—'}</td>
<td className="px-3 py-2">{fmtDate(q.issueDate)}</td>
<td className="px-3 py-2 text-right tabular-nums">
{formatMoney(Number(q.totalAmountMinor) / 100, q.currency)}
</td>
<td className="px-3 py-2">
<span className={`px-2 py-0.5 rounded text-xs font-medium ${
q.status === 'accepted' || q.status === 'converted' ? 'bg-green-100 text-green-800'
: q.status === 'declined' ? 'bg-red-100 text-red-800'
: q.status === 'sent' ? 'bg-blue-100 text-blue-800'
: 'bg-neutral-100 text-neutral-700'
}`}>{t(`quotes.status.${q.status}`, q.status)}</span>
</td>
</tr>
))}
</tbody>
</table>
</div>
{data.pagination.totalPages > 1 && (
<div className="flex justify-between items-center px-3 py-2 border-t border-neutral-200 dark:border-neutral-700 text-sm">
<span className="text-muted-theme">
{t('quotes.pagination', 'Page {{page}} of {{total}} · {{count}} quotes', {
page: data.pagination.page, total: data.pagination.totalPages, count: data.pagination.total,
})}
</span>
<div className="flex gap-2">
<Button variant="outline" size="sm" disabled={page <= 1} onClick={() => setPage((p) => p - 1)}>
{t('common.previous', 'Previous')}
</Button>
<Button variant="outline" size="sm" disabled={page >= data.pagination.totalPages} onClick={() => setPage((p) => p + 1)}>
{t('common.next', 'Next')}
</Button>
</div>
</div>
)}
</div>
)}
</div>
</Card>
</div>
);
};
@@ -0,0 +1,433 @@
/**
* Settings CRM tab.
*
* Per-feature toggles that fine-tune the CRM behaviour without turning
* off the whole `quotes` / `bills` global flag. Backed by the
* crm_* settings seeded by migration 102. Reads via the generic
* settings.service; writes one key at a time to keep blast radius small.
*/
import React, { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { Save as SaveIcon } from 'lucide-react';
import { Button, Card, Loading, Input } from '../../../components/common';
import { settingsService } from '../../../services/settings.service';
import { quotesService } from '../../../services/quotes.service';
import { useFeatureFlags } from '../../../contexts/FeatureFlagsContext';
import { toast } from 'react-toastify';
const SETTING_KEYS = [
'crm_quotes_pdf_attachment_enabled',
'crm_quotes_skonto_enabled',
'crm_quotes_accept_window_minutes',
'crm_quotes_default_valid_days',
'crm_quotes_number_format',
// Terms of Service step on quote acceptance (migration 104).
'crm_quotes_tos_required',
'crm_quotes_tos_text',
'crm_quotes_tos_url',
'crm_invoices_qr_enabled',
'crm_invoices_reminders_enabled',
'crm_invoices_reminder_first_days',
'crm_invoices_reminder_second_days',
'crm_invoices_late_fee_enabled',
'crm_invoices_late_fee_minor',
'crm_invoices_late_fee_label',
'crm_invoices_skonto_business_days',
'crm_invoices_skonto_percent_default',
// Installment defaults (migration 141) — pre-populate fresh ad-hoc
// rows in the Quote / Invoice editors' Installments panel.
'crm_invoices_installment_trigger_first',
'crm_invoices_installment_days_before_event',
'crm_invoices_installment_days_after_event',
// Pre-event customer reminders (migration 143) — toggles managed
// on Settings → Reminder emails (their own dedicated tab) so the
// template editor and the global enable/days-before settings live
// in one place.
'crm_invoices_number_format',
// Default Net days + Payment timing pickers (migration 124+125).
// The per-quote/per-invoice picker becomes a true override over
// these global defaults — the editor reads these on new documents.
'crm_invoices_default_payment_net_days_template_id',
'crm_invoices_default_payment_timing_template_id',
// Contracts (migration 130). Number format token convention matches
// quotes/invoices: {YEAR} {MONTH} {SEQ:04d}. Default 'C-{YEAR}-{SEQ:04d}'.
'crm_contracts_number_format',
'crm_contracts_default_valid_days',
'crm_contracts_pdf_attachment_enabled',
'crm_contracts_require_drawn_signature',
'crm_contracts_allow_pdf_upload',
'crm_contracts_store_ip',
// Dashboard CRM-overview tile visibility (per-tile). Default ON;
// explicit false hides the tile. Stored as one boolean per tile so
// admins can mix-and-match — e.g. someone who only bills hourly
// hides the quotes pipeline + the per-status invoice breakdown
// but keeps the revenue / outstanding tiles.
'crm_overview_show_revenue',
'crm_overview_show_outstanding',
'crm_overview_show_quotes',
'crm_overview_show_invoices',
];
export const CrmSettingsPage: React.FC = () => {
const { t } = useTranslation();
const qc = useQueryClient();
const { flags } = useFeatureFlags();
// Show each section only when the corresponding master flag is on —
// configuring Skonto on quotes is pointless when quotes itself is
// disabled. The dashboard-overview tile section depends on quotes
// OR bills (the per-tile checkboxes inside govern quote-pipeline +
// invoice-pipeline + revenue tiles, all of which are CRM-money).
const showQuotes = !!flags.quotes;
const showInvoices = !!flags.bills;
const showContracts = !!flags.contracts;
const showDashboardOverview = !!(flags.quotes || flags.bills);
const anySection = showQuotes || showInvoices || showContracts || showDashboardOverview;
const { data, isLoading } = useQuery({
queryKey: ['settings', 'crm'],
queryFn: async () => {
const all = await settingsService.getAllSettings();
const out: Record<string, any> = {};
for (const key of SETTING_KEYS) out[key] = all[key];
return out;
},
});
// Migration 124 — list the split-picker templates so the new
// dropdowns can render labels. Same endpoints the editors use.
const { data: netDaysTemplates } = useQuery({
queryKey: ['payment-net-days-templates'],
queryFn: () => quotesService.listPaymentNetDaysTemplates(),
});
const { data: timingTemplates } = useQuery({
queryKey: ['payment-timing-templates'],
queryFn: () => quotesService.listPaymentTimingTemplates(),
});
const [values, setValues] = useState<Record<string, any>>({});
useEffect(() => { if (data) setValues(data); }, [data]);
const saveAll = useMutation({
mutationFn: async () => {
const changed: Record<string, any> = {};
for (const key of SETTING_KEYS) {
if (values[key] !== data?.[key]) changed[key] = values[key];
}
if (Object.keys(changed).length > 0) {
await settingsService.updateSettings(changed);
}
},
onSuccess: () => {
toast.success(t('crmSettings.savedToast', 'CRM settings saved.'));
qc.invalidateQueries({ queryKey: ['settings', 'crm'] });
},
onError: (err: any) => toast.error(err?.response?.data?.error || 'Save failed'),
});
if (isLoading) return <Loading />;
const setVal = (k: string, v: any) => setValues((s) => ({ ...s, [k]: v }));
const checkbox = (k: string, label: string) => (
<label className="flex items-center gap-2 text-sm py-1">
<input type="checkbox" checked={!!values[k]} onChange={(e) => setVal(k, e.target.checked)} />
<span>{t(`crmSettings.${k}.label`, label)}</span>
</label>
);
/**
* Checkbox variant for settings whose backend semantics treat
* `undefined` (row missing from app_settings) as ON e.g.
* `crm_contracts_allow_pdf_upload` reads `getAppSetting(...) !== false`,
* so a missing row behaves as if checked. Without this variant the
* UI would render an unchecked box while the feature is actually
* running, which misleads admins on installs whose DB ran an earlier
* version of migration 130 (before these settings were added to the
* seed list).
*
* Stored booleans (true / false) always win; the default only fills
* in when the value is null/undefined.
*/
const checkboxDefaultOn = (k: string, label: string) => {
const stored = values[k];
const effective = stored === undefined || stored === null ? true : !!stored;
return (
<label className="flex items-center gap-2 text-sm py-1">
<input
type="checkbox"
checked={effective}
onChange={(e) => setVal(k, e.target.checked)}
/>
<span>{t(`crmSettings.${k}.label`, label)}</span>
</label>
);
};
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<div>
<h2 className="text-xl font-bold">{t('crmSettings.title', 'CRM settings')}</h2>
<p className="text-sm text-neutral-600 dark:text-neutral-400">
{t('crmSettings.subtitle', 'Fine-tune quote and invoice behaviour.')}
</p>
</div>
<Button onClick={() => saveAll.mutate()} disabled={saveAll.isPending || !anySection}>
<SaveIcon className="w-4 h-4 mr-1" />{t('common.save', 'Save')}
</Button>
</div>
{!anySection && (
<Card>
<p className="text-sm text-muted-theme">
{t('crmSettings.emptyState',
'No CRM features are currently enabled. Turn on Quotes, Invoices, or Contracts in Settings → Features to see the matching configuration here.')}
</p>
</Card>
)}
{showQuotes && (
<Card>
<h3 className="font-semibold mb-3">{t('crmSettings.section.quotes', 'Quotes')}</h3>
{checkbox('crm_quotes_pdf_attachment_enabled', 'Attach quote PDF to email')}
{checkbox('crm_quotes_skonto_enabled', 'Allow early-payment discount (Skonto) on quotes')}
<div className="grid grid-cols-1 md:grid-cols-2 gap-3 mt-3">
<Input type="number" min={1} max={120}
label={t('crmSettings.crm_quotes_accept_window_minutes.label', 'Accept window (minutes)') as string}
value={values.crm_quotes_accept_window_minutes ?? 15}
onChange={(e) => setVal('crm_quotes_accept_window_minutes', Number(e.target.value))} />
<Input type="number" min={1} max={365}
label={t('crmSettings.crm_quotes_default_valid_days.label', 'Default validity (days)') as string}
value={values.crm_quotes_default_valid_days ?? 30}
onChange={(e) => setVal('crm_quotes_default_valid_days', Number(e.target.value))} />
<Input
label={t('crmSettings.crm_quotes_number_format.label', 'Quote number format') as string}
value={values.crm_quotes_number_format ?? ''}
onChange={(e) => setVal('crm_quotes_number_format', e.target.value)} />
</div>
{/* Terms of Service step (migration 104). When required, the
public quote response page shows a checkbox the customer
must tick before Accept fires. The text snapshot is
recorded on the quote at acceptance time for audit. */}
<div className="mt-4 pt-4 border-t border-neutral-200 dark:border-neutral-700">
<h4 className="font-semibold mb-2 text-sm">
{t('crmSettings.section.quotesTos', 'Terms of Service / AGB step')}
</h4>
{checkbox('crm_quotes_tos_required', 'Require customers to tick "I accept the Terms of Service" before accepting')}
<div className="grid grid-cols-1 md:grid-cols-2 gap-3 mt-3">
<Input
label={t('crmSettings.crm_quotes_tos_url.label', 'Terms of Service URL (optional)') as string}
placeholder="https://example.com/terms"
value={values.crm_quotes_tos_url ?? ''}
onChange={(e) => setVal('crm_quotes_tos_url', e.target.value)} />
</div>
<div className="mt-3">
<label className="block text-sm font-medium mb-1">
{t('crmSettings.crm_quotes_tos_text.label', 'Inline Terms text shown on the quote page')}
</label>
<textarea
rows={6}
className="w-full rounded-md border border-neutral-300 dark:border-neutral-600 bg-white dark:bg-neutral-800 text-neutral-900 dark:text-neutral-100 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-accent-dark"
value={values.crm_quotes_tos_text ?? ''}
onChange={(e) => setVal('crm_quotes_tos_text', e.target.value)}
placeholder={t('crmSettings.crm_quotes_tos_text.placeholder',
'Paste the contract terms here. Plain text. Leave empty to only show the checkbox + URL.') as string}
/>
</div>
</div>
</Card>
)}
{showInvoices && (
<Card>
<h3 className="font-semibold mb-3">{t('crmSettings.section.invoices', 'Invoices')}</h3>
{checkbox('crm_invoices_qr_enabled', 'Render payment QR on invoice PDFs')}
{checkbox('crm_invoices_reminders_enabled', 'Send automatic reminders for overdue invoices')}
{checkbox('crm_invoices_late_fee_enabled', 'Add a late fee on the second reminder')}
<div className="grid grid-cols-1 md:grid-cols-2 gap-3 mt-3">
<Input type="number" min={1} max={365}
label={t('crmSettings.crm_invoices_reminder_first_days.label', 'First reminder after (days past due)') as string}
value={values.crm_invoices_reminder_first_days ?? 14}
onChange={(e) => setVal('crm_invoices_reminder_first_days', Number(e.target.value))} />
<Input type="number" min={1} max={365}
label={t('crmSettings.crm_invoices_reminder_second_days.label', 'Second reminder after (days past due)') as string}
value={values.crm_invoices_reminder_second_days ?? 30}
onChange={(e) => setVal('crm_invoices_reminder_second_days', Number(e.target.value))} />
<Input type="number" min={0}
label={t('crmSettings.crm_invoices_late_fee_minor.label', 'Late fee (minor units / Rappen)') as string}
value={values.crm_invoices_late_fee_minor ?? 0}
onChange={(e) => setVal('crm_invoices_late_fee_minor', Number(e.target.value))} />
<Input
label={t('crmSettings.crm_invoices_late_fee_label.label', 'Late fee label') as string}
value={values.crm_invoices_late_fee_label ?? 'Mahngebühr'}
onChange={(e) => setVal('crm_invoices_late_fee_label', e.target.value)} />
<Input type="number" min={0} step="0.01" max="100"
label={t('crmSettings.crm_invoices_skonto_percent_default.label', 'Skonto rate (default %)') as string}
value={values.crm_invoices_skonto_percent_default ?? 2}
onChange={(e) => setVal('crm_invoices_skonto_percent_default', Number(e.target.value))} />
<Input type="number" min={0}
label={t('crmSettings.crm_invoices_skonto_business_days.label', 'Skonto window (business days)') as string}
value={values.crm_invoices_skonto_business_days ?? 5}
onChange={(e) => setVal('crm_invoices_skonto_business_days', Number(e.target.value))} />
<Input
label={t('crmSettings.crm_invoices_number_format.label', 'Invoice number format') as string}
value={values.crm_invoices_number_format ?? ''}
onChange={(e) => setVal('crm_invoices_number_format', e.target.value)} />
</div>
{/* Installment defaults (migration 141). Pre-populate fresh
ad-hoc rows in the Quote / Invoice editors' Installments
panel admins type these defaults exactly once instead of
on every new multi-installment plan. Per-document edits
still override. */}
<div className="mt-4 pt-4 border-t border-neutral-200 dark:border-neutral-700">
<h4 className="font-semibold mb-2 text-sm">
{t('crmSettings.section.installmentDefaults', 'Default installment triggers')}
</h4>
<p className="text-xs text-neutral-500 mb-3">
{t('crmSettings.installmentDefaults.help',
'Pre-fill the trigger for fresh rows in the Installments panel. Per-document edits override; existing documents keep their snapshotted plan.')}
</p>
<div className="grid grid-cols-1 md:grid-cols-3 gap-3">
<div>
<label className="block text-sm font-medium mb-1">
{t('crmSettings.crm_invoices_installment_trigger_first.label', 'First installment trigger')}
</label>
<select
value={values.crm_invoices_installment_trigger_first ?? 'quote_accepted'}
onChange={(e) => setVal('crm_invoices_installment_trigger_first', e.target.value)}
className="w-full px-3 py-2 rounded-md border border-neutral-300 dark:border-neutral-600 bg-white dark:bg-neutral-800 text-sm"
>
<option value="quote_accepted">{t('crmSettings.installmentDefaults.trigger.quote_accepted', 'At signing / creation')}</option>
<option value="before_event">{t('crmSettings.installmentDefaults.trigger.before_event', 'Before event')}</option>
<option value="after_event">{t('crmSettings.installmentDefaults.trigger.after_event', 'After event')}</option>
<option value="after_delivery">{t('crmSettings.installmentDefaults.trigger.after_delivery', 'On delivery (manual release)')}</option>
<option value="fixed_date">{t('crmSettings.installmentDefaults.trigger.fixed_date', 'Fixed date')}</option>
</select>
</div>
<Input type="number" min={0}
label={t('crmSettings.crm_invoices_installment_days_before_event.label', 'Days before event (middle installment)') as string}
value={values.crm_invoices_installment_days_before_event ?? 14}
onChange={(e) => setVal('crm_invoices_installment_days_before_event', Number(e.target.value))} />
<Input type="number" min={0}
label={t('crmSettings.crm_invoices_installment_days_after_event.label', 'Days after event (final installment)') as string}
value={values.crm_invoices_installment_days_after_event ?? 14}
onChange={(e) => setVal('crm_invoices_installment_days_after_event', Number(e.target.value))} />
</div>
</div>
{/* Pre-event customer reminders moved to their own dedicated
tab Settings Reminder emails modelled on the
BlockLibrary two-column layout. Toggles + per-type
templates live there in one place. */}
{/* Default payment-term pickers (migration 124+125). The
per-quote / per-invoice editor still always shows the
pickers admin can override per document but new
drafts auto-prefill from these two settings. */}
<div className="mt-4 pt-4 border-t border-neutral-200 dark:border-neutral-700">
<h4 className="font-semibold mb-2 text-sm">
{t('crmSettings.section.paymentDefaults', 'Default payment conditions')}
</h4>
<p className="text-xs text-neutral-500 mb-3">
{t('crmSettings.paymentDefaults.help',
'Pre-filled on every new quote and invoice. The editor still lets you pick a different combination per document.')}
</p>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
<div>
<label className="block text-sm font-medium mb-1">
{t('crmSettings.crm_invoices_default_payment_net_days_template_id.label', 'Default net days')}
</label>
<select
className="w-full px-3 py-2 rounded-md border border-neutral-300 dark:border-neutral-600 bg-white dark:bg-neutral-800 text-sm"
value={values.crm_invoices_default_payment_net_days_template_id ?? ''}
onChange={(e) => setVal('crm_invoices_default_payment_net_days_template_id', e.target.value ? Number(e.target.value) : null)}
>
<option value="">{t('crmSettings.paymentDefaults.none', '— No default —')}</option>
{netDaysTemplates?.templates.map((tpl) => (
<option key={tpl.id} value={tpl.id}>{tpl.name}</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-medium mb-1">
{t('crmSettings.crm_invoices_default_payment_timing_template_id.label', 'Default payment schedule')}
</label>
<select
className="w-full px-3 py-2 rounded-md border border-neutral-300 dark:border-neutral-600 bg-white dark:bg-neutral-800 text-sm"
value={values.crm_invoices_default_payment_timing_template_id ?? ''}
onChange={(e) => setVal('crm_invoices_default_payment_timing_template_id', e.target.value ? Number(e.target.value) : null)}
>
<option value="">{t('crmSettings.paymentDefaults.none', '— No default —')}</option>
{timingTemplates?.templates.map((tpl) => (
<option key={tpl.id} value={tpl.id}>{tpl.name}</option>
))}
</select>
</div>
</div>
</div>
</Card>
)}
{showContracts && (
/* Contracts (migration 130). Mirrors the Quotes / Invoices block
shape: 3 behaviour toggles, then a 2-column input grid, then
the number-format input with helper text. */
<Card>
<h3 className="font-semibold mb-3">{t('crmSettings.section.contracts', 'Contracts')}</h3>
{/* Three of these four toggles use `!== false` semantics on
the backend a missing app_settings row behaves as if
checked. `checkboxDefaultOn` mirrors that so the UI tells
the truth on installs whose DB never received the seed
rows. `require_drawn_signature` uses `=== true` (default
off) so it stays on the plain `checkbox`. */}
{checkboxDefaultOn('crm_contracts_pdf_attachment_enabled', 'Attach contract PDF to email')}
{checkbox('crm_contracts_require_drawn_signature', 'Require drawn signature (typed name alone is not enough)')}
{checkboxDefaultOn('crm_contracts_allow_pdf_upload', 'Allow customer to upload a wet-signed PDF')}
{checkboxDefaultOn('crm_contracts_store_ip', "Store signer's IP address (recommended — corroborating evidence in civil disputes)")}
<p className="text-xs text-neutral-500 mt-1 ml-6">
{t('crmSettings.crm_contracts_store_ip.help',
"When off, the customer's and admin's IP at signing time is NOT recorded into the contract row or the public sign-page audit confirmation. Per GDPR data-minimisation principle some operators prefer this — but IP is corroborating identity evidence if the contract is challenged, so we recommend keeping it on.")}
</p>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3 mt-3">
<Input type="number" min={1} max={365}
label={t('crmSettings.crm_contracts_default_valid_days.label', 'Signing window (days)') as string}
value={values.crm_contracts_default_valid_days ?? 30}
onChange={(e) => setVal('crm_contracts_default_valid_days', Number(e.target.value))} />
<Input
label={t('crmSettings.crm_contracts_number_format.label', 'Contract number format') as string}
value={values.crm_contracts_number_format ?? ''}
onChange={(e) => setVal('crm_contracts_number_format', e.target.value)}
placeholder="C-{YEAR}-{SEQ:04d}"
/>
</div>
<p className="text-xs text-neutral-500 mt-2">
{t('crmSettings.crm_contracts_number_format.help',
'Supported tokens: {YEAR}, {MONTH}, {SEQ:04d}. Example: LBM-C-{YEAR}-{SEQ:04d} → LBM-C-2026-0001.')}
</p>
</Card>
)}
{showDashboardOverview && (
/* Dashboard CRM overview per-tile visibility. All settings
default ON; the checkbox shows checked when the value is
unset, matching the contracts card's pattern. The dashboard
component reads these via /public/settings and hides the
matching tile when the value is explicit false. */
<Card>
<h3 className="font-semibold mb-1">
{t('crmSettings.section.dashboardOverview', 'Dashboard CRM overview')}
</h3>
<p className="text-xs text-neutral-500 mb-3">
{t('crmSettings.section.dashboardOverviewHint',
'Hide CRM overview tiles on the admin dashboard. All tiles render by default; uncheck to hide.')}
</p>
{checkboxDefaultOn('crm_overview_show_revenue', 'Revenue tiles (30 / 90 / 365 days)')}
{checkboxDefaultOn('crm_overview_show_outstanding', 'Outstanding payments tile')}
{checkboxDefaultOn('crm_overview_show_quotes', 'Quotes pipeline (per-status)')}
{checkboxDefaultOn('crm_overview_show_invoices', 'Invoices pipeline (per-status)')}
</Card>
)}
</div>
);
};
@@ -0,0 +1,471 @@
/**
* Admin Settings CRM Reminder Emails.
*
* Two-column layout deliberately mirroring the contract block library
* and the email-template editor so admins navigate one shape across
* every template surface. Left rail lists "Default (catch-all)" first,
* then every active event_type from the catalog with a "Default" pill
* when no per-type template exists yet (admin's first save creates it
* from whatever's currently in the form, which on entry mirrors the
* default).
*
* Right panel: full language tab row (EN/DE/RU/PT/NL/FR with flags +
* amber bullets for missing translations) Subject input the same
* tiptap-based `EmailTemplateEditor` used in the main email-templates
* editor so the body styling, toolbar, and variable inserter match.
*
* Plus a header strip with the two global toggles (`crm_event_reminders_enabled`,
* `crm_event_reminders_days_before`).
*
* Template-key naming convention (matches eventReminderService):
* - `event_reminder_default` catch-all, seeded at runtime
* - `event_reminder_<event_type slug_prefix>` per-type override
*
* Per-type templates that don't exist yet have their right-panel fields
* pre-filled from the default's content so admin can see what the
* customer would currently receive and decide whether to override.
* First Save on such a row hits POST /admin/email/templates which
* creates the per-type row.
*/
import React, { useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Link } from 'react-router-dom';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { toast } from 'react-toastify';
import { ArrowLeft, Save, AlertTriangle } from 'lucide-react';
import { Button, Card, Loading, Input } from '../../../components/common';
import { SUPPORTED_LANGUAGES } from '../../../components/common/LanguageSelector';
import { EmailTemplateEditor } from '../../../components/admin/EmailTemplateEditor';
import { eventTypesService } from '../../../services/eventTypes.service';
import { emailService, type EmailTemplateTranslation } from '../../../services/email.service';
import { settingsService } from '../../../services/settings.service';
const TEMPLATE_KEY_DEFAULT = 'event_reminder_default';
const TEMPLATE_KEY_PREFIX = 'event_reminder_';
const VARIABLES = [
'customer_name', 'event_name', 'event_date',
'event_type', 'days_before', 'business_name',
];
interface SidebarRow {
key: string;
label: string;
emoji: string;
isDefault: boolean;
hasTemplate: boolean;
}
export const ReminderTemplatesPage: React.FC = () => {
const { t } = useTranslation();
const queryClient = useQueryClient();
// ---- Global toggles ---------------------------------------------------
// Only two keys are read on this page — fetch just those instead of
// the full ~100-row app_settings dict. Saves transferring + parsing
// every unrelated setting.
const { data: settings } = useQuery({
queryKey: ['reminder-settings'],
queryFn: () => settingsService.getSettings([
'crm_event_reminders_enabled',
'crm_event_reminders_days_before',
]),
});
const [enabled, setEnabled] = useState<boolean>(false);
const [daysBefore, setDaysBefore] = useState<number>(2);
useEffect(() => {
if (!settings) return;
const e = settings.crm_event_reminders_enabled;
setEnabled(e === true || e === 'true' || e === 1 || e === '1');
const d = Number(settings.crm_event_reminders_days_before);
setDaysBefore(Number.isFinite(d) ? d : 2);
}, [settings]);
const saveSettingsMutation = useMutation({
mutationFn: () => settingsService.updateSettings({
crm_event_reminders_enabled: enabled,
crm_event_reminders_days_before: daysBefore,
}),
onSuccess: () => {
toast.success(t('reminderTemplates.settingsSaved', 'Reminder settings saved.'));
queryClient.invalidateQueries({ queryKey: ['reminder-settings'] });
},
onError: () => toast.error(t('reminderTemplates.settingsSaveError', 'Could not save reminder settings.')),
});
// ---- Event types catalog ---------------------------------------------
const { data: eventTypes = [] } = useQuery({
queryKey: ['event-types-active'],
queryFn: () => eventTypesService.getActiveEventTypes(),
});
// ---- All templates (for the "has template?" indicator) ---------------
// GET /templates triggers the runtime self-heal on the backend, which
// backfills empty/missing event_reminder_* rows with the example
// content. By the time this list resolves, the default has content.
const { data: allTemplates = [] } = useQuery({
queryKey: ['email-templates'],
queryFn: () => emailService.getTemplates(),
});
const reminderTemplateKeys = useMemo(
() => new Set(allTemplates
.filter((t) => t.template_key.startsWith(TEMPLATE_KEY_PREFIX))
.map((t) => t.template_key)),
[allTemplates],
);
// ---- Sidebar rows ------------------------------------------------------
const sidebarRows: SidebarRow[] = useMemo(() => {
const rows: SidebarRow[] = [{
key: TEMPLATE_KEY_DEFAULT,
label: t('reminderTemplates.defaultLabel', 'Default (catch-all)'),
emoji: '✉️',
isDefault: true,
hasTemplate: reminderTemplateKeys.has(TEMPLATE_KEY_DEFAULT),
}];
for (const et of eventTypes) {
const key = `${TEMPLATE_KEY_PREFIX}${et.slug_prefix}`;
rows.push({
key,
label: et.name,
emoji: et.emoji || '📅',
isDefault: false,
hasTemplate: reminderTemplateKeys.has(key),
});
}
return rows;
}, [eventTypes, reminderTemplateKeys, t]);
// ---- Selection + per-template fetch ----------------------------------
const [selectedKey, setSelectedKey] = useState<string>(TEMPLATE_KEY_DEFAULT);
const [editingLang, setEditingLang] = useState<string>('en');
// Default template — always loaded so per-type rows that don't exist
// yet can prefill their form from it.
const { data: defaultTemplate } = useQuery({
queryKey: ['email-template', TEMPLATE_KEY_DEFAULT],
queryFn: () => emailService.getTemplate(TEMPLATE_KEY_DEFAULT),
enabled: reminderTemplateKeys.has(TEMPLATE_KEY_DEFAULT),
});
const { data: selectedTemplate, isLoading: selectedLoading } = useQuery({
queryKey: ['email-template', selectedKey],
queryFn: () => emailService.getTemplate(selectedKey),
enabled: reminderTemplateKeys.has(selectedKey),
});
// ---- Form state ------------------------------------------------------
// Six locales buffered locally — admin can flip between tabs without
// losing edits. Reset whenever the selection changes.
const emptyTranslation: EmailTemplateTranslation = { subject: '', body_html: '', body_text: '' };
const blankAllLangs = (): Record<string, EmailTemplateTranslation> => {
const out: Record<string, EmailTemplateTranslation> = {};
for (const lang of SUPPORTED_LANGUAGES) out[lang.code] = { ...emptyTranslation };
return out;
};
const [translations, setTranslations] = useState<Record<string, EmailTemplateTranslation>>(blankAllLangs);
useEffect(() => {
// Repopulate form from the resolved source:
// 1. existing template's translations (per-type that has its own row)
// 2. default template's translations (prefill for per-type without a row yet)
// 3. blank fallback
const source = selectedTemplate ?? (
!reminderTemplateKeys.has(selectedKey) && defaultTemplate ? defaultTemplate : null
);
const next = blankAllLangs();
if (source?.translations) {
for (const lang of SUPPORTED_LANGUAGES) {
const tr = source.translations[lang.code];
if (tr) next[lang.code] = {
subject: tr.subject || '',
body_html: tr.body_html || '',
body_text: tr.body_text || '',
};
}
}
setTranslations(next);
setEditingLang('en');
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [selectedKey, selectedTemplate, defaultTemplate]);
// ---- Save -------------------------------------------------------------
const saveMutation = useMutation({
mutationFn: async () => {
// Only send non-empty translations so we don't clobber DB rows
// for locales the admin hasn't touched.
const payloadTranslations: Record<string, EmailTemplateTranslation> = {};
for (const lang of SUPPORTED_LANGUAGES) {
const tr = translations[lang.code];
const s = (tr?.subject || '').trim();
const h = (tr?.body_html || '').trim();
const x = (tr?.body_text || '').trim();
if (s || h || x) payloadTranslations[lang.code] = tr;
}
if (reminderTemplateKeys.has(selectedKey)) {
await emailService.updateTemplate(selectedKey, { translations: payloadTranslations });
} else {
await emailService.createTemplate({
template_key: selectedKey,
translations: payloadTranslations,
category: 'crm',
subcategory: 'event_reminder',
feature_flag: 'crm_event_reminders_enabled',
variables: VARIABLES,
});
}
},
onSuccess: () => {
toast.success(t('reminderTemplates.saved', 'Template saved.'));
queryClient.invalidateQueries({ queryKey: ['email-templates'] });
queryClient.invalidateQueries({ queryKey: ['email-template', selectedKey] });
},
onError: (err: unknown) => {
const e = err as { response?: { data?: { error?: string } } };
toast.error(e?.response?.data?.error || t('reminderTemplates.saveError', 'Could not save template.'));
},
});
// Translation completeness pill for the sidebar — matches the email
// templates' "{count}/{total}" display.
const translationCount = (key: string) => {
const tpl = allTemplates.find((x) => x.template_key === key);
if (!tpl?.translations) return 0;
return SUPPORTED_LANGUAGES.filter((lang) => {
const tr = tpl.translations[lang.code];
return !!(tr && ((tr.subject || '').trim() || (tr.body_html || '').trim() || (tr.body_text || '').trim()));
}).length;
};
const currentTranslation = translations[editingLang] || emptyTranslation;
const setCurrentField = (field: keyof EmailTemplateTranslation, value: string) =>
setTranslations((prev) => ({
...prev,
[editingLang]: { ...prev[editingLang], [field]: value },
}));
const isNewPerType = !reminderTemplateKeys.has(selectedKey)
&& !sidebarRows.find((r) => r.key === selectedKey)?.isDefault;
const totalLangs = SUPPORTED_LANGUAGES.length;
return (
<div>
<div className="mb-4 flex items-center gap-3">
<Link to="/admin/settings/crm" className="p-2 -ml-2 rounded hover:bg-neutral-100 dark:hover:bg-neutral-700">
<ArrowLeft className="w-4 h-4" />
</Link>
<h1 className="text-2xl font-bold text-theme">
{t('reminderTemplates.title', 'Pre-event reminder emails')}
</h1>
</div>
{/* Global toggles strip */}
<Card className="mb-4">
<h3 className="font-semibold text-sm mb-2">
{t('reminderTemplates.globalSection', 'Global behaviour')}
</h3>
<p className="text-xs text-muted-theme mb-3">
{t('reminderTemplates.globalHelp',
'Off by default — turn on to start sending pre-event reminders. The offset below is the default; each event can override on its detail page.')}
</p>
<div className="flex items-center gap-6 flex-wrap">
<label className="inline-flex items-center gap-2 text-sm cursor-pointer">
<input
type="checkbox"
checked={enabled}
onChange={(e) => setEnabled(e.target.checked)}
/>
{t('reminderTemplates.enableLabel', 'Send pre-event reminder emails')}
</label>
<div className="flex items-center gap-2">
<label htmlFor="reminder-days-before" className="text-sm">
{t('reminderTemplates.daysBeforeLabel', 'Days before the event')}
</label>
<Input
id="reminder-days-before"
type="number"
min={0}
max={365}
value={daysBefore}
onChange={(e) => setDaysBefore(Number(e.target.value))}
className="w-24"
/>
</div>
<Button
variant="outline"
size="sm"
onClick={() => saveSettingsMutation.mutate()}
isLoading={saveSettingsMutation.isPending}
disabled={saveSettingsMutation.isPending}
leftIcon={<Save className="w-4 h-4" />}
>
{t('reminderTemplates.saveSettings', 'Save global settings')}
</Button>
</div>
</Card>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* Sidebar — same shape as EmailConfigPage + BlockLibraryPage. */}
<Card padding="sm">
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100">
{t('reminderTemplates.sidebarHeading', 'Templates')}
</h3>
</div>
<div className="space-y-5">
{/* Section header single section for now ("TEMPLATES"). The
contract block library uses 4-6 sections; we have one
conceptual grouping (default + per-type) so we render
one uppercase header and let the rows speak for the
taxonomy via the "Default" pill. */}
<div>
<h4 className="px-1 mb-2 text-[11px] font-semibold uppercase tracking-wider text-neutral-500 dark:text-neutral-400">
{t('reminderTemplates.sectionTemplates', 'Templates')}
</h4>
<div className="space-y-2">
{sidebarRows.map((row) => {
const isSelected = row.key === selectedKey;
const count = translationCount(row.key);
return (
<button
key={row.key}
onClick={() => setSelectedKey(row.key)}
className={`w-full text-left p-3 rounded-lg transition-colors ${
isSelected
? 'tile-selected'
: 'bg-neutral-50 dark:bg-neutral-700 border-2 border-transparent hover:bg-neutral-100 dark:hover:bg-neutral-600'
}`}
>
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-2 min-w-0">
<span className="text-base shrink-0">{row.emoji}</span>
<p className="font-medium text-neutral-900 dark:text-neutral-100 truncate">
{row.label}
</p>
</div>
<div className="flex items-center gap-1.5 flex-shrink-0">
{!row.hasTemplate && !row.isDefault && (
<span
className="text-[10px] uppercase tracking-wider px-1.5 py-0.5 rounded font-semibold bg-amber-100 text-amber-800 dark:bg-amber-900/40 dark:text-amber-300"
title={t('reminderTemplates.usesDefaultTooltip',
'No dedicated template yet — this event type falls back to the default. Edit + save here to create one.') as string}
>
{t('reminderTemplates.usesDefault', 'Default')}
</span>
)}
{row.hasTemplate && (
<span className="text-xs px-2 py-0.5 rounded-full bg-neutral-200 dark:bg-neutral-600 text-neutral-600 dark:text-neutral-300">
{count}/{totalLangs}
</span>
)}
</div>
</div>
</button>
);
})}
</div>
</div>
</div>
</Card>
{/* Right panel edit form, lg:col-span-2 to match the email +
contract editor proportions. */}
<div className="lg:col-span-2">
<Card padding="md">
{selectedLoading ? <Loading /> : (
<>
<div className="flex items-center justify-between mb-4 flex-wrap gap-2">
<h3 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100">
{sidebarRows.find((r) => r.key === selectedKey)?.label || selectedKey}
</h3>
<Button
variant="primary"
size="sm"
onClick={() => saveMutation.mutate()}
isLoading={saveMutation.isPending}
disabled={saveMutation.isPending}
leftIcon={<Save className="w-4 h-4" />}
>
{t('reminderTemplates.saveTemplate', 'Save template')}
</Button>
</div>
{/* Language tabs full SUPPORTED_LANGUAGES row with
flags and an amber bullet on locales the admin
hasn't filled. */}
<div className="flex flex-wrap gap-1 mb-4 p-1 bg-neutral-100 dark:bg-neutral-700 rounded-lg">
{SUPPORTED_LANGUAGES.map((lang) => {
const tr = translations[lang.code];
const filled = !!(tr && ((tr.subject || '').trim() || (tr.body_html || '').trim()));
return (
<button
key={lang.code}
onClick={() => setEditingLang(lang.code)}
className={`px-3 py-1.5 text-sm font-medium rounded-md transition-colors flex items-center gap-1.5 ${
editingLang === lang.code
? 'bg-white dark:bg-neutral-800 text-accent-dark shadow-sm'
: 'text-neutral-600 dark:text-neutral-400 hover:text-neutral-800 dark:hover:text-neutral-200'
}`}
>
<lang.Flag />
<span>{lang.name}</span>
{!filled && lang.code !== 'en' && (
<span
className="w-1.5 h-1.5 rounded-full bg-amber-400"
title={t('reminderTemplates.noTranslation', 'No translation yet') as string}
/>
)}
</button>
);
})}
</div>
{isNewPerType && (
<div className="rounded-md border border-amber-300 bg-amber-50 dark:bg-amber-900/20 dark:border-amber-700 p-3 mb-3 flex items-start gap-2">
<AlertTriangle className="w-4 h-4 text-amber-700 dark:text-amber-300 mt-0.5 shrink-0" />
<p className="text-sm text-amber-800 dark:text-amber-200">
{t('reminderTemplates.willCreateOnSave',
'This event type uses the default template. The fields below are pre-filled from the default; saving will create a dedicated template for this event type.')}
</p>
</div>
)}
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">
{t('reminderTemplates.subjectLabel', 'Subject')} ({SUPPORTED_LANGUAGES.find((l) => l.code === editingLang)?.name || editingLang})
</label>
<Input
type="text"
value={currentTranslation.subject || ''}
onChange={(e) => setCurrentField('subject', e.target.value)}
placeholder={t('reminderTemplates.subjectPlaceholder', 'Email subject') as string}
/>
</div>
<div>
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">
{t('reminderTemplates.bodyLabel', 'Body')} ({SUPPORTED_LANGUAGES.find((l) => l.code === editingLang)?.name || editingLang})
</label>
<EmailTemplateEditor
content={currentTranslation.body_html || ''}
onChange={(value) => setCurrentField('body_html', value)}
variables={VARIABLES}
/>
</div>
<p className="text-xs text-muted-theme">
{t('reminderTemplates.variablesHint',
'Available variables: {{customer_name}}, {{event_name}}, {{event_date}}, {{event_type}}, {{days_before}}, {{business_name}} — substituted when the email is rendered.')}
</p>
</div>
</>
)}
</Card>
</div>
</div>
</div>
);
};
export default ReminderTemplatesPage;
@@ -0,0 +1,529 @@
/**
* Settings Business profile tab.
*
* Issuer block + bank-account roster used by every quote / invoice PDF.
* Loads via businessProfileService.get(); each section persists via the
* matching service method.
*/
import React, { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { Plus, Trash2, Star, Pencil, Save } from 'lucide-react';
import {
businessProfileService,
type BusinessProfile,
type BankAccount,
type QrFormat,
} from '../../../services/businessProfile.service';
import { Button, Card, Loading, Input } from '../../../components/common';
import { toast } from 'react-toastify';
export const SettingsBusinessProfilePage: React.FC = () => {
const { t } = useTranslation();
const qc = useQueryClient();
const { data, isLoading } = useQuery({
queryKey: ['business-profile'],
queryFn: () => businessProfileService.get(),
});
const [profile, setProfile] = useState<BusinessProfile | null>(null);
useEffect(() => { if (data?.profile) setProfile(data.profile); }, [data]);
const saveProfile = useMutation({
mutationFn: () => profile ? businessProfileService.update(profile) : Promise.reject(),
onSuccess: () => {
toast.success(t('businessProfile.savedToast', 'Business profile saved.'));
qc.invalidateQueries({ queryKey: ['business-profile'] });
},
onError: (err: any) => toast.error(err?.response?.data?.error || 'Save failed'),
});
if (isLoading || !profile) return <Loading />;
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<div>
<h2 className="text-xl font-bold">{t('businessProfile.title', 'Business profile')}</h2>
<p className="text-sm text-neutral-600 dark:text-neutral-400">
{t('businessProfile.subtitle', 'Issuer block shown on every quote and invoice PDF.')}
</p>
</div>
<Button
onClick={() => saveProfile.mutate()}
disabled={saveProfile.isPending}
isLoading={saveProfile.isPending}
leftIcon={<Save className="w-4 h-4" />}
>
{t('common.save', 'Save')}
</Button>
</div>
<Card>
<h3 className="font-semibold mb-3">{t('businessProfile.section.company', 'Company')}</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
<Input label={t('businessProfile.field.companyName', 'Company name') as string} value={profile.companyName}
onChange={(e) => setProfile({ ...profile, companyName: e.target.value })} />
<Input label={t('businessProfile.field.vatId', 'VAT ID (USt-IdNr.)') as string} value={profile.vatId}
onChange={(e) => setProfile({ ...profile, vatId: e.target.value })} />
{/* Migration 139 Steuernummer. Distinct from VAT-ID. §14
UStG requires one or both on every invoice; many
Kleinunternehmer (§19 UStG) only have this. */}
<Input label={t('businessProfile.field.taxId', 'Tax number (Steuernummer)') as string}
value={profile.taxId}
onChange={(e) => setProfile({ ...profile, taxId: e.target.value })} />
<Input label={t('businessProfile.field.addressLine1', 'Address line 1') as string} value={profile.addressLine1}
onChange={(e) => setProfile({ ...profile, addressLine1: e.target.value })} />
<Input label={t('businessProfile.field.addressLine2', 'Address line 2') as string} value={profile.addressLine2}
onChange={(e) => setProfile({ ...profile, addressLine2: e.target.value })} />
<Input label={t('businessProfile.field.postalCode', 'Postal code') as string} value={profile.postalCode}
onChange={(e) => setProfile({ ...profile, postalCode: e.target.value })} />
<Input label={t('businessProfile.field.city', 'City') as string} value={profile.city}
onChange={(e) => setProfile({ ...profile, city: e.target.value })} />
<Input label={t('businessProfile.field.state', 'State / Region') as string} value={profile.state}
onChange={(e) => setProfile({ ...profile, state: e.target.value })} />
<Input label={t('businessProfile.field.countryCode', 'Country abbreviation (FL, CH, DE …)') as string}
value={profile.countryCode}
maxLength={2}
placeholder="FL"
onChange={(e) => setProfile({ ...profile, countryCode: e.target.value.toUpperCase() })} />
{/* Free-text country name override (migration 107). When
left empty the renderer falls back to the COUNTRY_NAMES
lookup on the abbreviation. */}
<Input label={t('businessProfile.field.countryName', 'Country (full name)') as string}
value={profile.countryName || ''}
placeholder="Liechtenstein"
onChange={(e) => setProfile({ ...profile, countryName: e.target.value })} />
</div>
</Card>
<Card>
<h3 className="font-semibold mb-3">{t('businessProfile.section.contact', 'Contact')}</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
<Input label={t('businessProfile.field.phone', 'Phone') as string} value={profile.phone}
onChange={(e) => setProfile({ ...profile, phone: e.target.value })} />
<Input label={t('businessProfile.field.mobile', 'Mobile') as string} value={profile.mobile}
onChange={(e) => setProfile({ ...profile, mobile: e.target.value })} />
<Input type="email" label={t('businessProfile.field.email', 'Email') as string} value={profile.email}
onChange={(e) => setProfile({ ...profile, email: e.target.value })} />
<Input label={t('businessProfile.field.website', 'Website') as string} value={profile.website}
onChange={(e) => setProfile({ ...profile, website: e.target.value })} />
</div>
</Card>
<Card>
<h3 className="font-semibold mb-3">{t('businessProfile.section.defaults', 'Defaults')}</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
<Input label={t('businessProfile.field.defaultCurrency', 'Default currency') as string} value={profile.defaultCurrency}
maxLength={3} onChange={(e) => setProfile({ ...profile, defaultCurrency: e.target.value.toUpperCase() })} />
<Input label={t('businessProfile.field.defaultLocale', 'Default locale') as string} value={profile.defaultLocale}
maxLength={8} onChange={(e) => setProfile({ ...profile, defaultLocale: e.target.value })} />
{/* Migration 137 IANA timezone string for the admin calendar.
Free-text; backend caps at 64 chars. When blank the calendar
UI falls back to the browser's `Intl.DateTimeFormat()
.resolvedOptions().timeZone`. */}
<Input
label={t('businessProfile.field.timezone', 'Timezone (IANA)') as string}
value={profile.timezone || ''}
maxLength={64}
placeholder={Intl.DateTimeFormat().resolvedOptions().timeZone}
onChange={(e) => setProfile({ ...profile, timezone: e.target.value || null })}
/>
<Input label={t('businessProfile.field.vatLabel', 'VAT label (e.g. MwSt., VAT)') as string} value={profile.vatLabel}
onChange={(e) => setProfile({ ...profile, vatLabel: e.target.value })} />
<Input type="number" step="0.01" label={t('businessProfile.field.vatRateDefault', 'Default VAT rate %') as string}
value={profile.vatRateDefault ?? 0}
onChange={(e) => setProfile({ ...profile, vatRateDefault: Number(e.target.value) })} />
<div>
<label className="block text-sm font-medium mb-1">{t('businessProfile.field.defaultQrFormat', 'Default invoice QR')}</label>
<select value={profile.defaultQrFormat} onChange={(e) => setProfile({ ...profile, defaultQrFormat: e.target.value as QrFormat })}
className="w-full px-3 py-2 rounded-md border border-neutral-300 dark:border-neutral-600 bg-white dark:bg-neutral-800 text-sm">
<option value="none">{t('businessProfile.qrFormat.none', 'None')}</option>
<option value="swiss">{t('businessProfile.qrFormat.swiss', 'Swiss QR-bill (CH / LI)')}</option>
<option value="epc">{t('businessProfile.qrFormat.epc', 'EPC QR (SEPA / EUR)')}</option>
</select>
</div>
<Input label={t('businessProfile.field.footerLine', 'PDF footer line') as string} value={profile.footerLine}
onChange={(e) => setProfile({ ...profile, footerLine: e.target.value })} />
{/* Dedicated PDF letterhead logo separate from the global
Settings Branding logo. SVG accepted (rasterised to PNG
on the fly). When unset the renderer falls back to the
branding logo. */}
<div className="md:col-span-2">
<PdfLogoUploader profile={profile} setProfile={setProfile} />
</div>
{/* Logo banner height (pt) — admin-adjustable per migration 108. */}
<Input type="number" min={24} max={200}
label={t('businessProfile.field.pdfLogoHeight', 'PDF logo height (pt, 24-200)') as string}
value={profile.pdfLogoHeight ?? 56}
onChange={(e) => setProfile({ ...profile, pdfLogoHeight: Number(e.target.value) })} />
{/* Folding marks dropdown. */}
<div>
<label className="block text-sm font-medium mb-1">
{t('businessProfile.field.pdfFoldingMarks', 'Folding marks on PDF page edge')}
</label>
<select value={profile.pdfFoldingMarks || 'none'}
onChange={(e) => setProfile({ ...profile, pdfFoldingMarks: e.target.value as any })}
className="w-full px-3 py-2 rounded-md border border-neutral-300 dark:border-neutral-600 bg-white dark:bg-neutral-800 text-sm">
<option value="none">{t('businessProfile.foldingMarks.none', 'None')}</option>
<option value="half">{t('businessProfile.foldingMarks.half', 'Half (148.5mm) — for C5 envelopes')}</option>
<option value="third">{t('businessProfile.foldingMarks.third', 'Thirds (105 + 210mm) — for DL / DIN long envelopes')}</option>
<option value="both">{t('businessProfile.foldingMarks.both', 'All three marks')}</option>
</select>
</div>
{/* PDF font selection lives on Settings Branding now
(migration 121, "PDF typography" card). The legacy
free-text TTF path input was retired in favour of the
bundled-fonts dropdown there. The column
`pdf_font_ttf_path` stays on the row as a power-user
override that the PDF renderer still honours when
populated directly in the DB. */}
</div>
{/* PDF letterhead visibility toggles let the admin suppress
the logo or the company-name line independently. Useful
when the logo itself already contains the brand name
(very common with wordmark logos). Lifted out of the input
grid so the toggle switches don't fight the field sizing. */}
<div className="mt-4 pt-4 border-t border-neutral-200 dark:border-neutral-700 space-y-3">
<PdfToggleRow
label={t('businessProfile.field.pdfShowLogo', 'Show logo in PDF letterhead') as string}
description={t('businessProfile.field.pdfShowLogoHelp',
'When off, the logo image is suppressed on every PDF even if a logo is uploaded.') as string}
enabled={profile.pdfShowLogo}
onChange={(v) => setProfile({ ...profile, pdfShowLogo: v })}
/>
<PdfToggleRow
label={t('businessProfile.field.pdfShowCompanyName', 'Show company name in PDF letterhead') as string}
description={t('businessProfile.field.pdfShowCompanyNameHelp',
'When off, the company-name line is suppressed (useful if the logo is a wordmark already containing the name).') as string}
enabled={profile.pdfShowCompanyName}
onChange={(v) => setProfile({ ...profile, pdfShowCompanyName: v })}
/>
<PdfToggleRow
label={t('businessProfile.field.pdfCompanyNameInline', 'Render company name inline with address') as string}
description={t('businessProfile.field.pdfCompanyNameInlineHelp',
'When on, the company name appears as a plain line directly above the street address (same size + weight). When off, it renders as a bold title under the logo.') as string}
enabled={profile.pdfCompanyNameInline}
onChange={(v) => setProfile({ ...profile, pdfCompanyNameInline: v })}
/>
{/* Quote payment-block toggles (migration 110). Both default
OFF a quote is an offer, not a demand for payment, and
the IBAN block is always invoice-only. Admins opt in when
they want to set payment expectations on the quote. */}
<PdfToggleRow
label={t('businessProfile.field.pdfQuoteShowNetDays', 'Show net payment days on quote PDFs') as string}
description={t('businessProfile.field.pdfQuoteShowNetDaysHelp',
'When on, quote PDFs include the "X days from invoice date." line in the payment conditions block. Invoices always show this row regardless.') as string}
enabled={profile.pdfQuoteShowNetDays}
onChange={(v) => setProfile({ ...profile, pdfQuoteShowNetDays: v })}
/>
<PdfToggleRow
label={t('businessProfile.field.pdfQuoteShowSkonto', 'Show Skonto / early-payment discount on quote PDFs') as string}
description={t('businessProfile.field.pdfQuoteShowSkontoHelp',
'When on, quote PDFs include the Skonto offer and the "Amount with discount" line. Invoices always show these regardless.') as string}
enabled={profile.pdfQuoteShowSkonto}
onChange={(v) => setProfile({ ...profile, pdfQuoteShowSkonto: v })}
/>
</div>
</Card>
{/* Disclaimer banner for QR-bill / IBAN data. picpeak renders
what the operator types it cannot validate IBAN/BIC, QR-IID
or scan-compatibility with any specific bank's e-banking app.
See docs/crm-disclaimers.md. */}
<div className="mt-4 p-3 rounded-md border border-amber-200 dark:border-amber-800 bg-amber-50 dark:bg-amber-950/30 text-sm text-amber-900 dark:text-amber-200">
<p className="font-medium mb-1">
{t('businessProfile.qrDisclaimer.title', 'QR-bill / bank data — verify before going live')}
</p>
<p className="text-xs">
{t(
'businessProfile.qrDisclaimer.body',
"Picpeak is open source. We render the QR code and IBAN block from the values you typed — we don't validate them. Print a test invoice and scan it with your bank's app before sending real invoices. We are not responsible for any mistakes that come from sending an invoice with bad data on it.",
)}
</p>
</div>
<BankAccountsSection accounts={data?.bankAccounts ?? []} />
</div>
);
};
/**
* Toggle row used for the PDF letterhead visibility options. Same
* accessible-switch markup as the customer feature toggles on
* CustomerDetailPage so the styling is consistent across the admin.
*/
interface PdfToggleRowProps {
label: string;
description?: string;
enabled: boolean;
onChange: (next: boolean) => void;
}
const PdfToggleRow: React.FC<PdfToggleRowProps> = ({ label, description, enabled, onChange }) => (
<label className="flex items-center justify-between gap-4 cursor-pointer">
<span className="text-sm">
<span className="font-medium text-neutral-900 dark:text-neutral-100">{label}</span>
{description && (
<span className="block text-xs text-neutral-500 dark:text-neutral-400 mt-0.5">{description}</span>
)}
</span>
<button
type="button"
role="switch"
aria-checked={enabled}
onClick={() => onChange(!enabled)}
className="relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 shrink-0"
style={{ backgroundColor: enabled ? 'var(--color-accent)' : 'var(--color-surface-border)' }}
>
<span
className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${enabled ? 'translate-x-6' : 'translate-x-1'}`}
/>
</button>
</label>
);
/**
* Dedicated PDF letterhead logo uploader. Accepts PNG / JPEG / SVG;
* the backend rasterises SVG to PNG via sharp so vector uploads work
* in print. Stores the relative path on business_profile.logo_path.
*/
interface PdfLogoUploaderProps {
profile: BusinessProfile;
setProfile: (next: BusinessProfile) => void;
}
const PdfLogoUploader: React.FC<PdfLogoUploaderProps> = ({ profile, setProfile }) => {
const { t } = useTranslation();
const qc = useQueryClient();
const [uploading, setUploading] = useState(false);
const inputRef = React.useRef<HTMLInputElement>(null);
const onPick = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
setUploading(true);
try {
const { logoPath } = await businessProfileService.uploadLogo(file);
setProfile({ ...profile, logoPath });
qc.invalidateQueries({ queryKey: ['business-profile'] });
toast.success(t('businessProfile.logoUploadedToast', 'PDF logo uploaded.'));
} catch (err: any) {
toast.error(err?.response?.data?.error || 'Upload failed');
} finally {
setUploading(false);
if (inputRef.current) inputRef.current.value = '';
}
};
const onClear = async () => {
if (!window.confirm(t('businessProfile.logoConfirmClear', 'Remove the PDF logo? The renderer will fall back to the Branding logo if one is set.'))) return;
setUploading(true);
try {
await businessProfileService.clearLogo();
setProfile({ ...profile, logoPath: '' });
qc.invalidateQueries({ queryKey: ['business-profile'] });
toast.success(t('businessProfile.logoClearedToast', 'PDF logo removed.'));
} catch (err: any) {
toast.error(err?.response?.data?.error || 'Clear failed');
} finally {
setUploading(false);
}
};
return (
<div>
<label className="block text-sm font-medium mb-1">
{t('businessProfile.field.pdfLogoUpload', 'PDF letterhead logo (PNG, JPEG, or SVG)')}
</label>
<div className="flex items-center gap-3 flex-wrap">
<input
ref={inputRef}
type="file"
accept="image/png,image/jpeg,image/svg+xml"
onChange={onPick}
disabled={uploading}
className="text-sm file:mr-3 file:rounded-md file:border-0 file:bg-primary-600 file:px-3 file:py-1.5 file:text-sm file:font-medium file:text-white hover:file:bg-primary-700 disabled:opacity-60"
/>
{profile.logoPath && (
<>
<span className="text-xs text-neutral-500 font-mono break-all">{profile.logoPath}</span>
<Button variant="outline" size="sm" onClick={onClear} disabled={uploading}>
{t('common.remove', 'Remove')}
</Button>
</>
)}
</div>
<p className="text-xs text-neutral-500 mt-1">
{t('businessProfile.field.pdfLogoUploadHelp',
'Used on every quote and invoice PDF. SVG is accepted and rasterised to PNG automatically. When empty, the renderer falls back to the global Branding logo.')}
</p>
</div>
);
};
interface BankAccountsSectionProps { accounts: BankAccount[] }
type BankDraft = {
label: string;
accountHolder: string;
iban: string;
bic: string;
currency: string;
isDefault: boolean;
};
const EMPTY_DRAFT: BankDraft = {
label: '', accountHolder: '', iban: '', bic: '', currency: 'CHF', isDefault: false,
};
const BankAccountsSection: React.FC<BankAccountsSectionProps> = ({ accounts }) => {
const { t } = useTranslation();
const qc = useQueryClient();
// null = no form open; 'new' = adding; numeric id = editing that row.
// Single piece of state means only one form is open at a time, which
// is what the maintainer asked for: Add OR Edit, never both.
const [openForm, setOpenForm] = useState<null | 'new' | number>(null);
const [draft, setDraft] = useState<BankDraft>(EMPTY_DRAFT);
const startEdit = (a: BankAccount) => {
setDraft({
label: a.label || '',
accountHolder: a.accountHolder || '',
iban: a.iban || '',
bic: a.bic || '',
currency: a.currency || 'CHF',
isDefault: !!a.isDefault,
});
setOpenForm(a.id);
};
const closeForm = () => { setOpenForm(null); setDraft(EMPTY_DRAFT); };
const create = useMutation({
mutationFn: () => businessProfileService.createBankAccount(draft),
onSuccess: () => {
toast.success(t('businessProfile.bankCreatedToast', 'Bank account added.'));
qc.invalidateQueries({ queryKey: ['business-profile'] });
closeForm();
},
onError: (err: any) => toast.error(err?.response?.data?.error || 'Failed'),
});
const update = useMutation({
mutationFn: (id: number) => businessProfileService.updateBankAccount(id, draft),
onSuccess: () => {
toast.success(t('businessProfile.bankUpdatedToast', 'Bank account updated.'));
qc.invalidateQueries({ queryKey: ['business-profile'] });
closeForm();
},
onError: (err: any) => toast.error(err?.response?.data?.error || 'Failed'),
});
const setDefault = useMutation({
mutationFn: (id: number) => businessProfileService.updateBankAccount(id, { isDefault: true }),
onSuccess: () => qc.invalidateQueries({ queryKey: ['business-profile'] }),
});
const remove = useMutation({
mutationFn: (id: number) => businessProfileService.deleteBankAccount(id),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['business-profile'] });
toast.success(t('businessProfile.bankDeletedToast', 'Bank account removed.'));
},
});
// Form body reused for both Add and Edit (single source of layout).
const renderForm = (mode: 'new' | 'edit', onSubmit: () => void, submitting: boolean) => (
<div className="mb-4 p-3 rounded-md border border-neutral-200 dark:border-neutral-700 bg-neutral-50 dark:bg-neutral-800">
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
<Input label={t('businessProfile.bank.label', 'Label') as string} value={draft.label}
onChange={(e) => setDraft({ ...draft, label: e.target.value })} />
<Input label={t('businessProfile.bank.accountHolder', 'Account holder') as string} value={draft.accountHolder}
onChange={(e) => setDraft({ ...draft, accountHolder: e.target.value })} />
<Input label="IBAN" value={draft.iban}
onChange={(e) => setDraft({ ...draft, iban: e.target.value })} />
<Input label="BIC" value={draft.bic}
onChange={(e) => setDraft({ ...draft, bic: e.target.value })} />
<Input label={t('businessProfile.bank.currency', 'Currency') as string} value={draft.currency}
maxLength={3} onChange={(e) => setDraft({ ...draft, currency: e.target.value.toUpperCase() })} />
<label className="flex items-center gap-2 text-sm pt-6">
<input type="checkbox" checked={draft.isDefault}
onChange={(e) => setDraft({ ...draft, isDefault: e.target.checked })} />
{t('businessProfile.bank.isDefault', 'Default for this currency')}
</label>
</div>
<div className="flex justify-end gap-2 mt-3">
<Button variant="outline" size="sm" onClick={closeForm}>{t('common.cancel', 'Cancel')}</Button>
<Button
size="sm"
onClick={onSubmit}
disabled={!draft.iban || submitting}
isLoading={submitting}
leftIcon={mode === 'new' ? <Plus className="w-4 h-4" /> : <Save className="w-4 h-4" />}
>
{mode === 'new' ? t('common.add', 'Add') : t('common.save', 'Save')}
</Button>
</div>
</div>
);
return (
<Card>
<div className="flex items-center justify-between mb-3">
<h3 className="font-semibold">{t('businessProfile.section.banks', 'Bank accounts')}</h3>
<Button size="sm" onClick={() => {
if (openForm === 'new') closeForm();
else { setDraft(EMPTY_DRAFT); setOpenForm('new'); }
}}>
<Plus className="w-4 h-4 mr-1" />{t('businessProfile.addBank', 'Add account')}
</Button>
</div>
{openForm === 'new' && renderForm('new', () => create.mutate(), create.isPending)}
{accounts.length === 0 ? (
<p className="text-sm text-neutral-500">{t('businessProfile.noBanks', 'No bank accounts configured yet.')}</p>
) : (
<ul className="divide-y divide-neutral-200 dark:divide-neutral-700">
{accounts.map((b) => (
<React.Fragment key={b.id}>
<li className="py-2 flex items-center justify-between">
<div>
<div className="font-medium text-sm">{b.label || b.iban}
{b.isDefault && <Star className="inline w-4 h-4 ml-1 text-amber-500" />}
</div>
<div className="text-xs text-neutral-500 font-mono">{b.iban.replace(/(.{4})/g, '$1 ').trim()}{b.currency ? ` · ${b.currency}` : ''}</div>
</div>
<div className="flex gap-2">
{!b.isDefault && (
<Button variant="outline" size="sm" onClick={() => setDefault.mutate(b.id)}>
{t('businessProfile.bank.makeDefault', 'Make default')}
</Button>
)}
<Button variant="outline" size="sm" onClick={() => {
if (openForm === b.id) closeForm();
else startEdit(b);
}}
title={t('common.edit', 'Edit') as string}>
<Pencil className="w-4 h-4" />
</Button>
<Button variant="outline" size="sm" onClick={() => {
if (window.confirm(t('businessProfile.bank.confirmDelete', 'Remove this bank account?'))) remove.mutate(b.id);
}}
title={t('common.delete', 'Delete') as string}>
<Trash2 className="w-4 h-4 text-red-600" />
</Button>
</div>
</li>
{openForm === b.id && (
<li className="py-2">
{renderForm('edit', () => update.mutate(b.id), update.isPending)}
</li>
)}
</React.Fragment>
))}
</ul>
)}
</Card>
);
};
+330 -12
View File
@@ -1,15 +1,333 @@
import React from 'react';
import { Receipt } from 'lucide-react';
import { CustomerComingSoonPage } from './CustomerComingSoonPage';
/**
* Customer-side Invoices list. Read-only view of every invoice that
* has been sent (or is overdue / paid). Scheduled drafts and
* cancelled invoices are hidden server-side so the customer never
* sees admin's in-progress work.
*
* Each row exposes a "View PDF" link that opens the rendered invoice
* in a new tab via a popup-blocker-safe sync window.open.
*
* Adds client-side sort + status filter controls (newest, oldest,
* price /; status: all / sent / paid / overdue / outstanding).
* "outstanding" rolls up everything still owing (sent + overdue).
*/
import React, { useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useQuery } from '@tanstack/react-query';
import { Receipt, Download } from 'lucide-react';
import { customerService, type CustomerInvoice } from '../../services/customer.service';
import { Card, Loading } from '../../components/common';
import { toast } from 'react-toastify';
import { formatMoney } from '../../utils/money';
import { formatShortDate } from '../../utils/dateShort';
export const CustomerBillsPage: React.FC = () => (
<CustomerComingSoonPage
titleKey="customer.bills.title"
titleFallback="Bills"
bodyKey="customer.bills.body"
bodyFallback="Invoices and payment history for your sessions will be available here. We'll send you an email when this is live."
icon={Receipt}
/>
);
type SortKey = 'newest' | 'oldest' | 'price_desc' | 'price_asc';
type StatusFilter = 'all' | 'sent' | 'paid' | 'overdue' | 'outstanding';
const SORT_OPTIONS: { value: SortKey; key: string; fallback: string }[] = [
{ value: 'newest', key: 'customer.sort.newest', fallback: 'Newest first' },
{ value: 'oldest', key: 'customer.sort.oldest', fallback: 'Oldest first' },
{ value: 'price_desc', key: 'customer.sort.priceDesc', fallback: 'Price (high to low)' },
{ value: 'price_asc', key: 'customer.sort.priceAsc', fallback: 'Price (low to high)' },
];
const STATUS_OPTIONS: { value: StatusFilter; key: string; fallback: string }[] = [
{ value: 'all', key: 'customer.filter.all', fallback: 'All' },
{ value: 'sent', key: 'bills.status.sent', fallback: 'Open / sent' },
{ value: 'paid', key: 'bills.status.paid', fallback: 'Paid' },
{ value: 'overdue', key: 'bills.status.overdue', fallback: 'Overdue' },
{ value: 'outstanding', key: 'customer.bills.filter.outstanding', fallback: 'Outstanding (sent + overdue)' },
];
export const CustomerBillsPage: React.FC = () => {
const { t } = useTranslation();
const { data, isLoading, isError, error } = useQuery({
queryKey: ['customer-invoices'],
queryFn: () => customerService.listInvoices(),
});
const [sort, setSort] = useState<SortKey>('newest');
const [statusFilter, setStatusFilter] = useState<StatusFilter>('all');
const visible = useMemo(() => {
const rows = data || [];
const filtered = rows.filter((inv) => {
if (statusFilter === 'all') return true;
if (statusFilter === 'outstanding') {
return inv.status === 'sent' || inv.status === 'overdue';
}
return inv.status === statusFilter;
});
const sorted = [...filtered].sort((a, b) => {
switch (sort) {
case 'oldest':
return new Date(a.issueDate).getTime() - new Date(b.issueDate).getTime();
case 'price_desc':
return Number(b.totalAmountMinor || 0) - Number(a.totalAmountMinor || 0);
case 'price_asc':
return Number(a.totalAmountMinor || 0) - Number(b.totalAmountMinor || 0);
case 'newest':
default:
return new Date(b.issueDate).getTime() - new Date(a.issueDate).getTime();
}
});
return sorted;
}, [data, sort, statusFilter]);
if (isLoading) return <Loading />;
if (isError) {
const status = (error as any)?.response?.status;
if (status === 403) {
return (
<div className="container py-8">
<h1 className="text-2xl font-bold mb-2">{t('customer.bills.title', 'Invoices')}</h1>
<p className="text-muted-theme">
{t('customer.bills.disabled',
'This feature is currently disabled for your account. Please contact your photographer if you expected to see invoices here.')}
</p>
</div>
);
}
return (
<div className="container py-8">
<p className="text-red-600">{t('customer.bills.loadError', 'Could not load invoices.')}</p>
</div>
);
}
const invoices = data || [];
const handleViewPdf = async (inv: CustomerInvoice) => {
// Sync-open the window so the popup blocker treats this as a
// user gesture, then redirect to the blob URL once it's ready.
const win = window.open('about:blank', '_blank');
if (!win) {
toast.error(t('customer.bills.popupBlocked', 'Allow pop-ups for this site to view the invoice PDF.'));
return;
}
try {
const url = await customerService.invoicePdfUrl(inv.id);
win.location.href = url;
} catch (err: any) {
win.close();
toast.error(err?.response?.data?.error || err.message || 'Failed to load invoice PDF');
}
};
return (
<div className="container py-6">
<div className="mb-6">
<h1 className="text-2xl font-bold text-theme flex items-center gap-2">
<Receipt className="w-6 h-6" />
{t('customer.bills.title', 'Invoices')}
</h1>
<p className="text-sm text-muted-theme mt-1">
{t('customer.bills.subtitle',
'Every invoice your photographer has sent you. Click "View PDF" to download.')}
</p>
</div>
{invoices.length === 0 ? (
<Card padding="lg">
<p className="text-center text-muted-theme py-8">
{t('customer.bills.empty', 'No invoices yet.')}
</p>
</Card>
) : (
<>
<FilterSortBar
sort={sort} onSortChange={setSort}
statusFilter={statusFilter} onStatusChange={setStatusFilter}
statusOptions={STATUS_OPTIONS}
totalRowCount={invoices.length}
visibleRowCount={visible.length}
/>
<Card padding="none">
<ul className="divide-y" style={{ borderColor: 'var(--color-surface-border)' }}>
{visible.map((inv) => (
<InvoiceRow key={inv.id} inv={inv} onViewPdf={() => handleViewPdf(inv)} />
))}
</ul>
</Card>
</>
)}
</div>
);
};
interface FilterSortBarProps<S extends string> {
sort: SortKey;
onSortChange: (v: SortKey) => void;
statusFilter: S;
onStatusChange: (v: S) => void;
statusOptions: { value: S; key: string; fallback: string }[];
totalRowCount: number;
visibleRowCount: number;
}
function FilterSortBar<S extends string>({
sort, onSortChange, statusFilter, onStatusChange, statusOptions,
totalRowCount, visibleRowCount,
}: FilterSortBarProps<S>) {
const { t } = useTranslation();
return (
<div className="flex flex-wrap items-center justify-between gap-3 mb-3">
<div className="flex flex-wrap items-center gap-2">
<label className="text-xs text-muted-theme uppercase tracking-wider">
{t('customer.filter.label', 'Filter')}
</label>
<select
value={statusFilter}
onChange={(e) => onStatusChange(e.target.value as S)}
className="text-sm px-2 py-1 rounded border"
style={{
backgroundColor: 'var(--color-surface)',
borderColor: 'var(--color-surface-border)',
color: 'var(--color-text)',
}}
>
{statusOptions.map((o) => (
<option key={o.value} value={o.value}>{t(o.key, o.fallback)}</option>
))}
</select>
<label className="text-xs text-muted-theme uppercase tracking-wider ml-2">
{t('customer.sort.label', 'Sort')}
</label>
<select
value={sort}
onChange={(e) => onSortChange(e.target.value as SortKey)}
className="text-sm px-2 py-1 rounded border"
style={{
backgroundColor: 'var(--color-surface)',
borderColor: 'var(--color-surface-border)',
color: 'var(--color-text)',
}}
>
{SORT_OPTIONS.map((o) => (
<option key={o.value} value={o.value}>{t(o.key, o.fallback)}</option>
))}
</select>
</div>
<div className="text-xs text-muted-theme">
{visibleRowCount === totalRowCount
? t('customer.filter.countAll', '{{count}} total', { count: totalRowCount })
: t('customer.filter.countFiltered', '{{visible}} of {{total}}', { visible: visibleRowCount, total: totalRowCount })}
</div>
</div>
);
}
const InvoiceRow: React.FC<{ inv: CustomerInvoice; onViewPdf: () => void }> = ({ inv, onViewPdf }) => {
const { t } = useTranslation();
const total = Number(inv.totalAmountMinor || 0) / 100;
const lateFee = Number(inv.lateFeeAmountMinor || 0) / 100;
const paid = Number(inv.paidAmountMinor || 0) / 100;
// Server-side `status` is the source of truth — when it's 'paid',
// outstanding MUST be 0 regardless of (total - paid). The Skonto
// path (migration 126) intentionally flips status to 'paid' while
// paid_amount_minor stays below total_amount_minor by the discount;
// raw subtraction would otherwise leave the customer reading "you
// still owe the Skonto amount".
const isPaid = inv.status === 'paid';
const outstanding = isPaid ? 0 : Math.max(0, total - paid);
const isStorno = inv.kind === 'storno';
const isCancelled = inv.status === 'cancelled';
// Outstanding hidden for Storni (negative total → meaningless) and
// cancelled invoices (no longer owed). Total still renders so the
// customer's bookkeeper can match line totals between the document
// pair.
const showOutstanding = !isStorno && !isCancelled && outstanding > 0;
const statusClass = isStorno
? 'bg-purple-100 text-purple-800 dark:bg-purple-900/40 dark:text-purple-300'
: inv.status === 'paid' ? 'bg-green-100 text-green-800 dark:bg-green-900/40 dark:text-green-300'
: inv.status === 'overdue' ? 'bg-red-100 text-red-800 dark:bg-red-900/40 dark:text-red-300'
: inv.status === 'cancelled' ? 'bg-neutral-200 text-neutral-700 dark:bg-neutral-700 dark:text-neutral-300'
: 'bg-blue-100 text-blue-800 dark:bg-blue-900/40 dark:text-blue-300';
// Cancelled invoices and Storni display the absolute total. The
// row-level totals on Storno rows are stored negative in the DB,
// but the customer-facing list reads more naturally with the
// amount and a sign-cue badge alongside ("Cancellation invoice")
// than with a leading minus on the number itself.
const displayTotal = Math.abs(total);
return (
<li className={`px-4 py-3 ${isStorno || isCancelled ? 'bg-neutral-50/50 dark:bg-neutral-800/30' : ''}`}>
<div className="flex items-center justify-between gap-3 flex-wrap">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2 flex-wrap">
<span className="font-mono text-sm">{inv.invoiceNumber}</span>
<span className={`px-2 py-0.5 rounded text-xs font-medium ${statusClass}`}>
{isStorno
? t('customer.bills.kind.storno', 'Cancellation invoice')
: t(`bills.status.${inv.status}`, inv.status)}
</span>
{inv.installmentTotal > 1 && !isStorno && (
<span className="text-xs text-muted-theme">
{inv.installmentIndex + 1}/{inv.installmentTotal}
{inv.installmentLabel ? ` · ${inv.installmentLabel}` : ''}
</span>
)}
</div>
{/* Event label (migration 123 snapshot). Mirrors the customer
quotes view so an invoice for the same event is visually
grouped at a glance. Omit when no event is set rather than
rendering a stray em-dash. */}
{inv.eventName && (
<div className="text-sm text-muted-theme mt-1 truncate">
{inv.eventName}
{inv.eventDate ? ` · ${formatShortDate(inv.eventDate)}` : ''}
</div>
)}
<div className="text-sm text-muted-theme mt-1">
{t('customer.bills.field.issueDate', 'Issued')}: {formatShortDate(inv.issueDate)}
{!isStorno && inv.dueDate && (
<> {' · '} {t('customer.bills.field.dueDate', 'Due')}: {formatShortDate(inv.dueDate)}</>
)}
{inv.paidAt && !isStorno && (
<> {' · '} {t('customer.bills.field.paidAt', 'Paid')}: {formatShortDate(inv.paidAt)}</>
)}
</div>
{/* Lineage hint the legal pair must be discoverable from
either end. Storno rows surface the invoice they cancel;
cancelled invoices surface the Storno that reversed them
(matching the PDF's reference line so the customer's
bookkeeper can find both documents). */}
{isStorno && inv.cancelsInvoiceId && (
<div className="text-xs text-purple-700 dark:text-purple-300 mt-1">
{t('customer.bills.cancelsLabel', 'Cancels invoice')}{' '}
{inv.cancelsInvoiceNumber || `#${inv.cancelsInvoiceId}`}
</div>
)}
{!isStorno && isCancelled && inv.cancellationStornoId && (
<div className="text-xs text-neutral-600 dark:text-neutral-400 mt-1">
{t('customer.bills.cancelledByLabel', 'Cancelled by cancellation invoice')}{' '}
{inv.cancellationStornoNumber || `#${inv.cancellationStornoId}`}
</div>
)}
</div>
<div className="text-right">
<div className={`font-medium tabular-nums ${isStorno ? 'text-purple-700 dark:text-purple-300' : ''}`}>
{isStorno ? '-' : ''}{formatMoney(displayTotal, inv.currency)}
</div>
{showOutstanding && (
<div className="text-xs text-red-700 dark:text-red-400 mt-0.5">
{t('customer.bills.field.outstanding', 'Outstanding')}: {formatMoney(outstanding, inv.currency)}
</div>
)}
{lateFee > 0 && !isStorno && (
<div className="text-xs text-amber-700 dark:text-amber-400 mt-0.5">
{t('customer.bills.field.lateFee', 'Late fee')}: {formatMoney(lateFee, inv.currency)}
</div>
)}
<button type="button" onClick={onViewPdf}
className="text-xs text-primary-600 dark:text-primary-400 mt-1 inline-flex items-center gap-1 hover:underline">
<Download className="w-3 h-3" />
{t('customer.bills.viewPdf', 'View PDF')}
</button>
</div>
</div>
</li>
);
};
export default CustomerBillsPage;
@@ -0,0 +1,243 @@
/**
* Customer-side Contracts list. Read-only view of every contract the
* photographer has sent. Mirrors CustomerQuotesPage in shape:
* - status filter + sort
* - "Open & sign" link on `sent` rows (deep-link to the public page)
* - "Download PDF" on any non-cancelled row prefers the signed PDF
* when present, otherwise the system-rendered copy
*/
import React, { useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useQuery } from '@tanstack/react-query';
import { ScrollText, ExternalLink, Download } from 'lucide-react';
import { customerService, type CustomerContract } from '../../services/customer.service';
import { Card, Loading } from '../../components/common';
import { toast } from 'react-toastify';
import { formatShortDate } from '../../utils/dateShort';
type SortKey = 'newest' | 'oldest';
type StatusFilter =
| 'all'
| 'sent'
| 'signed_by_customer'
| 'signed_by_admin'
| 'fully_signed'
| 'cancelled';
const STATUS_OPTIONS: { value: StatusFilter; key: string; fallback: string }[] = [
{ value: 'all', key: 'customer.filter.all', fallback: 'All' },
{ value: 'sent', key: 'contracts.status.sent', fallback: 'Awaiting signature' },
{ value: 'signed_by_customer', key: 'contracts.status.signed_by_customer', fallback: 'Signed by customer' },
{ value: 'signed_by_admin', key: 'contracts.status.signed_by_admin', fallback: 'Counter-signed' },
{ value: 'fully_signed', key: 'contracts.status.fully_signed', fallback: 'Fully signed' },
{ value: 'cancelled', key: 'contracts.status.cancelled', fallback: 'Cancelled' },
];
export const CustomerContractsPage: React.FC = () => {
const { t } = useTranslation();
const { data, isLoading, isError, error } = useQuery({
queryKey: ['customer-contracts'],
queryFn: () => customerService.listContracts(),
});
const [sort, setSort] = useState<SortKey>('newest');
const [statusFilter, setStatusFilter] = useState<StatusFilter>('all');
const visible = useMemo(() => {
const rows = data || [];
const filtered = statusFilter === 'all' ? rows : rows.filter((c) => c.status === statusFilter);
return [...filtered].sort((a, b) => {
const da = new Date(a.issueDate).getTime();
const db = new Date(b.issueDate).getTime();
return sort === 'oldest' ? da - db : db - da;
});
}, [data, sort, statusFilter]);
if (isLoading) return <Loading />;
if (isError) {
const status = (error as any)?.response?.status;
if (status === 403) {
return (
<div className="container py-8">
<h1 className="text-2xl font-bold mb-2">{t('customer.contracts.title', 'Contracts')}</h1>
<p className="text-muted-theme">
{t('customer.contracts.disabled',
'This feature is currently disabled for your account.')}
</p>
</div>
);
}
return (
<div className="container py-8">
<p className="text-red-600">{t('customer.contracts.loadError', 'Could not load contracts.')}</p>
</div>
);
}
const all = data || [];
return (
<div className="container py-6">
<div className="mb-6">
<h1 className="text-2xl font-bold text-theme flex items-center gap-2">
<ScrollText className="w-6 h-6" />
{t('customer.contracts.title', 'Contracts')}
</h1>
<p className="text-sm text-muted-theme mt-1">
{t('customer.contracts.subtitle',
'Every contract your photographer has sent you. Open the signing link on awaiting-signature contracts, or download the signed PDF once both parties have signed.')}
</p>
</div>
{all.length === 0 ? (
<Card padding="lg">
<p className="text-center text-muted-theme py-8">
{t('customer.contracts.empty', 'No contracts yet.')}
</p>
</Card>
) : (
<>
<div className="flex flex-wrap items-center justify-between gap-3 mb-3">
<div className="flex flex-wrap items-center gap-2">
<label className="text-xs text-muted-theme uppercase tracking-wider">
{t('customer.filter.label', 'Filter')}
</label>
<select
value={statusFilter}
onChange={(e) => setStatusFilter(e.target.value as StatusFilter)}
className="text-sm px-2 py-1 rounded border"
style={{
backgroundColor: 'var(--color-surface)',
borderColor: 'var(--color-surface-border)',
color: 'var(--color-text)',
}}
>
{STATUS_OPTIONS.map((o) => (
<option key={o.value} value={o.value}>{t(o.key, o.fallback)}</option>
))}
</select>
<label className="text-xs text-muted-theme uppercase tracking-wider ml-2">
{t('customer.sort.label', 'Sort')}
</label>
<select
value={sort}
onChange={(e) => setSort(e.target.value as SortKey)}
className="text-sm px-2 py-1 rounded border"
style={{
backgroundColor: 'var(--color-surface)',
borderColor: 'var(--color-surface-border)',
color: 'var(--color-text)',
}}
>
<option value="newest">{t('customer.sort.newest', 'Newest first')}</option>
<option value="oldest">{t('customer.sort.oldest', 'Oldest first')}</option>
</select>
</div>
<div className="text-xs text-muted-theme">
{visible.length === all.length
? t('customer.filter.countAll', '{{count}} total', { count: all.length })
: t('customer.filter.countFiltered', '{{visible}} of {{total}}', { visible: visible.length, total: all.length })}
</div>
</div>
<Card padding="none">
<ul className="divide-y" style={{ borderColor: 'var(--color-surface-border)' }}>
{visible.map((c) => <ContractRow key={c.id} c={c} />)}
</ul>
</Card>
</>
)}
</div>
);
};
const ContractRow: React.FC<{ c: CustomerContract }> = ({ c }) => {
const { t } = useTranslation();
async function handleDownload() {
// Sync-open BEFORE await so the popup-blocker accepts the gesture
// — same pattern bills/quotes use.
const w = window.open('about:blank', '_blank');
if (!w) {
toast.error(t('customer.contracts.popupBlocked',
'Allow pop-ups for this site to download the contract PDF.') as string);
return;
}
try {
const url = await customerService.contractPdfUrl(c.id);
w.location.href = url;
} catch (err: any) {
w.close();
toast.error(err?.response?.data?.error || 'Download failed');
}
}
const statusBadge =
c.status === 'fully_signed' ? 'bg-green-100 text-green-800'
: c.status === 'signed_by_customer' || c.status === 'signed_by_admin' ? 'bg-blue-100 text-blue-800'
: c.status === 'sent' ? 'bg-amber-100 text-amber-800'
: c.status === 'cancelled' ? 'bg-neutral-200 text-neutral-600'
: 'bg-neutral-100 text-neutral-700';
return (
<li className="p-4 flex items-center justify-between gap-3 flex-wrap">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2 flex-wrap">
<span className="font-mono text-sm">{c.contractNumber}</span>
<span className={`px-2 py-0.5 rounded text-xs font-medium ${statusBadge}`}>
{t(`contracts.status.${c.status}`, c.status)}
</span>
</div>
{c.title && (
<p className="text-sm text-theme mt-1">{c.title}</p>
)}
<p className="text-xs text-muted-theme mt-0.5">
{t('customer.contracts.issued', 'Issued')}: {formatShortDate(c.issueDate)}
{c.signedByCustomerAt && (
<>
{' · '}
{t('customer.contracts.signedByYou', 'Signed by you')}: {formatShortDate(c.signedByCustomerAt)}
</>
)}
{c.signedByAdminAt && (
<>
{' · '}
{t('customer.contracts.counterSigned', 'Counter-signed')}: {formatShortDate(c.signedByAdminAt)}
</>
)}
</p>
</div>
<div className="flex items-center gap-2">
{c.status === 'sent' && c.responseToken && (
<a
href={`/contract/${c.responseToken}`}
target="_blank"
rel="noreferrer"
className="inline-flex items-center gap-1 px-3 py-1.5 rounded text-sm bg-accent-dark text-white hover:opacity-90"
>
<ExternalLink className="w-4 h-4" />
{t('customer.contracts.openSign', 'Open & sign')}
</a>
)}
{(c.hasPdf || c.hasSignedPdf) && (
<button
type="button"
onClick={handleDownload}
className="inline-flex items-center gap-1 px-3 py-1.5 rounded text-sm border"
style={{
backgroundColor: 'var(--color-surface)',
borderColor: 'var(--color-surface-border)',
color: 'var(--color-text)',
}}
>
<Download className="w-4 h-4" />
{c.hasSignedPdf
? t('customer.contracts.downloadSigned', 'Download signed PDF')
: t('customer.contracts.download', 'Download PDF')}
</button>
)}
</div>
</li>
);
};
export default CustomerContractsPage;
+24 -23
View File
@@ -25,6 +25,7 @@ import {
LogOut,
Menu,
Receipt,
ScrollText,
User as UserIcon,
X,
} from 'lucide-react';
@@ -44,14 +45,15 @@ interface NavItem {
* Galleries + Profile are always visible; Calendar/Quotes/Bills are
* gated.
*/
feature?: 'calendar' | 'quotes' | 'bills';
feature?: 'calendar' | 'quotes' | 'bills' | 'contracts';
}
const NAV: NavItem[] = [
{ to: '/customer/dashboard', labelKey: 'customer.nav.galleries', fallback: 'Galleries', icon: ImageIcon },
{ to: '/customer/calendar', labelKey: 'customer.nav.calendar', fallback: 'Calendar', icon: Calendar, feature: 'calendar' },
{ to: '/customer/quotes', labelKey: 'customer.nav.quotes', fallback: 'Quotes', icon: FileText, feature: 'quotes' },
{ to: '/customer/bills', labelKey: 'customer.nav.bills', fallback: 'Bills', icon: Receipt, feature: 'bills' },
{ to: '/customer/contracts', labelKey: 'customer.nav.contracts', fallback: 'Contracts', icon: ScrollText, feature: 'contracts' },
{ to: '/customer/bills', labelKey: 'customer.nav.bills', fallback: 'Invoices', icon: Receipt, feature: 'bills' },
{ to: '/customer/profile', labelKey: 'customer.nav.profile', fallback: 'Profile', icon: UserIcon },
];
@@ -178,36 +180,35 @@ export const CustomerLayout: React.FC = () => {
className={`flex items-center px-3 py-2 text-sm font-medium rounded-lg transition-colors ${
isActive
? 'bg-accent-dark text-white'
: 'text-neutral-700 dark:text-neutral-300 hover:bg-neutral-100 dark:hover:bg-neutral-800 hover:text-neutral-900 dark:hover:text-neutral-100'
// Hover uses the theme `--color-elevated` token
// (light mode = #f5f5f5, dark mode = #1f1f1f) —
// mirrors the admin sidebar's subtle grey hover.
// Tailwind's bare `hover:bg-neutral-100 dark:
// hover:bg-neutral-800` doesn't work here because
// the customer portal toggles palette via CSS
// variables, not the `.dark` class, so the
// light-mode value was always winning and the
// hover read as near-white.
: 'hover:bg-[var(--color-elevated)]'
}`}
// Non-active items take their colour from the theme
// variable the admin chose in the colour pickers
// (`--color-text`). The Tailwind dark:text-white
// approach didn't apply because the customer
// portal toggles the palette via CSS variables, not
// the `.dark` class — so the previous styling
// resolved to the body's inherited muted colour.
style={isActive ? undefined : { color: 'var(--color-text)' }}
>
{/* Mirrors AdminSidebar's active state exactly: solid
accent-dark pill, white icon and label, no extra
flex grow on the label so the pill width matches
what the admin chrome renders. */}
<Icon
className={`w-5 h-5 mr-3 ${
isActive ? 'text-white' : 'text-neutral-400'
}`}
className="w-5 h-5 mr-3"
style={isActive ? undefined : { color: 'var(--color-text)' }}
/>
{t(item.labelKey, item.fallback)}
{/* Coming-soon pill for the gated entries. Pushed to
the right of the label with ml-auto so it doesn't
add the flex-1 stretching that was throwing off
the active-pill visual. */}
{item.feature && (
<span
className={`ml-auto text-[10px] uppercase tracking-wider px-1.5 py-0.5 rounded font-semibold ${
isActive ? 'bg-white/20 text-white' : ''
}`}
style={isActive ? undefined : {
backgroundColor: 'color-mix(in srgb, var(--color-accent) 14%, transparent)',
color: 'var(--color-accent)',
}}
>
{t('customer.nav.soon', 'Soon')}
</span>
)}
</NavLink>
);
})}
@@ -1,15 +1,279 @@
import React from 'react';
import { FileText } from 'lucide-react';
import { CustomerComingSoonPage } from './CustomerComingSoonPage';
/**
* Customer-side Quotes list. Read-only view of every quote the
* photographer has sent this customer. Open links straight back to
* the public quote response page when the quote is still in the
* accept/decline window saves the customer from digging through
* email to find the original link.
*
* Adds client-side sort + status filter controls (newest, oldest,
* price /; status: all / sent / accepted / declined / expired /
* converted). All client-side because the API doesn't paginate.
*/
import React, { useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useQuery } from '@tanstack/react-query';
import { FileText, ExternalLink, Download } from 'lucide-react';
import { customerService, type CustomerQuote } from '../../services/customer.service';
import { Card, Loading } from '../../components/common';
import { toast } from 'react-toastify';
import { formatMoney } from '../../utils/money';
import { formatShortDate } from '../../utils/dateShort';
export const CustomerQuotesPage: React.FC = () => (
<CustomerComingSoonPage
titleKey="customer.quotes.title"
titleFallback="Quotes"
bodyKey="customer.quotes.body"
bodyFallback="Review and accept quotes for upcoming shoots in one place. We're still building this — for now, your photographer will keep sending quotes the usual way."
icon={FileText}
/>
);
type SortKey = 'newest' | 'oldest' | 'price_desc' | 'price_asc';
type StatusFilter = 'all' | 'sent' | 'accepted' | 'declined' | 'expired' | 'converted';
const SORT_OPTIONS: { value: SortKey; key: string; fallback: string }[] = [
{ value: 'newest', key: 'customer.sort.newest', fallback: 'Newest first' },
{ value: 'oldest', key: 'customer.sort.oldest', fallback: 'Oldest first' },
{ value: 'price_desc', key: 'customer.sort.priceDesc', fallback: 'Price (high to low)' },
{ value: 'price_asc', key: 'customer.sort.priceAsc', fallback: 'Price (low to high)' },
];
const STATUS_OPTIONS: { value: StatusFilter; key: string; fallback: string }[] = [
{ value: 'all', key: 'customer.filter.all', fallback: 'All' },
{ value: 'sent', key: 'quotes.status.sent', fallback: 'Open / sent' },
{ value: 'accepted', key: 'quotes.status.accepted', fallback: 'Accepted' },
{ value: 'declined', key: 'quotes.status.declined', fallback: 'Declined' },
{ value: 'expired', key: 'quotes.status.expired', fallback: 'Expired' },
{ value: 'converted', key: 'quotes.status.converted', fallback: 'Converted' },
];
export const CustomerQuotesPage: React.FC = () => {
const { t } = useTranslation();
const { data, isLoading, isError, error } = useQuery({
queryKey: ['customer-quotes'],
queryFn: () => customerService.listQuotes(),
});
const [sort, setSort] = useState<SortKey>('newest');
const [statusFilter, setStatusFilter] = useState<StatusFilter>('all');
const visible = useMemo(() => {
const rows = data || [];
const filtered = statusFilter === 'all'
? rows
: rows.filter((q) => q.status === statusFilter);
const sorted = [...filtered].sort((a, b) => {
switch (sort) {
case 'oldest':
return new Date(a.issueDate).getTime() - new Date(b.issueDate).getTime();
case 'price_desc':
return Number(b.totalAmountMinor || 0) - Number(a.totalAmountMinor || 0);
case 'price_asc':
return Number(a.totalAmountMinor || 0) - Number(b.totalAmountMinor || 0);
case 'newest':
default:
return new Date(b.issueDate).getTime() - new Date(a.issueDate).getTime();
}
});
return sorted;
}, [data, sort, statusFilter]);
if (isLoading) return <Loading />;
if (isError) {
const status = (error as any)?.response?.status;
if (status === 403) {
return (
<div className="container py-8">
<h1 className="text-2xl font-bold mb-2">{t('customer.quotes.title', 'Quotes')}</h1>
<p className="text-muted-theme">
{t('customer.quotes.disabled',
'This feature is currently disabled for your account. Please contact your photographer if you expected to see quotes here.')}
</p>
</div>
);
}
return (
<div className="container py-8">
<p className="text-red-600">{t('customer.quotes.loadError', 'Could not load quotes.')}</p>
</div>
);
}
const allQuotes = data || [];
return (
<div className="container py-6">
<div className="mb-6">
<h1 className="text-2xl font-bold text-theme flex items-center gap-2">
<FileText className="w-6 h-6" />
{t('customer.quotes.title', 'Quotes')}
</h1>
<p className="text-sm text-muted-theme mt-1">
{t('customer.quotes.subtitle',
'Every quote your photographer has sent you. Click an open quote to accept or decline.')}
</p>
</div>
{allQuotes.length === 0 ? (
<Card padding="lg">
<p className="text-center text-muted-theme py-8">
{t('customer.quotes.empty', 'No quotes yet.')}
</p>
</Card>
) : (
<>
<FilterSortBar
sort={sort} onSortChange={setSort}
statusFilter={statusFilter} onStatusChange={setStatusFilter}
statusOptions={STATUS_OPTIONS}
totalRowCount={allQuotes.length}
visibleRowCount={visible.length}
/>
<Card padding="none">
<ul className="divide-y" style={{ borderColor: 'var(--color-surface-border)' }}>
{visible.map((q: CustomerQuote) => (
<QuoteRow key={q.id} q={q} />
))}
</ul>
</Card>
</>
)}
</div>
);
};
interface FilterSortBarProps<S extends string> {
sort: SortKey;
onSortChange: (v: SortKey) => void;
statusFilter: S;
onStatusChange: (v: S) => void;
statusOptions: { value: S; key: string; fallback: string }[];
totalRowCount: number;
visibleRowCount: number;
}
function FilterSortBar<S extends string>({
sort, onSortChange, statusFilter, onStatusChange, statusOptions,
totalRowCount, visibleRowCount,
}: FilterSortBarProps<S>) {
const { t } = useTranslation();
return (
<div className="flex flex-wrap items-center justify-between gap-3 mb-3">
<div className="flex flex-wrap items-center gap-2">
<label className="text-xs text-muted-theme uppercase tracking-wider">
{t('customer.filter.label', 'Filter')}
</label>
<select
value={statusFilter}
onChange={(e) => onStatusChange(e.target.value as S)}
className="text-sm px-2 py-1 rounded border"
style={{
backgroundColor: 'var(--color-surface)',
borderColor: 'var(--color-surface-border)',
color: 'var(--color-text)',
}}
>
{statusOptions.map((o) => (
<option key={o.value} value={o.value}>{t(o.key, o.fallback)}</option>
))}
</select>
<label className="text-xs text-muted-theme uppercase tracking-wider ml-2">
{t('customer.sort.label', 'Sort')}
</label>
<select
value={sort}
onChange={(e) => onSortChange(e.target.value as SortKey)}
className="text-sm px-2 py-1 rounded border"
style={{
backgroundColor: 'var(--color-surface)',
borderColor: 'var(--color-surface-border)',
color: 'var(--color-text)',
}}
>
{SORT_OPTIONS.map((o) => (
<option key={o.value} value={o.value}>{t(o.key, o.fallback)}</option>
))}
</select>
</div>
<div className="text-xs text-muted-theme">
{visibleRowCount === totalRowCount
? t('customer.filter.countAll', '{{count}} total', { count: totalRowCount })
: t('customer.filter.countFiltered', '{{visible}} of {{total}}', { visible: visibleRowCount, total: totalRowCount })}
</div>
</div>
);
}
const QuoteRow: React.FC<{ q: CustomerQuote }> = ({ q }) => {
const { t } = useTranslation();
// Open the public response page when the quote is still actionable.
// Once locked (responded_at + 15 min) or converted/expired the page
// becomes a read-only view of the locked state.
const canRespond = q.status === 'sent' || (
!!q.respondedAt && !!q.responseLockedAt && new Date(q.responseLockedAt).getTime() > Date.now()
);
const linkHref = q.responseToken ? `/quote/${q.responseToken}` : null;
const handleDownloadPdf = async (e: React.MouseEvent) => {
// Don't bubble to the row-wide link wrapper.
e.preventDefault();
e.stopPropagation();
// Sync-open the window so the popup blocker treats this as a
// user gesture (same trick as the Bills page).
const win = window.open('about:blank', '_blank');
if (!win) {
toast.error(t('customer.quotes.popupBlocked', 'Allow pop-ups for this site to view the quote PDF.'));
return;
}
try {
const url = await customerService.quotePdfUrl(q.id);
win.location.href = url;
} catch (err: any) {
win.close();
toast.error(err?.response?.data?.error || err.message || 'Failed to load quote PDF');
}
};
const statusClass =
q.status === 'accepted' || q.status === 'converted' ? 'bg-green-100 text-green-800 dark:bg-green-900/40 dark:text-green-300'
: q.status === 'declined' ? 'bg-red-100 text-red-800 dark:bg-red-900/40 dark:text-red-300'
: q.status === 'sent' ? 'bg-blue-100 text-blue-800 dark:bg-blue-900/40 dark:text-blue-300'
: 'bg-neutral-100 text-neutral-700 dark:bg-neutral-700 dark:text-neutral-300';
const body = (
<div className="flex items-center justify-between gap-3 px-4 py-3">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2 flex-wrap">
<span className="font-mono text-sm">{q.quoteNumber}</span>
<span className={`px-2 py-0.5 rounded text-xs font-medium ${statusClass}`}>
{t(`quotes.status.${q.status}`, q.status)}
</span>
</div>
<div className="text-sm text-muted-theme mt-1 truncate">
{q.eventName || t('customer.quotes.noEventName', '—')}
{q.eventDate ? ` · ${formatShortDate(q.eventDate)}` : ''}
{q.validUntil && canRespond ? ` · ${t('quoteResponse.validUntil', 'valid until')} ${formatShortDate(q.validUntil)}` : ''}
</div>
</div>
<div className="text-right">
<div className="font-medium tabular-nums">
{formatMoney(Number(q.totalAmountMinor) / 100, q.currency)}
</div>
<div className="mt-1 flex items-center justify-end gap-3 text-xs">
<button type="button" onClick={handleDownloadPdf}
className="text-primary-600 dark:text-primary-400 inline-flex items-center gap-1 hover:underline">
<Download className="w-3 h-3" />
{t('customer.quotes.viewPdf', 'View PDF')}
</button>
{canRespond && linkHref && (
<span className="text-primary-600 dark:text-primary-400 inline-flex items-center gap-1">
{t('customer.quotes.openToRespond', 'Open to respond')}
<ExternalLink className="w-3 h-3" />
</span>
)}
</div>
</div>
</div>
);
return (
<li>
{linkHref ? (
<a href={linkHref} target={canRespond ? '_blank' : '_self'} rel="noopener noreferrer"
className="block hover:bg-neutral-50 dark:hover:bg-neutral-800">
{body}
</a>
) : body}
</li>
);
};
export default CustomerQuotesPage;
+1
View File
@@ -6,4 +6,5 @@ export { CustomerProfilePage } from './CustomerProfilePage';
export { CustomerCalendarPage } from './CustomerCalendarPage';
export { CustomerQuotesPage } from './CustomerQuotesPage';
export { CustomerBillsPage } from './CustomerBillsPage';
export { CustomerContractsPage } from './CustomerContractsPage';
export { CustomerResetPasswordPage } from './CustomerResetPasswordPage';
+7 -18
View File
@@ -7,7 +7,6 @@ import {
Lock,
Eye,
EyeOff,
AlertCircle,
CheckCircle,
Mail,
Shield,
@@ -16,6 +15,7 @@ import {
import { toast } from 'react-toastify';
import { Button, Input, Card, Loading } from '../../components/common';
import { useLocalizedDate } from '../../hooks/useLocalizedDate';
import { api } from '../../config/api';
interface InvitationValidation {
@@ -43,6 +43,7 @@ interface PasswordRequirement {
export const AcceptInvitePage: React.FC = () => {
const { t } = useTranslation();
const { formatDateTime: fmtDateTime } = useLocalizedDate();
const { token } = useParams<{ token: string }>();
const navigate = useNavigate();
@@ -241,17 +242,13 @@ export const AcceptInvitePage: React.FC = () => {
return translated;
};
// Format expiration date
// Format expiration date — respects the admin-configured
// `general_date_format` for the date half + 24-hour HH:mm for the
// time half (was previously a hardcoded long-form en/locale string
// that ignored the setting).
const formatExpirationDate = (dateString: string): string => {
try {
const date = new Date(dateString);
return date.toLocaleDateString(undefined, {
year: 'numeric',
month: 'long',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
return fmtDateTime(dateString);
} catch {
return dateString;
}
@@ -383,14 +380,6 @@ export const AcceptInvitePage: React.FC = () => {
{/* Registration Form */}
<Card padding="lg">
<form onSubmit={handleSubmit} className="space-y-6">
{/* Form Error */}
{errors.form && (
<div className="bg-red-50 border border-red-200 rounded-lg p-4 flex items-start gap-3">
<AlertCircle className="w-5 h-5 text-red-600 flex-shrink-0 mt-0.5" />
<p className="text-sm text-red-800">{errors.form}</p>
</div>
)}
{/* Username Field */}
<div>
<label htmlFor="username" className="block text-sm font-medium text-neutral-700 mb-1">
@@ -0,0 +1,496 @@
/**
* Public contract response page. No authentication the link in the
* customer's email is the only secret.
*
* Two signing paths offered side-by-side:
* 1. In-browser: customer types their full name, optionally draws a
* signature on a small canvas (signature_pad), ticks "I have read
* and agree", and submits. Server captures IP + timestamp + the
* signature image, re-renders the PDF with the signature stamped,
* and emails the admin a notification.
* 2. Upload wet-signed PDF: customer can sign physically and upload
* the PDF instead. Server treats this as the authoritative copy.
*
* Visual treatment matches QuoteResponsePage so admins get a consistent
* customer-facing surface: branding-aware dark/light mode via
* usePublicDarkMode, issuer logo + name header, neutral card styling.
*/
import React, { useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useParams } from 'react-router-dom';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import SignaturePad from 'signature_pad';
import { CheckCircle, Upload, RotateCcw, Download, ShieldCheck } from 'lucide-react';
import { Loading } from '../../components/common';
import { usePublicDarkMode } from '../../hooks/usePublicDarkMode';
import {
publicContractsService,
type ContractBlockSection,
} from '../../services/contracts.service';
/**
* Maximum width of the exported signature PNG. The on-screen canvas
* uses CSS-pixel size × devicePixelRatio for crisp strokes; without
* downscaling, a 4× retina display exports a multi-MB PNG that
* trips the server's SIGNATURE_TOO_LARGE cap (1 MB base64). 800 px
* is wide enough to render the signature legibly when stamped onto
* the contract PDF (printed at 4 inches × 200 dpi).
*/
const MAX_SIGNATURE_WIDTH = 800;
/**
* Export the signature canvas as a downscaled PNG data URL.
* Renders the source canvas onto a temporary canvas at
* MAX_SIGNATURE_WIDTH (preserving aspect ratio), then exports via
* toDataURL. The pad parameter is passed for the empty-state check
* + the fallback path when downscaling can't run.
*/
function downscaleSignature(pad: SignaturePad, sourceCanvas: HTMLCanvasElement): string {
const srcW = sourceCanvas.width;
const srcH = sourceCanvas.height;
if (srcW <= MAX_SIGNATURE_WIDTH) {
return pad.toDataURL('image/png');
}
const scale = MAX_SIGNATURE_WIDTH / srcW;
const targetW = MAX_SIGNATURE_WIDTH;
const targetH = Math.round(srcH * scale);
const dst = document.createElement('canvas');
dst.width = targetW;
dst.height = targetH;
const ctx = dst.getContext('2d');
if (!ctx) return pad.toDataURL('image/png');
ctx.imageSmoothingEnabled = true;
ctx.imageSmoothingQuality = 'high';
ctx.drawImage(sourceCanvas, 0, 0, srcW, srcH, 0, 0, targetW, targetH);
return dst.toDataURL('image/png');
}
const SECTION_LABELS: Record<ContractBlockSection, { en: string; de: string }> = {
basics: { en: 'Basics', de: 'Vertragsgrundlagen' },
scope: { en: 'Scope', de: 'Leistungsumfang' },
privacy: { en: 'Privacy', de: 'Persönlichkeitsrechte & Datenschutz' },
commercial: { en: 'Commercial', de: 'Kaufmännisches' },
nda: { en: 'Confidentiality', de: 'Vertraulichkeit' },
closing: { en: 'Closing', de: 'Schlussbestimmungen' },
};
export const ContractResponsePage: React.FC = () => {
const { t, i18n } = useTranslation();
const { token } = useParams<{ token: string }>();
const queryClient = useQueryClient();
// Honour branding dark/light mode the same way QuoteResponsePage
// does — without this the page renders in light regardless of admin
// settings. The wrapper styling below still has `dark:` variants
// so the page reads cleanly in either mode.
usePublicDarkMode();
const canvasRef = useRef<HTMLCanvasElement>(null);
const padRef = useRef<SignaturePad | null>(null);
const [name, setName] = useState('');
const [accepted, setAccepted] = useState(false);
const [error, setError] = useState<string | null>(null);
const [uploadFile, setUploadFile] = useState<File | null>(null);
const { data, isLoading, isError } = useQuery({
queryKey: ['public-contract', token],
queryFn: () => publicContractsService.get(token as string),
enabled: !!token,
retry: false,
});
// Switch UI locale to the contract's language for a consistent
// customer experience (matches QuoteResponsePage).
useEffect(() => {
if (data?.contract.language && data.contract.language !== i18n.language) {
i18n.changeLanguage(data.contract.language).catch(() => { /* tolerate */ });
}
}, [data, i18n]);
// Initialise signature_pad once the canvas is mounted. Re-sizes the
// canvas drawing buffer to its CSS dimensions × devicePixelRatio so
// the strokes stay crisp on HiDPI displays — signature_pad's docs
// recommend this pattern. Re-runs on window resize so rotating a
// phone doesn't break the input.
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const resize = () => {
const ratio = Math.max(window.devicePixelRatio || 1, 1);
const rect = canvas.getBoundingClientRect();
canvas.width = rect.width * ratio;
canvas.height = rect.height * ratio;
const ctx = canvas.getContext('2d');
ctx?.scale(ratio, ratio);
padRef.current?.clear(); // canvas resize clears the buffer
};
padRef.current = new SignaturePad(canvas, {
penColor: '#111',
backgroundColor: 'rgba(255, 255, 255, 0)',
});
resize();
window.addEventListener('resize', resize);
return () => {
window.removeEventListener('resize', resize);
padRef.current?.off();
padRef.current = null;
};
}, [data]); // re-run if the contract loads after the canvas mounts
const signMutation = useMutation({
mutationFn: async () => {
const pad = padRef.current;
// Downscale the signature image before exporting. The canvas is
// sized to its CSS dimensions × devicePixelRatio (resize effect
// above) so on a 4× retina display we'd otherwise ship a ~1 MB
// PNG to the server. Cap the export at MAX_SIGNATURE_WIDTH px
// wide — preserves the visual fidelity needed for a legible
// signature stamp while keeping the payload well under the
// server's SIGNATURE_TOO_LARGE cap (1 MB base64). The
// downscale uses a temporary canvas + drawImage to bilinear-
// sample; signature_pad has no built-in export-size option.
const canvas = canvasRef.current;
const signatureDataUrl = (pad && canvas && !pad.isEmpty())
? downscaleSignature(pad, canvas)
: null;
return publicContractsService.sign(token as string, {
name: name.trim(),
signatureDataUrl,
accepted: true,
});
},
onSuccess: () => {
setError(null);
queryClient.invalidateQueries({ queryKey: ['public-contract', token] });
},
onError: (err: any) => setError(err?.response?.data?.error || 'Failed to sign'),
});
const uploadMutation = useMutation({
mutationFn: async (file: File) => publicContractsService.uploadSignedPdf(token as string, file),
onSuccess: () => {
setError(null);
setUploadFile(null);
queryClient.invalidateQueries({ queryKey: ['public-contract', token] });
},
onError: (err: any) => setError(err?.response?.data?.error || 'Upload failed'),
});
function handleSign(e: React.FormEvent) {
e.preventDefault();
if (!accepted) {
setError(t('publicContract.errorAccept', 'Please tick the acceptance box.') as string);
return;
}
if (!name.trim()) {
setError(t('publicContract.errorName', 'Please enter your name.') as string);
return;
}
// Client-side enforcement of the admin's "require drawn signature"
// toggle. The server re-checks; this just gives a clearer error
// before the round-trip.
if (data?.contract.requireDrawnSignature && (!padRef.current || padRef.current.isEmpty())) {
setError(t('publicContract.errorSignatureRequired',
'A drawn signature is required for this contract.') as string);
return;
}
setError(null);
signMutation.mutate();
}
if (isLoading) {
return (
<div className="min-h-screen flex items-center justify-center bg-neutral-50 dark:bg-neutral-900">
<Loading />
</div>
);
}
if (isError || !data) {
return (
<div className="min-h-screen bg-neutral-50 dark:bg-neutral-900 flex items-center justify-center p-6">
<div className="max-w-md text-center">
<h1 className="text-2xl font-bold mb-2 text-neutral-900 dark:text-neutral-100">
{t('publicContract.notFoundTitle', 'Contract not available')}
</h1>
<p className="text-neutral-600 dark:text-neutral-400">
{t('publicContract.notFoundBody', 'This signing link is invalid or expired. Please contact the sender.')}
</p>
</div>
</div>
);
}
const c = data.contract;
const locale = (c.language === 'de' ? 'de' : 'en') as 'en' | 'de';
const alreadySigned =
c.status === 'signed_by_customer'
|| c.status === 'signed_by_admin'
|| c.status === 'fully_signed';
return (
<div className="min-h-screen bg-neutral-50 dark:bg-neutral-900 text-neutral-900 dark:text-neutral-100">
<div className="max-w-3xl mx-auto py-8 px-4">
{/* Issuer header — same shape as QuoteResponsePage. */}
<div className="text-center mb-6">
{c.issuer?.companyName && (
<h2 className="text-xl font-bold">{c.issuer.companyName}</h2>
)}
{c.issuer?.website && (
<p className="text-sm text-neutral-500 dark:text-neutral-400">{c.issuer.website}</p>
)}
</div>
{/* Main card */}
<div className="bg-white dark:bg-neutral-800 rounded-xl shadow-sm border border-neutral-200 dark:border-neutral-700 p-6 md:p-8">
<div className="flex items-baseline justify-between mb-4 gap-3 flex-wrap">
<h1 className="text-2xl font-bold">
{c.title || t('publicContract.fallbackTitle', 'Contract')}
</h1>
<span className="text-xs font-mono px-2 py-1 rounded bg-neutral-100 dark:bg-neutral-700 text-neutral-600 dark:text-neutral-300">
{c.contractNumber}
</span>
</div>
{c.recipient && (
<div className="mb-4 text-sm text-neutral-700 dark:text-neutral-300">
<p className="font-medium">{c.recipient.companyName || c.recipient.displayName}</p>
<p className="text-neutral-500 dark:text-neutral-400">{c.recipient.email}</p>
</div>
)}
{c.introText && (
<p className="whitespace-pre-line text-neutral-700 dark:text-neutral-300 my-4">
{c.introText}
</p>
)}
{/* Sections + blocks */}
{c.sections.map((sec) => (
<section key={sec.section} className="mt-6">
<h2 className="text-lg font-semibold border-b border-neutral-200 dark:border-neutral-700 pb-1 mb-3">
{SECTION_LABELS[sec.section]?.[locale] || sec.section}
</h2>
{sec.blocks.map((blk) => (
<article key={blk.blockId} className="mb-4">
<h3 className="font-semibold text-sm mb-1">{blk.name}</h3>
<p className="text-sm whitespace-pre-line leading-6 text-neutral-700 dark:text-neutral-300">
{blk.body}
</p>
</article>
))}
</section>
))}
{c.outroText && (
<p className="whitespace-pre-line text-neutral-700 dark:text-neutral-300 mt-6">
{c.outroText}
</p>
)}
</div>
{/* Signing card */}
<div className="mt-6 bg-white dark:bg-neutral-800 rounded-xl shadow-sm border border-neutral-200 dark:border-neutral-700 p-6 md:p-8">
{alreadySigned ? (
<div className="py-2">
<div className="text-center mb-5">
<CheckCircle className="w-12 h-12 mx-auto text-green-600 mb-3" />
<h2 className="text-lg font-semibold">
{t('publicContract.signed.title', 'Thank you — the contract is signed.')}
</h2>
{c.signedCustomerName && (
<p className="text-sm text-neutral-600 dark:text-neutral-400 mt-1">
{t('publicContract.signed.by', 'Signed by')}: {c.signedCustomerName}
</p>
)}
{c.signedByAdminAt && c.signedAdminName && (
<p className="text-sm text-neutral-600 dark:text-neutral-400">
{t('publicContract.signed.counterBy', 'Counter-signed by')}: {c.signedAdminName}
</p>
)}
</div>
{/* Download button issue #2. Streams whatever is the
most authoritative copy on disk (signed_pdf_path when
present, otherwise pdf_path). Sync-opens about:blank
pre-fetch so the popup-blocker accepts the gesture. */}
<div className="text-center mb-5">
<a
href={`/api/public/contracts/${token}/pdf`}
target="_blank"
rel="noreferrer"
className="inline-flex items-center gap-2 px-4 py-2 rounded-md bg-accent-dark text-white text-sm hover:opacity-90"
>
<Download className="w-4 h-4" />
{c.hasSignedPdf
? t('publicContract.downloadSigned', 'Download signed PDF')
: t('publicContract.download', 'Download PDF')}
</a>
</div>
{/* Audit confirmation issue #4. Surfaces every piece
of evidence we recorded so the customer can save /
screenshot it for their own records. Re-hashing the
downloaded PDF and comparing against pdfSha256 is
the cryptographic proof the file wasn't tampered
with after we issued it. */}
<details className="border-t border-neutral-200 dark:border-neutral-700 pt-4">
<summary className="text-sm font-semibold cursor-pointer inline-flex items-center gap-2">
<ShieldCheck className="w-4 h-4" />
{t('publicContract.signed.auditTitle', 'Signing audit trail')}
</summary>
<p className="text-xs text-neutral-600 dark:text-neutral-400 mt-2 mb-3">
{t('publicContract.signed.auditBody',
'For your records. Save or screenshot this — the SHA-256 hash lets you prove later that the PDF you downloaded is exactly what we issued (re-hash the file you have and compare).')}
</p>
<dl className="text-xs grid grid-cols-1 sm:grid-cols-[140px_1fr] gap-x-3 gap-y-1.5 font-mono">
<dt className="text-neutral-500">{t('publicContract.signed.contractNumber', 'Contract')}</dt>
<dd>{c.contractNumber}</dd>
{c.signedByCustomerAt && (
<>
<dt className="text-neutral-500">{t('publicContract.signed.signedAt', 'Signed at')}</dt>
<dd>{new Date(c.signedByCustomerAt).toISOString()}</dd>
</>
)}
{c.signedCustomerIp && (
<>
<dt className="text-neutral-500">{t('publicContract.signed.ipAddress', 'IP at signing')}</dt>
<dd>{c.signedCustomerIp}</dd>
</>
)}
{c.signedByAdminAt && (
<>
<dt className="text-neutral-500">{t('publicContract.signed.counterSignedAt', 'Counter-signed at')}</dt>
<dd>{new Date(c.signedByAdminAt).toISOString()}</dd>
</>
)}
{/* Admin counter-sign IP intentionally NOT rendered
to the customer it's the operator's identifier,
not part of what the customer needs to audit. The
backend no longer ships signedAdminIp on the
public payload as of A.6 security hardening. */}
{c.signedPdfSha256 && (
<>
<dt className="text-neutral-500">{t('publicContract.signed.signedSha', 'Signed PDF SHA-256')}</dt>
<dd className="break-all">{c.signedPdfSha256}</dd>
</>
)}
{c.pdfSha256 && (
<>
<dt className="text-neutral-500">{t('publicContract.signed.unsignedSha', 'Original PDF SHA-256')}</dt>
<dd className="break-all">{c.pdfSha256}</dd>
</>
)}
</dl>
</details>
</div>
) : (
<>
<h2 className="text-lg font-semibold mb-3">
{t('publicContract.signTitle', 'Sign this contract')}
</h2>
<form onSubmit={handleSign} className="space-y-3">
<div>
<label className="block text-sm font-medium mb-1">
{t('publicContract.nameField', 'Your full name')}
</label>
<input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
className="w-full px-3 py-2 rounded-md border border-neutral-300 dark:border-neutral-600 bg-white dark:bg-neutral-900 text-sm text-neutral-900 dark:text-neutral-100"
autoComplete="name"
/>
</div>
<div>
<label className="block text-sm font-medium mb-1">
{c.requireDrawnSignature
? t('publicContract.signaturePromptRequired', 'Draw your signature')
: t('publicContract.signaturePrompt', 'Draw your signature (optional)')}
</label>
<canvas
ref={canvasRef}
className="w-full h-32 bg-white rounded border border-neutral-300 dark:border-neutral-600 touch-none"
/>
<div className="mt-1 flex justify-end">
<button
type="button"
onClick={() => padRef.current?.clear()}
className="text-xs text-neutral-600 dark:text-neutral-400 hover:underline inline-flex items-center gap-1"
>
<RotateCcw className="w-3 h-3" />
{t('publicContract.clearSignature', 'Clear')}
</button>
</div>
</div>
<label className="flex items-start gap-2 text-sm text-neutral-700 dark:text-neutral-300">
<input
type="checkbox"
checked={accepted}
onChange={(e) => setAccepted(e.target.checked)}
className="mt-1"
/>
<span>
{t(
'publicContract.acceptCheckbox',
'I have read this contract and agree to be bound by its terms.',
)}
</span>
</label>
{error && <p className="text-sm text-red-600">{error}</p>}
<div className="flex justify-end">
<button
type="submit"
disabled={signMutation.isPending}
className="px-4 py-2 rounded-md bg-accent-dark text-white text-sm hover:opacity-90 disabled:opacity-50"
>
{t('publicContract.submit', 'Sign contract')}
</button>
</div>
</form>
{/* Alternative: upload wet-signed PDF. Hidden when the
admin has turned off the upload path in Settings
CRM behaviour Contracts. */}
{c.allowPdfUpload !== false && (
<div className="mt-6 pt-4 border-t border-neutral-200 dark:border-neutral-700">
<h3 className="text-sm font-semibold mb-2">
{t('publicContract.uploadAlternative', 'Or upload a wet-signed PDF')}
</h3>
<p className="text-xs text-neutral-600 dark:text-neutral-400 mb-2">
{t(
'publicContract.uploadHint',
'Sign the printed contract by hand, scan it, and upload the PDF here.',
)}
</p>
<div className="flex items-center gap-2 flex-wrap">
<input
type="file"
accept="application/pdf"
onChange={(e) => setUploadFile(e.target.files?.[0] || null)}
className="text-sm text-neutral-700 dark:text-neutral-300"
/>
<button
type="button"
disabled={!uploadFile || uploadMutation.isPending}
onClick={() => uploadFile && uploadMutation.mutate(uploadFile)}
className="px-3 py-1.5 rounded-md border border-neutral-300 dark:border-neutral-600 text-sm inline-flex items-center gap-1 disabled:opacity-50 text-neutral-700 dark:text-neutral-300"
>
<Upload className="w-4 h-4" />
{t('publicContract.uploadButton', 'Upload signed PDF')}
</button>
</div>
</div>
)}
</>
)}
</div>
</div>
</div>
);
};
@@ -0,0 +1,405 @@
/**
* Public payment-check landing page. Mounted at /payment-check/:token
* (outside any auth gate). The admin arrives here from the email
* button the token in the URL is the only credential.
*
* Three actions:
* - Paid in full confirm, POST 'paid_full'
* - Partial enter amount, POST 'partial' with amountMinor
* - Not paid yet confirm, POST 'unpaid'
*
* Theming: respects the admin's branding settings header carries
* the logo + company name from Settings Branding, surface/text
* colors come from the theme CSS variables. Dark mode is applied
* via the shared `usePublicDarkMode` hook.
*/
import React, { useEffect, useState } from 'react';
import { useParams, useSearchParams } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { useQuery } from '@tanstack/react-query';
import { CheckCircle2, Wallet, AlertTriangle } from 'lucide-react';
import {
paymentCheckService,
type PaymentCheckAction,
type PaymentCheckView,
type PaymentCheckIssuer,
} from '../../services/paymentCheck.service';
import { usePublicDarkMode } from '../../hooks/usePublicDarkMode';
import { Loading } from '../../components/common';
import { formatMoneyMinor } from '../../utils/money';
// All call-sites in this file pass minor units — alias to the
// minor-aware helper so the rest of the file is untouched.
const formatMoney = formatMoneyMinor;
import { formatShortDate } from '../../utils/dateShort';
export const PaymentCheckPage: React.FC = () => {
const { t } = useTranslation();
const { token } = useParams<{ token: string }>();
const [searchParams] = useSearchParams();
const initialAction = (searchParams.get('action') as PaymentCheckAction) || null;
usePublicDarkMode();
const { data, isLoading, isError, error } = useQuery({
queryKey: ['payment-check', token],
queryFn: () => paymentCheckService.get(token!),
enabled: !!token,
retry: false,
});
const [action, setAction] = useState<PaymentCheckAction | null>(null);
const [partialAmount, setPartialAmount] = useState<string>('');
const [submitting, setSubmitting] = useState(false);
const [result, setResult] = useState<{ applied: PaymentCheckAction; reminderLevel?: number; reminderSkipped?: string } | null>(null);
const [submitError, setSubmitError] = useState<string | null>(null);
useEffect(() => {
if (!action && initialAction
&& ['paid_full', 'paid_with_skonto', 'partial', 'unpaid'].includes(initialAction)) {
setAction(initialAction);
}
}, [initialAction, action]);
useEffect(() => {
if (action === 'partial' && data && !partialAmount) {
setPartialAmount((data.invoice.outstandingMinor / 100).toFixed(2));
}
}, [action, data, partialAmount]);
if (!token) {
return <ErrorBox message={t('paymentCheck.missingToken', 'Missing token')} />;
}
if (isLoading) return <Loading />;
if (isError) {
const status = (error as any)?.response?.status;
const code = (error as any)?.response?.data?.code;
if (status === 410 && code === 'TOKEN_ALREADY_USED') {
const usedAction = (error as any)?.response?.data?.usedAction;
return <ErrorBox message={t('paymentCheck.alreadyUsed',
'This link has already been used (action: {{action}}). If you need to record another payment, open the invoice in admin.',
{ action: usedAction || '' })} />;
}
if (status === 410) {
return <ErrorBox message={t('paymentCheck.expired',
'This link has expired. Open the invoice in admin to record the payment manually.')} />;
}
return <ErrorBox message={t('paymentCheck.loadError', 'Could not load invoice. The link may be invalid.')} />;
}
const inv: PaymentCheckView = data!.invoice;
const issuer: PaymentCheckIssuer | null = data!.issuer;
if (result) return <ResultBox result={result} inv={inv} issuer={issuer} />;
const submit = async () => {
if (!action) return;
setSubmitError(null);
setSubmitting(true);
try {
let amountMinor: number | undefined;
if (action === 'partial') {
const v = Number(partialAmount);
if (!Number.isFinite(v) || v <= 0) {
setSubmitError(t('paymentCheck.partialInvalid', 'Enter a positive amount.'));
setSubmitting(false);
return;
}
amountMinor = Math.round(v * 100);
if (amountMinor > inv.outstandingMinor) {
setSubmitError(t('paymentCheck.partialTooHigh',
'Amount cannot exceed the outstanding total ({{max}}).',
{ max: formatMoney(inv.outstandingMinor, inv.currency) }));
setSubmitting(false);
return;
}
}
const res = await paymentCheckService.record(token, { action, amountMinor });
setResult(res);
} catch (e: any) {
setSubmitError(e?.response?.data?.error || t('paymentCheck.submitError', 'Could not record action.'));
} finally {
setSubmitting(false);
}
};
return (
<div
className="min-h-screen py-10 px-4"
style={{
backgroundColor: 'var(--color-background, #fafafa)',
color: 'var(--color-text, #171717)',
}}
>
<div className="max-w-2xl mx-auto">
<BrandingHeader issuer={issuer} />
<h1 className="text-2xl font-bold mb-1">{t('paymentCheck.title', 'Confirm payment')}</h1>
<p className="text-sm mb-6" style={{ color: 'var(--color-muted-text, #737373)' }}>
{t('paymentCheck.subtitle',
'Select what was received for this invoice. The choice is logged and the appropriate reminder is queued automatically.')}
</p>
<ThemedSurface className="p-5 mb-5">
<div className="grid grid-cols-2 gap-3 text-sm">
<Field label={t('paymentCheck.field.invoice', 'Invoice')} value={<span className="font-mono">{inv.invoiceNumber}</span>} />
<Field label={t('paymentCheck.field.customer', 'Customer')} value={inv.customer.label} />
<Field label={t('paymentCheck.field.issued', 'Issued')} value={formatShortDate(inv.issueDate)} />
<Field label={t('paymentCheck.field.due', 'Due')} value={formatShortDate(inv.dueDate)} />
<Field label={t('paymentCheck.field.total', 'Total')} value={<span className="tabular-nums">{formatMoney(inv.totalMinor, inv.currency)}</span>} />
<Field label={t('paymentCheck.field.outstanding', 'Outstanding')} value={<span className="tabular-nums font-semibold">{formatMoney(inv.outstandingMinor, inv.currency)}</span>} />
{inv.paidMinor > 0 && (
<Field label={t('paymentCheck.field.paid', 'Already paid')} value={<span className="tabular-nums">{formatMoney(inv.paidMinor, inv.currency)}</span>} />
)}
{inv.lateFeeMinor > 0 && (
<Field
label={t('paymentCheck.field.lateFee', 'Late fee')}
value={<span className="tabular-nums" style={{ color: 'var(--color-warning, #b45309)' }}>{formatMoney(inv.lateFeeMinor, inv.currency)}</span>}
/>
)}
</div>
</ThemedSurface>
<div className="space-y-3">
<ActionCard
label={t('paymentCheck.action.paidFull', 'Paid in full')}
description={t('paymentCheck.action.paidFullHelp',
'Mark the entire outstanding amount ({{amount}}) as received. No reminder is sent.',
{ amount: formatMoney(inv.outstandingMinor, inv.currency) })}
icon={<CheckCircle2 className="w-5 h-5" style={{ color: '#16a34a' }} />}
selected={action === 'paid_full'}
onSelect={() => setAction('paid_full')}
/>
{/* Migration 126 Skonto fast-path. Only rendered when the
invoice's payment terms include a Skonto percentage; the
backend resolves this and exposes hasSkonto + the
discounted total so we don't have to recompute on the
client. */}
{inv.hasSkonto && inv.skontoDiscountedTotalMinor != null && (
<ActionCard
label={t('paymentCheck.action.paidSkonto', 'Paid with Skonto')}
description={t('paymentCheck.action.paidSkontoHelp',
'Customer settled within the Skonto window. Record {{amount}} ({{percent}}% discount) as paid.',
{
amount: formatMoney(inv.skontoDiscountedTotalMinor, inv.currency),
percent: inv.skontoPercent,
})}
icon={<CheckCircle2 className="w-5 h-5" style={{ color: '#0d9488' }} />}
selected={action === 'paid_with_skonto'}
onSelect={() => setAction('paid_with_skonto')}
/>
)}
<ActionCard
label={t('paymentCheck.action.partial', 'Partially paid')}
description={t('paymentCheck.action.partialHelp',
'Log the amount received, then queue the customer reminder for the remainder.')}
icon={<Wallet className="w-5 h-5" style={{ color: 'var(--color-accent, #2563eb)' }} />}
selected={action === 'partial'}
onSelect={() => setAction('partial')}
>
{action === 'partial' && (
<div className="mt-3">
<label className="block text-xs uppercase tracking-wider mb-1" style={{ color: 'var(--color-muted-text)' }}>
{t('paymentCheck.action.partialAmount', 'Amount received')}
</label>
<div className="flex items-center gap-2">
<span className="text-sm font-medium">{inv.currency}</span>
<input
type="number"
step="0.01"
min={0}
max={inv.outstandingMinor / 100}
value={partialAmount}
onChange={(e) => setPartialAmount(e.target.value)}
className="flex-1 px-3 py-2 rounded-md border text-sm"
style={{
backgroundColor: 'var(--color-elevated, #ffffff)',
borderColor: 'var(--color-surface-border, #e5e5e5)',
color: 'var(--color-text)',
}}
/>
</div>
<p className="text-xs mt-1" style={{ color: 'var(--color-muted-text)' }}>
{t('paymentCheck.action.partialMax', 'Max: {{max}}', {
max: formatMoney(inv.outstandingMinor, inv.currency),
})}
</p>
</div>
)}
</ActionCard>
<ActionCard
label={t('paymentCheck.action.unpaid', 'Not paid yet')}
description={t('paymentCheck.action.unpaidHelp',
'Nothing received. The customer reminder will be queued{{fee}}.',
{ fee: inv.reminderLevel >= 1 ? t('paymentCheck.action.unpaidWithFee', ' (with late fee at second reminder)') : '' })}
icon={<AlertTriangle className="w-5 h-5" style={{ color: '#dc2626' }} />}
selected={action === 'unpaid'}
onSelect={() => setAction('unpaid')}
/>
</div>
{submitError && (
<p className="mt-4 text-sm" style={{ color: '#dc2626' }}>{submitError}</p>
)}
<div className="mt-6 flex justify-end">
<button
type="button"
onClick={submit}
disabled={!action || submitting}
className="px-6 py-3 rounded-md font-medium disabled:opacity-50 transition-colors"
style={{
backgroundColor: 'var(--color-accent-dark, #2563eb)',
color: 'var(--color-accent-fg, #ffffff)',
}}
>
{submitting ? t('paymentCheck.submitting', 'Recording…') : t('paymentCheck.submit', 'Confirm')}
</button>
</div>
</div>
</div>
);
};
const BrandingHeader: React.FC<{ issuer: PaymentCheckIssuer | null }> = ({ issuer }) => {
if (!issuer || (!issuer.logoUrl && !issuer.companyName)) return null;
return (
<header className="text-center mb-8">
{issuer.logoUrl && (
<img
src={issuer.logoUrl}
alt={issuer.companyName || 'Logo'}
className="mx-auto h-16 w-auto object-contain mb-3"
/>
)}
{issuer.companyName && (
<h2 className="text-xl font-bold">{issuer.companyName}</h2>
)}
{issuer.website && (
<p className="text-sm" style={{ color: 'var(--color-muted-text, #737373)' }}>{issuer.website}</p>
)}
</header>
);
};
const ThemedSurface: React.FC<{ className?: string; children: React.ReactNode }> = ({ className, children }) => (
<div
className={`rounded-lg border ${className || ''}`}
style={{
backgroundColor: 'var(--color-surface, #ffffff)',
borderColor: 'var(--color-surface-border, #e5e5e5)',
}}
>
{children}
</div>
);
const Field: React.FC<{ label: string; value: React.ReactNode }> = ({ label, value }) => (
<div>
<div className="text-xs uppercase" style={{ color: 'var(--color-muted-text, #737373)' }}>{label}</div>
<div>{value}</div>
</div>
);
interface ActionCardProps {
label: string;
description: string;
icon: React.ReactNode;
selected: boolean;
onSelect: () => void;
children?: React.ReactNode;
}
const ActionCard: React.FC<ActionCardProps> = ({ label, description, icon, selected, onSelect, children }) => (
<button
type="button"
onClick={onSelect}
className="w-full text-left rounded-lg border p-4 transition-colors"
style={{
borderColor: selected ? 'var(--color-accent, #2563eb)' : 'var(--color-surface-border, #e5e5e5)',
backgroundColor: selected
? 'color-mix(in srgb, var(--color-accent) 8%, var(--color-surface))'
: 'var(--color-surface, #ffffff)',
}}
>
<div className="flex items-start gap-3">
<div className="shrink-0 mt-0.5">{icon}</div>
<div className="flex-1">
<div className="font-medium">{label}</div>
<div className="text-sm mt-1" style={{ color: 'var(--color-muted-text, #737373)' }}>{description}</div>
{children}
</div>
</div>
</button>
);
const ErrorBox: React.FC<{ message: string }> = ({ message }) => (
<div
className="min-h-screen flex items-center justify-center p-6"
style={{
backgroundColor: 'var(--color-background, #fafafa)',
color: 'var(--color-text)',
}}
>
<div
className="max-w-md w-full rounded-lg border p-6"
style={{
borderColor: '#fecaca',
backgroundColor: 'color-mix(in srgb, #fee2e2 50%, var(--color-surface))',
}}
>
<h1 className="text-lg font-bold mb-2" style={{ color: '#991b1b' }}>{message}</h1>
</div>
</div>
);
const ResultBox: React.FC<{
result: { applied: PaymentCheckAction; reminderLevel?: number; reminderSkipped?: string };
inv: PaymentCheckView;
issuer: PaymentCheckIssuer | null;
}> = ({ result, inv, issuer }) => {
const { t } = useTranslation();
return (
<div
className="min-h-screen flex items-center justify-center p-6"
style={{
backgroundColor: 'var(--color-background, #fafafa)',
color: 'var(--color-text)',
}}
>
<div className="max-w-md w-full">
<BrandingHeader issuer={issuer} />
<div
className="rounded-lg border p-6"
style={{
borderColor: '#bbf7d0',
backgroundColor: 'color-mix(in srgb, #dcfce7 50%, var(--color-surface))',
}}
>
<CheckCircle2 className="w-10 h-10 mb-3" style={{ color: '#16a34a' }} />
<h1 className="text-lg font-bold mb-1" style={{ color: '#166534' }}>
{t('paymentCheck.result.title', 'Action recorded')}
</h1>
<p className="text-sm">
{result.applied === 'paid_full' && t('paymentCheck.result.paid',
'Invoice {{n}} marked as paid in full.', { n: inv.invoiceNumber })}
{result.applied === 'paid_with_skonto' && t('paymentCheck.result.paidSkonto',
'Invoice {{n}} marked as paid with Skonto applied.', { n: inv.invoiceNumber })}
{result.applied === 'partial' && t('paymentCheck.result.partial',
'Partial payment logged for invoice {{n}}. Customer reminder queued for the remainder.',
{ n: inv.invoiceNumber })}
{result.applied === 'unpaid' && result.reminderSkipped === 'max_level_reached'
&& t('paymentCheck.result.unpaidMax',
'Recorded as unpaid. Maximum reminder level already reached — handle this customer offline.')}
{result.applied === 'unpaid' && !result.reminderSkipped
&& t('paymentCheck.result.unpaid',
'Recorded as unpaid. Customer reminder queued (level {{lvl}}).',
{ lvl: result.reminderLevel || 1 })}
</p>
<p className="text-xs mt-4" style={{ color: 'var(--color-muted-text, #737373)' }}>
{t('paymentCheck.result.close', 'You can close this tab.')}
</p>
</div>
</div>
</div>
);
};
export default PaymentCheckPage;
@@ -0,0 +1,380 @@
/**
* Public quote accept/decline page. Mounted at /quote/:token (outside
* any auth gate). The customer arrives here from the email button.
*
* Behaviour:
* - Reads the quote via /api/public/quotes/:token (no auth)
* - Shows the line items + totals (read-only)
* - Two big buttons: Accept / Decline
* - Within the response window (default 15 min) the customer can
* re-toggle. After the window the page shows a locked state and
* hides the buttons.
* - Localised based on the quote's stored language; falls back to
* browser language.
*/
import React, { useEffect, useState } from 'react';
import { useParams, useSearchParams } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { useQuery } from '@tanstack/react-query';
import { publicQuotesService } from '../../services/quotes.service';
import { usePublicDarkMode } from '../../hooks/usePublicDarkMode';
import { useLocalizedDate } from '../../hooks/useLocalizedDate';
import { Loading } from '../../components/common';
import { formatMoney } from '../../utils/money';
/**
* Format a date string as DD.MM.YYYY (the customer-facing format used
* on the PDF too). Accepts ISO strings, Date objects, or already-short
* "YYYY-MM-DD" forms; returns the original if parsing fails.
*/
import { formatShortDate } from '../../utils/dateShort';
export const QuoteResponsePage: React.FC = () => {
const { t, i18n } = useTranslation();
const { formatDateTime: fmtDateTime } = useLocalizedDate();
const { token } = useParams<{ token: string }>();
const [searchParams, setSearchParams] = useSearchParams();
const [error, setError] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
// Apply dark mode per the branding settings (forced dark/light)
// or fall back to the OS preference. Without this the public
// quote page renders in light mode regardless of admin settings.
usePublicDarkMode();
const { data, isLoading, isError, refetch } = useQuery({
queryKey: ['public-quote', token],
queryFn: () => publicQuotesService.get(token!),
enabled: !!token,
retry: false,
});
React.useEffect(() => {
// Switch interface language to the quote's language if it differs
// from the browser default — matches what the customer expects.
if (data?.quote.language && data.quote.language !== i18n.language) {
i18n.changeLanguage(data.quote.language).catch(() => {});
}
}, [data, i18n]);
const q = data?.quote;
// Tick state for the optional Terms of Service step. Pre-ticked if
// the quote was already accepted (so re-displays don't lose state).
const [tosAccepted, setTosAccepted] = useState(false);
useEffect(() => {
if (q?.tos?.acceptedAt) setTosAccepted(true);
}, [q?.tos?.acceptedAt]);
const handleRespond = React.useCallback(async (action: 'accept' | 'decline') => {
setBusy(true);
setError(null);
try {
await publicQuotesService.respond(token!, action, { tosAccepted });
await refetch();
} catch (err: any) {
if (err?.response?.data?.code === 'RESPONSE_LOCKED') {
setError(t('quoteResponse.locked', 'Your response window has closed and the decision is now final.'));
} else if (err?.response?.data?.code === 'TOS_REQUIRED') {
setError(t('quoteResponse.tosRequiredError',
'Please tick "I accept the Terms of Service" before accepting the quote, or click Decline to refuse.'));
} else {
setError(err?.response?.data?.error || err.message || 'Something went wrong');
}
} finally { setBusy(false); }
}, [token, refetch, t, tosAccepted]);
// PRE-SELECTED ACTION FROM EMAIL LINK
//
// The Accept / Decline links in the customer email carry the chosen
// action as a query string, e.g. /quote/<token>?action=accept.
//
// SECURITY: this MUST NOT auto-submit. Link prefetchers (Outlook
// Safe Links, some Slack-like clients, Edge's tab-preview pre-fetch,
// antivirus URL scanners) will GET the URL before the customer
// ever clicks — auto-submission would silently flip the quote
// state from a prefetch. Confirmed-clicked actions only.
//
// Instead we surface the intent via `preselectedAction` so the
// matching CTA is visually highlighted on the page; the customer
// confirms by clicking the on-page button. One human click is
// required regardless of how they arrived.
const preselectedAction = (() => {
const raw = searchParams.get('action');
return raw === 'accept' || raw === 'decline' ? raw : null;
})();
// Strip ?action= from the URL once the page is loaded so a refresh
// doesn't keep the highlight (and so the URL in the browser bar /
// history loses the action hint after first read). Idempotent.
useEffect(() => {
if (!searchParams.get('action')) return;
const next = new URLSearchParams(searchParams);
next.delete('action');
setSearchParams(next, { replace: true });
}, [searchParams, setSearchParams]);
if (isLoading) return (
<div className="min-h-screen flex items-center justify-center bg-neutral-50 dark:bg-neutral-900">
<Loading />
</div>
);
if (isError || !data) {
return (
<div className="min-h-screen bg-neutral-50 dark:bg-neutral-900 flex items-center justify-center p-6">
<div className="max-w-md text-center">
<h1 className="text-2xl font-bold mb-2 text-neutral-900 dark:text-neutral-100">
{t('quoteResponse.notFound', 'Quote not found')}
</h1>
<p className="text-neutral-600 dark:text-neutral-400">
{t('quoteResponse.notFoundBody', 'This link may have expired or been revoked. Please contact the photographer for a new quote.')}
</p>
</div>
</div>
);
}
// Past the `if (isError || !data)` guard above, `data` is non-null —
// so `data.quote` is the concrete Quote shape we render below. Hoist
// it to a narrowed local instead of asserting `q!` on every property
// access in the JSX. The original `q` stays around for the React
// hooks declared above the guard (they MUST run on every render).
const quote = data.quote;
const locked = !quote.canRespond;
const responseStatus = quote.respondedAt
? (quote.status === 'accepted' ? 'accepted' : quote.status === 'declined' ? 'declined' : 'pending')
: 'pending';
return (
<div className="min-h-screen bg-neutral-50 dark:bg-neutral-900 text-neutral-900 dark:text-neutral-100">
<div className="max-w-3xl mx-auto py-8 px-4">
{/* Issuer header logo + company name. The logo URL is
built server-side under /uploads/, so it works even on
self-hosted Docker installs where the frontend can't reach
the backend storage directory directly. */}
{quote.issuer && (
<div className="text-center mb-6">
{quote.issuer.logoUrl && (
<img
src={quote.issuer.logoUrl}
alt={quote.issuer.companyName || 'Logo'}
className="mx-auto mb-3 h-16 object-contain"
/>
)}
<h2 className="text-xl font-bold">{quote.issuer.companyName}</h2>
{quote.issuer.website && (
<p className="text-sm text-neutral-500 dark:text-neutral-400">{quote.issuer.website}</p>
)}
</div>
)}
<div className="bg-white dark:bg-neutral-800 rounded-xl shadow-sm border border-neutral-200 dark:border-neutral-700 p-6 md:p-8">
<div className="flex items-baseline justify-between mb-4 gap-3 flex-wrap">
<h1 className="text-2xl font-bold">{t('quoteResponse.title', 'Quote')} {quote.quoteNumber}</h1>
<span className={`text-xs font-medium px-2 py-1 rounded ${
quote.status === 'accepted' ? 'bg-green-100 text-green-800 dark:bg-green-900/40 dark:text-green-300'
: quote.status === 'declined' ? 'bg-red-100 text-red-800 dark:bg-red-900/40 dark:text-red-300'
: 'bg-blue-100 text-blue-800 dark:bg-blue-900/40 dark:text-blue-300'
}`}>{t(`quotes.status.${quote.status}`, quote.status)}</span>
</div>
{quote.recipient && (
<div className="mb-4 text-sm text-neutral-700 dark:text-neutral-300">
<p className="font-medium">{quote.recipient.companyName || quote.recipient.displayName}</p>
<p>{quote.recipient.email}</p>
</div>
)}
{quote.eventName && (
<p className="text-neutral-600 dark:text-neutral-400 mb-2">
<strong>{t('quoteResponse.event', 'Event')}:</strong> {quote.eventName}
{quote.eventDate && ` · ${formatShortDate(quote.eventDate)}`}
</p>
)}
<p className="text-neutral-600 dark:text-neutral-400 mb-4">
<strong>{t('quoteResponse.issueDate', 'Issued')}:</strong> {formatShortDate(quote.issueDate)}
{quote.validUntil && ` · ${t('quoteResponse.validUntil', 'valid until')} ${formatShortDate(quote.validUntil)}`}
</p>
{quote.introText && (
<p className="whitespace-pre-line text-neutral-700 dark:text-neutral-300 mb-4">{quote.introText}</p>
)}
<table className="w-full text-sm my-4">
<thead>
<tr className="border-b border-neutral-200 dark:border-neutral-700 text-neutral-600 dark:text-neutral-400">
<th className="text-left py-2 w-10">#</th>
<th className="text-left py-2">{t('quoteResponse.description', 'Description')}</th>
<th className="text-right py-2 w-16">{t('quoteResponse.qty', 'Qty')}</th>
<th className="text-right py-2 w-24">{t('quoteResponse.unit', 'Unit')}</th>
<th className="text-right py-2 w-24">{t('quoteResponse.total', 'Total')}</th>
</tr>
</thead>
<tbody>
{(() => {
// Migration 119 — sub-items + details_text. Top-level
// items get a numeric position; sub-items show empty
// position + indented description + parenthesised
// (display-only) line total. A non-empty detailsText
// renders as a small italic grey line directly below
// its parent. Sub-items with unit_price = 0 leave the
// price columns empty (transparency list only).
let topCount = 0;
const rows: React.ReactNode[] = [];
for (const li of quote.lineItems) {
const isSub = li.parentLineItemId != null || li.parentPosition != null;
if (!isSub) topCount += 1;
const priceless = isSub && (!li.unitPriceMinor || Number(li.unitPriceMinor) === 0);
rows.push(
<tr key={`row-${li.position}`} className={`border-b border-neutral-100 dark:border-neutral-700/70 ${
isSub ? 'text-neutral-600 dark:text-neutral-400' : ''
}`}>
<td className="py-2">{isSub ? '' : topCount}</td>
<td className={`py-2 whitespace-pre-line ${isSub ? 'pl-6' : ''}`}>
{isSub ? '• ' : ''}{li.description}
</td>
<td className="py-2 text-right">{Number(li.quantity)}</td>
<td className="py-2 text-right tabular-nums">
{priceless ? '' : formatMoney(Number(li.unitPriceMinor) / 100, quote.currency)}
</td>
<td className={`py-2 text-right tabular-nums ${isSub ? 'italic' : ''}`}>
{priceless
? ''
: isSub
? `(${formatMoney(Number(li.lineTotalMinor) / 100, quote.currency)})`
: formatMoney(Number(li.lineTotalMinor) / 100, quote.currency)}
</td>
</tr>
);
if (li.detailsText && String(li.detailsText).trim().length > 0) {
rows.push(
<tr key={`details-${li.position}`} className="border-b border-neutral-100 dark:border-neutral-700/70">
<td className="py-1"></td>
<td className={`py-1 text-xs italic text-neutral-500 dark:text-neutral-400 whitespace-pre-line ${isSub ? 'pl-10' : 'pl-4'}`}
colSpan={4}>
{li.detailsText}
</td>
</tr>
);
}
}
return rows;
})()}
</tbody>
</table>
<div className="flex flex-col items-end gap-1 text-sm border-t border-neutral-200 dark:border-neutral-700 pt-3">
<div className="flex gap-6"><span className="text-neutral-600 dark:text-neutral-400">{t('quoteResponse.subtotal', 'Subtotal')}:</span>
<span className="tabular-nums w-28 text-right">{formatMoney(Number(quote.netAmountMinor) / 100, quote.currency)}</span></div>
{quote.vatAmountMinor > 0 && (
<div className="flex gap-6"><span className="text-neutral-600 dark:text-neutral-400">{t('quoteResponse.vat', 'VAT')} ({Number(quote.vatRate || 0).toFixed(1)}%):</span>
<span className="tabular-nums w-28 text-right">{formatMoney(Number(quote.vatAmountMinor) / 100, quote.currency)}</span></div>
)}
<div className="flex gap-6 font-semibold text-base"><span>{t('quoteResponse.total', 'Total')}:</span>
<span className="tabular-nums w-28 text-right">{formatMoney(Number(quote.totalAmountMinor) / 100, quote.currency)}</span></div>
</div>
{quote.outroText && (
<p className="whitespace-pre-line text-neutral-700 dark:text-neutral-300 mt-4">{quote.outroText}</p>
)}
{/* Response area */}
<div className="mt-8 pt-6 border-t border-neutral-200 dark:border-neutral-700 text-center">
{error && <p className="text-red-600 dark:text-red-400 mb-4 text-sm">{error}</p>}
{busy && (
<p className="text-sm text-neutral-600 dark:text-neutral-400 mb-4">
{t('quoteResponse.submitting', 'Recording your response…')}
</p>
)}
{locked ? (
<p className="text-sm text-neutral-600 dark:text-neutral-400">
{responseStatus === 'accepted'
? t('quoteResponse.acceptedLocked', 'You accepted this quote on {{date}}. The decision is final.', { date: quote.respondedAt ? fmtDateTime(quote.respondedAt) : '' })
: responseStatus === 'declined'
? t('quoteResponse.declinedLocked', 'You declined this quote on {{date}}. The decision is final.', { date: quote.respondedAt ? fmtDateTime(quote.respondedAt) : '' })
: t('quoteResponse.cannotRespond', 'This quote can no longer be responded to via this link.')}
</p>
) : (
<>
<p className="text-sm text-neutral-600 dark:text-neutral-400 mb-4">
{quote.respondedAt
? t('quoteResponse.changeWithin', 'You can change your response until {{at}}.', { at: quote.responseLockedAt ? new Date(quote.responseLockedAt).toLocaleTimeString() : '' })
: t('quoteResponse.intro', 'Please accept or decline this quote:')}
</p>
{/* Terms of Service step (admin-configurable in CRM
Settings). When `tos.required` is true, Accept is
disabled until the customer ticks the box. The
text + optional link come from settings; both can
be empty if only the checkbox + label is wanted. */}
{quote.tos && (quote.tos.text || quote.tos.required) && (
<div className="text-left max-w-prose mx-auto mb-4 rounded-md border border-neutral-200 dark:border-neutral-700 bg-neutral-50 dark:bg-neutral-900 p-4">
{quote.tos.text && (
<div className="text-xs text-neutral-700 dark:text-neutral-300 whitespace-pre-line mb-3 max-h-48 overflow-y-auto">
{quote.tos.text}
</div>
)}
<label className="flex items-start gap-2 text-sm text-neutral-700 dark:text-neutral-300 cursor-pointer">
<input
type="checkbox"
className="mt-0.5"
checked={tosAccepted}
onChange={(e) => setTosAccepted(e.target.checked)}
/>
<span>
{t('quoteResponse.tosCheckbox',
'I have read and accept the Terms of Service.')}
{quote.tos.url && (
<>
{' '}
<a href={quote.tos.url} target="_blank" rel="noopener noreferrer"
className="underline text-primary-600 dark:text-primary-400">
{t('quoteResponse.tosLink', 'Read the full Terms')}
</a>
</>
)}
</span>
</label>
</div>
)}
{/* Pre-selected from email link: gentle highlight on
the matching CTA via a glowing focus ring, plus a
short tip line. The customer still has to click
see the preselectedAction comment above for why
auto-submit is unsafe (link prefetchers). */}
{preselectedAction && (
<p className="text-center text-sm text-neutral-600 dark:text-neutral-300 mb-3">
{preselectedAction === 'accept'
? t('quoteResponse.preselectAcceptHint', 'You followed the "Accept" link from the email — click the button below to confirm.')
: t('quoteResponse.preselectDeclineHint', 'You followed the "Decline" link from the email — click the button below to confirm.')}
</p>
)}
<div className="flex justify-center gap-3 flex-wrap">
<button
type="button"
disabled={busy || (quote.tos?.required && !tosAccepted)}
onClick={() => handleRespond('accept')}
className={`px-6 py-3 rounded-md bg-green-600 hover:bg-green-700 text-white font-medium disabled:opacity-50 ${
preselectedAction === 'accept' ? 'ring-4 ring-green-300 dark:ring-green-700 ring-offset-2 ring-offset-white dark:ring-offset-neutral-900' : ''
}`}
>{t('quoteResponse.accept', 'Accept quote')}</button>
<button
type="button" disabled={busy}
onClick={() => handleRespond('decline')}
className={`px-6 py-3 rounded-md border border-neutral-300 dark:border-neutral-600 hover:bg-neutral-50 dark:hover:bg-neutral-700 text-neutral-700 dark:text-neutral-200 font-medium disabled:opacity-50 ${
preselectedAction === 'decline' ? 'ring-4 ring-neutral-400 dark:ring-neutral-500 ring-offset-2 ring-offset-white dark:ring-offset-neutral-900' : ''
}`}
>{t('quoteResponse.decline', 'Decline')}</button>
</div>
</>
)}
</div>
</div>
{quote.issuer?.footerLine && (
<p className="text-center text-xs text-neutral-500 dark:text-neutral-400 mt-4">{quote.issuer.footerLine}</p>
)}
</div>
</div>
);
};
+388
View File
@@ -0,0 +1,388 @@
/**
* Admin Invoices API client. Hits /api/admin/invoices/*.
*/
import { api } from '../config/api';
import type { QuoteLineItem } from './quotes.service';
// `'skipped'` is the post-fact status of a monthly draft that aged out
// of its period with zero line items (migration 128 monthly pass).
// Carried in the row for audit-trail continuity; never visible to the
// customer, never counted in tax/dashboard aggregates.
export type InvoiceStatus = 'scheduled' | 'sent' | 'paid' | 'overdue' | 'cancelled' | 'pending_delivery' | 'skipped';
/**
* Document discriminator. `'invoice'` is the default; `'storno'` rows
* are Stornorechnungen (cancellation invoices) generated by the
* cancel/reissue flow. Both share the table + sequence, distinguished
* here so the UI can render the correct title/badge and gate actions
* (a Storno can't be cancelled, paid, or reissued).
*/
export type InvoiceKind = 'invoice' | 'storno';
export type InvoiceSort =
| 'newest' | 'oldest'
| 'due_asc' | 'due_desc'
| 'value_asc' | 'value_desc'
| 'customer_asc';
export type InvoiceQrFormat = 'swiss' | 'epc' | 'none';
export interface InvoiceSummary {
id: number;
/** 'invoice' (default) or 'storno' (cancellation document). Drives
* badge rendering + action gating in the admin list and detail
* views. */
kind: InvoiceKind;
invoiceNumber: string;
/** Cross-document lineage UUID (migration 140). See the equivalent
* field on QuoteSummary. */
dealUuid: string | null;
customerAccountId: number;
customer: {
email: string | null;
displayName: string | null;
firstName: string | null;
lastName: string | null;
companyName: string | null;
/** Computed server-side from password_hash. true = admin-only
* customer (no portal access). Drives the Passive badge that
* appears next to the customer label across editor + lists. */
isPassive?: boolean;
};
sourceQuoteId: number | null;
/** Migration 130 set by contractService.convertToInvoiceOnly when
* the invoice originates from a contract. */
sourceContractId?: number | null;
/** Human-readable quote number of the source quote (joined-in on read).
* Surfaced on the invoice detail view so the link reads
* "From quote LBM-Q-2026-0006" rather than "From quote #6". */
sourceQuoteNumber?: string | null;
/** Human-readable contract number of the source contract (joined-in
* on read). Surfaced on the invoice detail view so the link reads
* "From contract LBM-C-2026-0010" rather than "From contract #10". */
sourceContractNumber?: string | null;
eventId: number | null;
/** Inline event snapshot (migration 123). Free-text label for the
* event the invoice relates to. Persists independently of the
* events FK so renames don't retroactively change historical
* documents. Shown next to the invoice number on the customer
* portal and as the "Event" column on the admin list. */
eventName: string | null;
/** Event date snapshot. */
eventDate: string | null;
/** Event start time, "HH:MM". */
eventTimeStart: string | null;
/** Event end time, "HH:MM". */
eventTimeEnd: string | null;
language: string;
currency: string;
issueDate: string;
dueDate: string;
installmentIndex: number;
installmentTotal: number;
installmentLabel: string | null;
installmentTrigger: string | null;
status: InvoiceStatus;
scheduledSendAt: string | null;
sentAt: string | null;
totalAmountMinor: number;
paidAmountMinor: number;
reminderLevel: number;
lateFeeAmountMinor: number;
/** True when the invoice was attached from a historical PDF
* (migration 111). Hide line-item editing on these rows; the
* uploaded PDF is the source of truth. */
isImported?: boolean;
}
export interface InvoiceDetail extends InvoiceSummary {
netAmountMinor: number;
vatRate: number | null;
vatAmountMinor: number;
shippingAmountMinor: number;
paidAt: string | null;
paymentMethod: string | null;
paymentReference: string | null;
lastReminderSentAt: string | null;
ccPdfEmail: string | null;
qrFormat: InvoiceQrFormat | null;
pdfPath: string | null;
businessBankAccountId: number | null;
/** Selected payment-term template id (migration 113). When set
* the renderer uses this template's snapshot for the
* Zahlungsbedingungen block; otherwise it falls back to the
* source quote's snapshot or the global crm_invoices_* defaults. */
paymentTermTemplateId?: number | null;
/** Migration 124 split payment-term picker. The editor prefers
* these over the legacy single FK; null on legacy rows. Both must
* be set together for the new path to engage server-side. */
paymentNetDaysTemplateId?: number | null;
paymentTimingTemplateId?: number | null;
/** Effective Skonto percentage resolved server-side from the
* invoice's payment-term snapshot (with legacy + global fallback).
* null when no Skonto is configured used by the BillDetail
* "Record payment" dialog to decide whether to show the
* "Paid with Skonto" checkbox. Migration 126. */
skontoPercent?: number | null;
/** Per-invoice Skonto opt-out admin ticked "Disable Skonto" in
* the editor. When true, `skontoPercent` is suppressed regardless
* of what the snapshot / global default offers. Migration 126. */
skontoDisabled?: boolean;
/** Set when this invoice was created via Cancel & reissue
* points at the cancelled original. Migration 114. */
replacesInvoiceId?: number | null;
/** On a Storno row (`kind='storno'`): points at the original
* invoice this Storno reverses. Drives the "Storno zu Rechnung
* R-XXXX" reference rendering in the admin detail view. */
cancelsInvoiceId?: number | null;
/** Human invoice_number of the row referenced by `cancelsInvoiceId`
* (joined server-side). Used so the lineage banner can display
* "R-2026-0007" instead of the bare row id "#7". */
cancelsInvoiceNumber?: string | null;
/** On a CANCELLED original: points at the Storno that cancelled
* it. Drives the "Cancelled by Storno S-XXXX" banner shown on
* the cancelled invoice's detail view. */
cancellationStornoId?: number | null;
/** Human invoice_number of the row referenced by
* `cancellationStornoId` (joined server-side). */
cancellationStornoNumber?: string | null;
}
export interface InvoicePayment {
id: number;
amountMinor: number;
paidAt: string;
paymentMethod: string | null;
reference: string | null;
notes: string | null;
createdAt: string;
}
export interface InvoiceWithLineItems {
invoice: InvoiceDetail;
lineItems: QuoteLineItem[];
payments: InvoicePayment[];
/** Migration 140 + commit #4. Always present on create; single
* invoice = one-element array, multi-installment spawn = N. */
invoiceIds?: number[];
}
export interface InvoiceCreatePayload {
customerAccountId: number;
sourceQuoteId?: number;
eventId?: number;
language?: string;
currency?: string;
issueDate?: string;
dueDate?: string;
scheduledSendAt?: string;
installmentIndex?: number;
installmentTotal?: number;
installmentLabel?: string;
installmentTrigger?: string;
vatRate?: number;
shippingAmountMinor?: number;
ccPdfEmail?: string;
// null = explicitly clear the per-invoice override (back to the
// business-profile default for the currency). undefined = no
// change. number = pin this specific bank account.
businessBankAccountId?: number | null;
qrFormat?: InvoiceQrFormat;
paymentTermTemplateId?: number | null;
// Migration 124 — split payment-term picker. Both required by the
// editor in tandem; backend composes the merged snapshot.
paymentNetDaysTemplateId?: number | null;
paymentTimingTemplateId?: number | null;
// Per-invoice Skonto opt-out (migration 126).
skontoDisabled?: boolean;
// Ad-hoc installments (commit #6 of the deal_uuid PR). When set
// with ≥2 rows, backend spawns N invoices via
// spawnInstallmentInvoices and returns invoiceIds[]. Single row or
// omitted → single invoice.
installments?: import('./quotes.service').PaymentTermInstallment[];
// Inline event snapshot (migration 123). All optional — standalone
// invoices may have none of these.
eventName?: string;
eventDate?: string;
eventTimeStart?: string;
eventTimeEnd?: string;
lineItems: QuoteLineItem[];
}
export interface InvoiceListResponse {
invoices: InvoiceSummary[];
pagination: { total: number; page: number; pageSize: number; totalPages: number };
}
export const billsService = {
async list(params: {
status?: InvoiceStatus[];
customerAccountId?: number;
sourceQuoteId?: number;
unpaidOnly?: boolean;
q?: string;
sort?: InvoiceSort;
page?: number;
pageSize?: number;
} = {}): Promise<InvoiceListResponse> {
const { data } = await api.get('/admin/invoices', {
params: {
...params,
status: params.status?.join(','),
},
});
return data.data || data;
},
async get(id: number): Promise<InvoiceWithLineItems> {
const { data } = await api.get(`/admin/invoices/${id}`);
return data.data || data;
},
async create(payload: InvoiceCreatePayload): Promise<InvoiceWithLineItems> {
const { data } = await api.post('/admin/invoices', payload);
return data.data || data;
},
async update(id: number, payload: Partial<InvoiceCreatePayload>): Promise<InvoiceWithLineItems> {
const { data } = await api.put(`/admin/invoices/${id}`, payload);
return data.data || data;
},
async send(id: number): Promise<{ sent: true }> {
const { data } = await api.post(`/admin/invoices/${id}/send`);
return data.data || data;
},
async markPaid(id: number, payload: {
amountMinor: number;
paidAt?: string;
paymentMethod?: string;
reference?: string;
notes?: string;
/** Migration 126 when true the payment-log row records the
* Skonto discount amount and the invoice is treated as fully
* settled even though paid_amount_minor < total_amount_minor. */
skontoApplied?: boolean;
}): Promise<{ paidTotalMinor: number; status: InvoiceStatus }> {
const { data } = await api.post(`/admin/invoices/${id}/mark-paid`, payload);
return data.data || data;
},
async sendReminder(id: number, level?: 1 | 2): Promise<{ level: number; lateFeeMinor: number }> {
const { data } = await api.post(`/admin/invoices/${id}/send-reminder`, { level });
return data.data || data;
},
/** Cancel an invoice. For draft (`scheduled`) invoices this is a
* silent soft-cancel; for issued invoices (`sent`/`overdue`/`paid`)
* the backend generates a Stornorechnung and emails it to the
* customer automatically. `stornoId` is non-null in the issued
* case so callers can navigate to the cancellation document. */
async cancel(id: number): Promise<{ cancelled: true; stornoId: number | null }> {
const { data } = await api.post(`/admin/invoices/${id}/cancel`);
return data.data || data;
},
/** Release a pending_delivery invoice photographer has delivered
* the photos and is ready to collect the final installment. The
* email fires immediately. */
async releaseForDelivery(id: number): Promise<{ sent: true }> {
const { data } = await api.post(`/admin/invoices/${id}/release-for-delivery`);
return data.data || data;
},
/** Fire the admin payment-check email immediately, bypassing the
* 24h throttle. Useful for testing the flow without waiting for
* the scheduled reminder window to elapse. */
async testPaymentCheck(id: number): Promise<{ token: string; sent: true }> {
const { data } = await api.post(`/admin/invoices/${id}/test-payment-check`);
return data.data || data;
},
/** Cancel & reissue atomically cancels this invoice (via Storno
* when the original was already issued) and creates a fresh
* scheduled duplicate linked via `replacesInvoiceId`. Returns the
* new invoice's id, the original's id (`replaces`), and the
* generated Storno's id (`stornoId`, null when the original was
* already cancelled and no Storno needed to be created). Caller
* typically navigates to /admin/clients/bills/:id/edit to adjust
* before sending. */
async reissue(id: number): Promise<{ id: number; replaces: number; stornoId: number | null }> {
const { data } = await api.post(`/admin/invoices/${id}/reissue`);
return data.data || data;
},
async pdfUrl(id: number): Promise<string> {
const res = await api.get(`/admin/invoices/${id}/pdf`, { responseType: 'blob' });
return URL.createObjectURL(res.data);
},
async previewPdfUrl(payload: InvoiceCreatePayload): Promise<string> {
const res = await api.post('/admin/invoices/preview', payload, { responseType: 'blob' });
return URL.createObjectURL(res.data);
},
/**
* Attach a historical invoice PDF (from a previous billing system)
* to a customer's account. The backend creates a minimal invoice
* row whose imported_pdf_path points at the uploaded file
* customer + admin PDF endpoints stream the original document.
*/
async importHistorical(payload: {
customerAccountId: number;
invoiceNumber: string;
issueDate: string;
dueDate?: string;
totalAmountMinor: number;
currency?: string;
status?: 'sent' | 'paid' | 'overdue';
paidAmountMinor?: number;
language?: string;
file: File;
}): Promise<InvoiceWithLineItems> {
const form = new FormData();
form.append('pdf', payload.file);
form.append('customerAccountId', String(payload.customerAccountId));
form.append('invoiceNumber', payload.invoiceNumber);
form.append('issueDate', payload.issueDate);
if (payload.dueDate) form.append('dueDate', payload.dueDate);
form.append('totalAmountMinor', String(payload.totalAmountMinor));
if (payload.currency) form.append('currency', payload.currency);
if (payload.status) form.append('status', payload.status);
if (payload.paidAmountMinor != null) form.append('paidAmountMinor', String(payload.paidAmountMinor));
if (payload.language) form.append('language', payload.language);
const { data } = await api.post('/admin/invoices/import', form, {
headers: { 'Content-Type': 'multipart/form-data' },
});
return data.data || data;
},
};
export interface CrmOverviewStats {
currency: string;
quotes: {
draft: number; sent: number; accepted: number;
declined: number; expired: number; converted: number;
};
invoices: {
scheduled: number; sent: number; paid: number;
overdue: number; cancelled: number;
};
revenue: {
monthMinor: number;
quarterMinor: number;
yearMinor: number;
};
outstanding: {
totalMinor: number;
invoiceCount: number;
};
generatedAt: string;
}
/** CRM headline metrics — quote / invoice counts, rolling revenue
* windows (30 / 90 / 365 days), outstanding payment total. Drives
* the /admin/clients/overview tab. */
export async function fetchCrmOverview(): Promise<CrmOverviewStats> {
const { data } = await api.get('/admin/dashboard/crm-stats');
return data;
}
@@ -0,0 +1,157 @@
/**
* Admin Business profile API client.
*
* Backs the Settings Business profile tab. Issuer block + bank account
* roster used to render every quote / invoice PDF.
*/
import { api } from '../config/api';
export type QrFormat = 'swiss' | 'epc' | 'none';
export interface BusinessProfile {
id: number;
companyName: string;
addressLine1: string;
addressLine2: string;
postalCode: string;
city: string;
state: string;
countryCode: string;
/** Free-text country name (migration 107). When set, the PDF
* renderer uses this verbatim; otherwise falls back to the
* locale-aware lookup on countryCode. Useful when countryCode
* carries the postal/vehicle abbreviation ("FL") rather than the
* ISO code ("LI"). */
countryName: string;
phone: string;
mobile: string;
email: string;
website: string;
vatId: string;
/** Local tax number (Steuernummer in DE/AT). Distinct from vatId
* (USt-IdNr.); §14 UStG accepts either on invoices, and the issuer
* block on every PDF renders both when present. Migration 139. */
taxId: string;
vatLabel: string;
vatRateDefault: number | null;
defaultCurrency: string;
defaultLocale: string;
defaultQrFormat: QrFormat;
footerLine: string;
logoPath: string;
/** Path (absolute or relative to storage/) to a TTF/OTF used by the
* PDF renderer. Falls back to Helvetica when blank or missing.
* The UI for setting this was retired in favour of `pdfFontFamily`
* (migration 121) but the field stays read-only on the type so
* legacy values keep flowing through. */
pdfFontTtfPath: string;
/** Bundled-fonts dropdown choice (migration 121). Stores the
* on-disk directory name under backend/assets/fonts/ (e.g.
* "Inter", "Playfair-Display"). null = no preference, Helvetica
* fallback. */
pdfFontFamily: string | null;
/** When false, the issuer logo image is suppressed on every PDF
* (even if logoPath is set). Migration 106; defaults true. */
pdfShowLogo: boolean;
/** When false, the company name line is suppressed in the issuer
* block on every PDF. Migration 106; defaults true. */
pdfShowCompanyName: boolean;
/** When true, the company name renders as a plain address line
* (same size + weight as the street) right above the address
* instead of as a bold title under the logo. Migration 108. */
pdfCompanyNameInline: boolean;
/** Logo banner height in PDF points. 24-200. Defaults to 56.
* Migration 108. */
pdfLogoHeight: number;
/** DIN 5008 folding marks on the page edge.
* 'none' (default) | 'half' | 'third' | 'both'. Migration 108. */
pdfFoldingMarks: 'none' | 'half' | 'third' | 'both';
/** When true, render the "X days from invoice date." line in the
* payment-conditions block of QUOTE PDFs. Invoices always show
* this row regardless. Migration 110; defaults FALSE. */
pdfQuoteShowNetDays: boolean;
/** When true, render the Skonto offer + "Amount with discount"
* lines in the payment-conditions block of QUOTE PDFs. Invoices
* always show these regardless. Migration 110; defaults FALSE. */
pdfQuoteShowSkonto: boolean;
/** IANA timezone string for the admin calendar (e.g. "Europe/Zurich",
* "America/New_York"). Migration 137. Admin-only never exposed
* via publicSettings. When null/empty, the calendar UI falls back
* to the browser's `Intl.DateTimeFormat().resolvedOptions().timeZone`. */
timezone: string | null;
createdAt: string;
updatedAt: string;
}
export interface BankAccount {
id: number;
label: string;
accountHolder: string;
iban: string;
bic: string;
currency: string;
isDefault: boolean;
displayOrder: number;
createdAt: string;
updatedAt: string;
}
export interface BusinessProfileSnapshot {
profile: BusinessProfile;
bankAccounts: BankAccount[];
}
export type BusinessProfilePatch = Partial<Omit<BusinessProfile, 'id' | 'createdAt' | 'updatedAt'>>;
export type BankAccountPatch = Partial<Omit<BankAccount, 'id' | 'createdAt' | 'updatedAt'>>;
export const businessProfileService = {
async get(): Promise<BusinessProfileSnapshot> {
const { data } = await api.get('/admin/business-profile');
return data.data || data;
},
async update(payload: BusinessProfilePatch): Promise<BusinessProfileSnapshot> {
const { data } = await api.put('/admin/business-profile', payload);
return data.data || data;
},
async listBankAccounts(): Promise<{ bankAccounts: BankAccount[] }> {
const { data } = await api.get('/admin/business-profile/bank-accounts');
return data.data || data;
},
async createBankAccount(payload: BankAccountPatch & { iban: string }): Promise<{ bankAccount: BankAccount }> {
const { data } = await api.post('/admin/business-profile/bank-accounts', payload);
return data.data || data;
},
async updateBankAccount(id: number, payload: BankAccountPatch): Promise<{ bankAccount: BankAccount }> {
const { data } = await api.put(`/admin/business-profile/bank-accounts/${id}`, payload);
return data.data || data;
},
async deleteBankAccount(id: number): Promise<{ deleted: true }> {
const { data } = await api.delete(`/admin/business-profile/bank-accounts/${id}`);
return data.data || data;
},
/**
* Upload a dedicated PDF letterhead logo. Accepts PNG, JPEG, and
* SVG the renderer rasterises SVG to PNG via sharp.
*/
async uploadLogo(file: File): Promise<{ logoPath: string }> {
const form = new FormData();
form.append('logo', file);
const { data } = await api.post('/admin/business-profile/logo', form, {
headers: { 'Content-Type': 'multipart/form-data' },
});
return data.data || data;
},
/** Clear the dedicated PDF logo. The renderer then falls back to
* the global branding logo (Settings Branding). */
async clearLogo(): Promise<{ cleared: true }> {
const { data } = await api.delete('/admin/business-profile/logo');
return data.data || data;
},
};
+99
View File
@@ -0,0 +1,99 @@
/**
* Admin calendar API client.
*
* Wraps the single aggregate endpoint
* GET /api/admin/calendar/items?from=YYYY-MM-DD&to=YYYY-MM-DD
* exposed by `backend/src/routes/adminCalendar.js` (E.3).
*
* The response is a discriminated union on `kind` so the calendar page
* can switch render style per item type without juggling four separate
* react-query subscriptions.
*
* Hour-entry mutations stay on the existing customerAdmin.service
* the calendar's drag-create / inline-edit modals call those directly.
*/
import { api } from '../config/api';
export type CalendarItemKind = 'event' | 'hours' | 'quote' | 'contract';
interface CalendarItemBase {
kind: CalendarItemKind;
customerName: string | null;
}
export interface CalendarEventItem extends CalendarItemBase {
kind: 'event';
id: number;
slug: string;
eventName: string;
eventDate: string;
eventTimeStart: string | null;
eventTimeEnd: string | null;
isFullDay: boolean;
}
export interface CalendarHoursItem extends CalendarItemBase {
kind: 'hours';
id: number;
customerAccountId: number;
entryDate: string;
startTime: string;
endTime: string;
description: string | null;
status: 'unbilled' | 'billed' | 'cancelled';
invoiceId: number | null;
invoiceStatus: string | null;
/**
* True when this entry's invoice has been armed for send (monthly
* draft cleared + scheduled_send_at set, OR status sent/paid/cancelled).
* Backend computes this via customerHoursService._internal.isEntryLocked.
* Drives the calendar's locked badge + edit-disabled state.
*/
locked: boolean;
}
export interface CalendarQuoteItem extends CalendarItemBase {
kind: 'quote';
id: number;
quoteNumber: string;
eventName: string | null;
eventDate: string;
eventTimeStart: string | null;
eventTimeEnd: string | null;
status: 'sent' | 'accepted';
}
export interface CalendarContractItem extends CalendarItemBase {
kind: 'contract';
id: number;
contractNumber: string;
eventName: string | null;
eventDate: string;
eventTimeStart: string | null;
eventTimeEnd: string | null;
status: 'signed_by_customer' | 'fully_signed';
}
export type CalendarItem =
| CalendarEventItem
| CalendarHoursItem
| CalendarQuoteItem
| CalendarContractItem;
export interface CalendarItemsResponse {
items: CalendarItem[];
range: { from: string; to: string };
}
export const calendarService = {
/**
* Fetch all four layers (events, hours, pending quotes, pending
* contracts) for the supplied date range. Both ends are inclusive,
* ISO YYYY-MM-DD. Backend caps the range at 90 days.
*/
async list(params: { from: string; to: string }): Promise<CalendarItemsResponse> {
const { data } = await api.get('/admin/calendar/items', { params });
return data.data || data;
},
};
+455
View File
@@ -0,0 +1,455 @@
/**
* Admin Contracts API client. Hits /api/admin/contracts/*.
*
* Mirrors bills.service.ts shape: `data.data || data` unwrap, blob
* responses for PDFs via URL.createObjectURL.
*/
import { api } from '../config/api';
/** One leg of the integrity-check response (unsigned or signed PDF).
* `expected` is the stored SHA-256 column value; `actual` is freshly
* computed off the file on disk. `match` is true only when both are
* set and equal. `present:false` means the file doesn't exist on
* disk usually expected for `signed` until the customer signs. */
export interface ContractIntegrityLeg {
path: string | null;
present: boolean;
expected: string | null;
actual: string | null;
match: boolean;
}
export interface ContractIntegrityResult {
unsigned: ContractIntegrityLeg;
signed: ContractIntegrityLeg;
}
/** Shape of one row from /admin/contracts/:id/audit-trail. */
export interface AuditEntry {
id: number;
activity_type: string;
actor_type: string | null;
actor_id: number | null;
actor_name: string | null;
metadata: Record<string, unknown>;
created_at: string;
}
export type ContractStatus =
| 'draft'
| 'sent'
| 'signed_by_customer'
| 'signed_by_admin'
| 'fully_signed'
| 'cancelled';
export type ContractSort = 'newest' | 'oldest' | 'customer_asc';
/** Canonical section enum kept in sync with backend SECTIONS_ORDER
* and contractBlocksService.ALLOWED_SECTIONS. Renaming any value
* here also needs a backend update there's a test that guards it. */
export type ContractBlockSection =
| 'basics'
| 'scope'
| 'privacy'
| 'commercial'
| 'nda'
| 'closing';
export const CONTRACT_SECTIONS: ContractBlockSection[] = [
'basics', 'scope', 'privacy', 'commercial', 'nda', 'closing',
];
export interface ContractBlock {
id: number;
slug: string;
section: ContractBlockSection;
name: string;
description: string | null;
bodyText: string;
bodyTextDe: string | null;
/** Migration 131 locale-variant bodies. Null until the admin
* fills them in via the block library editor. Render context falls
* back EN when the contract's locale has no translation. */
bodyTextRu: string | null;
bodyTextPt: string | null;
bodyTextNl: string | null;
bodyTextFr: string | null;
isSystem: boolean;
isActive: boolean;
displayOrder: number;
createdAt: string;
updatedAt: string;
}
export interface ContractBlockInclusion {
id: number;
blockId: number;
section: ContractBlockSection;
position: number;
included: boolean;
block: {
slug: string;
name: string;
description: string | null;
bodyText: string;
bodyTextDe: string | null;
isSystem: boolean;
};
bodyTextSnapshot: string | null;
bodyTextDeSnapshot: string | null;
}
export interface ContractSummary {
id: number;
contractNumber: string;
/** Cross-document lineage UUID (migration 140). See QuoteSummary. */
dealUuid: string | null;
customerAccountId: number;
customer: {
email: string | null;
displayName: string | null;
firstName: string | null;
lastName: string | null;
companyName: string | null;
preferredLanguage?: string | null;
};
status: ContractStatus;
language: string;
issueDate: string;
validUntil: string | null;
title: string | null;
/** Event snapshot fields (migration 130 in-place edit). Mirror
* quotes.event_* + invoices.event_* so the label flows through
* quote contract invoice unchanged. Null when the standalone
* contract didn't set them OR when the DB hasn't re-migrated yet. */
eventName?: string | null;
eventDate?: string | null;
eventTimeStart?: string | null;
eventTimeEnd?: string | null;
introText: string | null;
outroText: string | null;
pdfPath: string | null;
signedPdfPath: string | null;
/** SHA-256 hex digest of the on-disk PDF surfaced for the
* audit-trail panel so the admin (and the customer in their
* audit confirmation) can verify file integrity by re-hashing. */
pdfSha256?: string | null;
signedPdfSha256?: string | null;
/** Migration 136 post-sign PDF re-stamp failure marker. When non-
* null, the most recent stamp attempt threw and the contract is in
* an orphan state (status is signed_by_customer or signed_by_admin
* but signed_pdf_path is missing). Detail page surfaces a recovery
* banner pointing at the resend-signed / restamp-signatures admin
* routes. Cleared by any successful subsequent stamp. */
signedPdfRenderFailedAt?: string | null;
signedPdfRenderError?: string | null;
sentAt: string | null;
signedByCustomerAt: string | null;
signedByAdminAt: string | null;
signedCustomerName: string | null;
signedAdminName: string | null;
/** Disk paths to the captured signature PNGs. Surfaced only so the
* UI can show a "(no image)" hint next to evidence rows whose
* customer/admin signature didn't capture (e.g. old canvas bug);
* the paths themselves are never exposed in user-facing strings. */
signedCustomerSignaturePath?: string | null;
signedAdminSignaturePath?: string | null;
createdByAdminId: number | null;
/** Lineage back-pointers (migration 130). Used by the detail page to
* render "Linked quote" + "Linked invoices" panels. Null when the
* contract was created standalone or when the DB lineage columns
* haven't migrated yet. */
sourceQuoteId?: number | null;
convertedEventId?: number | null;
createdAt: string;
updatedAt: string;
inclusions?: ContractBlockInclusion[];
}
export type ContractDetail = ContractSummary & {
inclusions: ContractBlockInclusion[];
};
export interface ContractListResponse {
contracts: ContractSummary[];
total: number;
page: number;
pageSize: number;
}
export interface ContractCreatePayload {
customerAccountId: number;
language?: string;
title?: string | null;
/** Event snapshot fields — same shape as the quote editor. */
eventName?: string | null;
eventDate?: string | null;
eventTimeStart?: string | null;
eventTimeEnd?: string | null;
introText?: string | null;
outroText?: string | null;
issueDate?: string;
validUntil?: string;
}
export interface ContractUpdatePayload {
title?: string | null;
eventName?: string | null;
eventDate?: string | null;
eventTimeStart?: string | null;
eventTimeEnd?: string | null;
introText?: string | null;
outroText?: string | null;
language?: string;
issueDate?: string;
validUntil?: string;
/** Full list of inclusions to write. Server rewrites the inclusion
* rows from this payload caller controls inclusion + per-section
* order via the position field. Omit to leave inclusions untouched. */
blocks?: Array<{ blockId: number; included?: boolean; position?: number }>;
}
export interface ContractBlockCreatePayload {
section: ContractBlockSection;
name: string;
bodyText: string;
bodyTextDe?: string | null;
bodyTextRu?: string | null;
bodyTextPt?: string | null;
bodyTextNl?: string | null;
bodyTextFr?: string | null;
description?: string | null;
displayOrder?: number;
isActive?: boolean;
}
export type ContractBlockUpdatePayload = Partial<ContractBlockCreatePayload>;
export const contractsService = {
async list(params: {
status?: ContractStatus[];
customerAccountId?: number;
q?: string;
sort?: ContractSort;
page?: number;
pageSize?: number;
} = {}): Promise<ContractListResponse> {
const { data } = await api.get('/admin/contracts', {
params: { ...params, status: params.status?.join(',') },
});
return data.data || data;
},
async get(id: number): Promise<{ contract: ContractDetail }> {
const { data } = await api.get(`/admin/contracts/${id}`);
return data.data || data;
},
async create(payload: ContractCreatePayload): Promise<{ contract: ContractDetail }> {
const { data } = await api.post('/admin/contracts', payload);
return data.data || data;
},
async update(id: number, payload: ContractUpdatePayload): Promise<{ contract: ContractDetail }> {
const { data } = await api.put(`/admin/contracts/${id}`, payload);
return data.data || data;
},
async send(id: number): Promise<{ token: string; pdfPath: string | null }> {
const { data } = await api.post(`/admin/contracts/${id}/send`);
return data.data || data;
},
async cancel(id: number): Promise<{ status: 'cancelled' }> {
const { data } = await api.post(`/admin/contracts/${id}/cancel`);
return data.data || data;
},
/** Convert a fully-signed contract into an event + scheduled invoices.
* Requires source_quote_id (no line items otherwise). Idempotent if
* the contract already has converted_event_id set the same event id
* comes back with alreadyConverted: true. */
async convertToEvent(id: number): Promise<{ eventId: number; alreadyConverted: boolean }> {
const { data } = await api.post(`/admin/contracts/${id}/convert-to-event`);
return data.data || data;
},
/** Convert a fully-signed contract directly into invoice(s) — no event. */
async convertToInvoice(id: number): Promise<{ installmentsCreated: number }> {
const { data } = await api.post(`/admin/contracts/${id}/convert-to-invoice`);
return data.data || data;
},
/** Re-render the signed PDF (system-render path only wet-signed
* uploads are preserved) and resend the contract_fully_signed
* email to both parties. Recovery action for contracts where the
* initial dual-party send failed silently. */
async resendSigned(id: number): Promise<{ signedPdfPath: string; resent: true }> {
const { data } = await api.post(`/admin/contracts/${id}/resend-signed`);
return data.data || data;
},
/** Re-stamp one or both signature images on a contract whose
* original sign happened before the canvas worked correctly.
* Either dataUrl may be null/omitted the corresponding image
* is then left untouched. Always re-renders + persists the PDF. */
async restampSignatures(
id: number,
payload: { customerSignatureDataUrl?: string | null; adminSignatureDataUrl?: string | null },
): Promise<{ signedPdfPath: string; stamped: { customer: boolean; admin: boolean } }> {
const { data } = await api.post(`/admin/contracts/${id}/restamp-signatures`, payload);
return data.data || data;
},
async countersign(
id: number,
payload: { name: string; signatureDataUrl?: string | null },
): Promise<{ status: ContractStatus; signedAt: string }> {
const { data } = await api.post(`/admin/contracts/${id}/countersign`, payload);
return data.data || data;
},
async uploadSignedPdf(id: number, file: File): Promise<{ status: 'fully_signed'; signedPdfPath: string }> {
const form = new FormData();
form.append('file', file);
const { data } = await api.post(`/admin/contracts/${id}/upload-signed-pdf`, form, {
headers: { 'Content-Type': 'multipart/form-data' },
});
return data.data || data;
},
async pdfUrl(id: number): Promise<string> {
const res = await api.get(`/admin/contracts/${id}/pdf`, { responseType: 'blob' });
return URL.createObjectURL(res.data);
},
async signedPdfUrl(id: number): Promise<string> {
const res = await api.get(`/admin/contracts/${id}/signed-pdf`, { responseType: 'blob' });
return URL.createObjectURL(res.data);
},
async previewPdfUrl(id: number): Promise<string> {
const res = await api.get(`/admin/contracts/${id}/preview`, { responseType: 'blob' });
return URL.createObjectURL(res.data);
},
/** Audit trail chronological activity_logs entries for this
* contract. Renders the timeline on the detail page so the admin
* has a single-pane view of every event (sent, signed, counter-
* signed, resent emails, conversions). */
async auditTrail(id: number): Promise<{ entries: AuditEntry[] }> {
const { data } = await api.get(`/admin/contracts/${id}/audit-trail`);
return data.data || data;
},
/** Integrity check re-hashes the unsigned + signed PDF on disk
* and compares each to the stored SHA-256 column from migration
* 131. Used by the IntegrityCheckCard on ContractDetailPage; the
* admin clicks once to confirm no backup-corruption / manual edit
* has altered the document since it was issued. */
async verifyIntegrity(id: number): Promise<ContractIntegrityResult> {
const { data } = await api.get(`/admin/contracts/${id}/verify-integrity`);
return data.data || data;
},
// ----- Block library -------------------------------------------------
async listBlocks(params: { section?: ContractBlockSection; includeInactive?: boolean } = {}): Promise<{ blocks: ContractBlock[] }> {
const { data } = await api.get('/admin/contracts/blocks', { params });
return data.data || data;
},
async createBlock(payload: ContractBlockCreatePayload): Promise<{ block: ContractBlock }> {
const { data } = await api.post('/admin/contracts/blocks', payload);
return data.data || data;
},
async updateBlock(id: number, payload: ContractBlockUpdatePayload): Promise<{ block: ContractBlock }> {
const { data } = await api.put(`/admin/contracts/blocks/${id}`, payload);
return data.data || data;
},
async deleteBlock(id: number): Promise<{ ok: true }> {
const { data } = await api.delete(`/admin/contracts/blocks/${id}`);
return data.data || data;
},
};
// ===================================================================
// Public client — used by ContractResponsePage (no auth, token-based).
// ===================================================================
export interface PublicContractView {
contractNumber: string;
status: ContractStatus;
language: string;
issueDate: string;
validUntil: string | null;
title: string | null;
introText: string | null;
outroText: string | null;
sentAt: string | null;
signedByCustomerAt: string | null;
signedByAdminAt: string | null;
signedCustomerName: string | null;
signedAdminName: string | null;
/** Customer IP at signing surfaced to the customer so they can
* verify what we recorded about THEM. Admin's counter-sign IP is
* intentionally NOT in this shape; it's the operator's identifier
* and not part of the customer's audit surface. */
signedCustomerIp?: string | null;
hasSignedPdf: boolean;
/** SHA-256 hashes of the on-disk PDFs shown in the audit
* confirmation so the customer can re-hash their copy. */
pdfSha256?: string | null;
signedPdfSha256?: string | null;
canSign: boolean;
sections: Array<{
section: ContractBlockSection;
blocks: Array<{
blockId: number;
section: ContractBlockSection;
position: number;
name: string;
body: string;
}>;
}>;
recipient: {
displayName: string;
companyName: string | null;
email: string;
} | null;
issuer: {
companyName: string | null;
addressLine1: string | null;
postalCode: string | null;
city: string | null;
email: string | null;
website: string | null;
} | null;
/** Admin-set behaviour flags surfaced for the public sign page.
* Server re-enforces both these only drive the UI. */
allowPdfUpload?: boolean;
requireDrawnSignature?: boolean;
}
export const publicContractsService = {
async get(token: string): Promise<{ contract: PublicContractView }> {
const { data } = await api.get(`/public/contracts/${token}`);
return data.data || data;
},
async sign(token: string, payload: { name: string; signatureDataUrl?: string | null; accepted: true }): Promise<{ status: ContractStatus; signedAt: string }> {
const { data } = await api.post(`/public/contracts/${token}/sign`, payload);
return data.data || data;
},
async uploadSignedPdf(token: string, file: File): Promise<{ status: 'fully_signed'; signedPdfPath: string }> {
const form = new FormData();
form.append('file', file);
const { data } = await api.post(`/public/contracts/${token}/upload-signed-pdf`, form, {
headers: { 'Content-Type': 'multipart/form-data' },
});
return data.data || data;
},
};
+127
View File
@@ -244,4 +244,131 @@ export const customerService = {
);
return response.data;
},
// ---- CRM (customer-side, read-only) ----
async listQuotes(): Promise<CustomerQuote[]> {
const response = await api.get<{ quotes: CustomerQuote[] }>('/customer/quotes');
return response.data.quotes;
},
async listInvoices(): Promise<CustomerInvoice[]> {
const response = await api.get<{ invoices: CustomerInvoice[] }>('/customer/invoices');
return response.data.invoices;
},
/** Returns a blob URL ready for window.open(). */
async invoicePdfUrl(id: number): Promise<string> {
const res = await api.get(`/customer/invoices/${id}/pdf`, { responseType: 'blob' });
return URL.createObjectURL(res.data);
},
/** Returns a blob URL for the quote PDF (customer-side). */
async quotePdfUrl(id: number): Promise<string> {
const res = await api.get(`/customer/quotes/${id}/pdf`, { responseType: 'blob' });
return URL.createObjectURL(res.data);
},
// ---- Contracts (customer-side) ----
async listContracts(): Promise<CustomerContract[]> {
const response = await api.get<{ contracts: CustomerContract[] }>('/customer/contracts');
return response.data.contracts;
},
/** Streams the signed PDF when available, otherwise the system-
* rendered PDF. The backend handles the fallback so the frontend
* just opens whatever it gets back. */
async contractPdfUrl(id: number): Promise<string> {
const res = await api.get(`/customer/contracts/${id}/pdf`, { responseType: 'blob' });
return URL.createObjectURL(res.data);
},
};
export interface CustomerQuote {
id: number;
quoteNumber: string;
status: 'draft' | 'sent' | 'accepted' | 'declined' | 'expired' | 'converted';
currency: string;
issueDate: string;
validUntil: string | null;
eventName: string | null;
eventDate: string | null;
netAmountMinor: number;
vatRate: number | null;
vatAmountMinor: number;
shippingAmountMinor: number;
totalAmountMinor: number;
introText: string | null;
outroText: string | null;
sentAt: string | null;
respondedAt: string | null;
responseLockedAt: string | null;
acceptedAt: string | null;
declinedAt: string | null;
/** Token to open the public response page from the customer
* dashboard. null when expired/used. */
responseToken: string | null;
}
export interface CustomerInvoice {
id: number;
/** Document discriminator. 'invoice' is the default; 'storno' rows
* are Stornorechnungen (cancellation invoices) and render with a
* distinct badge + lineage banner. */
kind: 'invoice' | 'storno';
invoiceNumber: string;
/** `cancelled` only appears on the customer side for invoices that
* were formally reversed via Stornorechnung (cancellation_storno_id
* IS NOT NULL). Soft-cancelled drafts stay hidden server-side. */
status: 'sent' | 'paid' | 'overdue' | 'cancelled';
currency: string;
issueDate: string;
dueDate: string;
installmentIndex: number;
installmentTotal: number;
installmentLabel: string | null;
netAmountMinor: number;
vatRate: number | null;
vatAmountMinor: number;
shippingAmountMinor: number;
totalAmountMinor: number;
paidAmountMinor: number;
paidAt: string | null;
lateFeeAmountMinor: number;
reminderLevel: number;
sentAt: string | null;
/** On a Storno row (kind='storno') → id of the invoice it reverses. */
cancelsInvoiceId: number | null;
/** Human invoice_number of the row referenced by `cancelsInvoiceId`,
* joined server-side so the customer view can show the actual
* invoice number instead of the bare row id. */
cancelsInvoiceNumber: string | null;
/** On a cancelled invoice → id of the Storno that cancelled it. */
cancellationStornoId: number | null;
/** Human invoice_number of the Storno referenced by
* `cancellationStornoId`. */
cancellationStornoNumber: string | null;
/** Inline event snapshot (migration 123) rendered next to the
* invoice number on the customer portal bills list. */
eventName: string | null;
eventDate: string | null;
}
export interface CustomerContract {
id: number;
contractNumber: string;
status: 'sent' | 'signed_by_customer' | 'signed_by_admin' | 'fully_signed' | 'cancelled';
language: string;
issueDate: string;
validUntil: string | null;
title: string | null;
sentAt: string | null;
signedByCustomerAt: string | null;
signedByAdminAt: string | null;
signedCustomerName: string | null;
signedAdminName: string | null;
hasPdf: boolean;
hasSignedPdf: boolean;
/** Live signing-link token for `sent` contracts so the dashboard can
* deep-link the public sign page when the customer lost the email. */
responseToken: string | null;
}
@@ -15,6 +15,11 @@ export interface CustomerAccountSummary {
salutation: string | null;
companyName: string | null;
isActive: boolean;
/** Passive = admin-only customer with no portal access (password_hash IS NULL).
* The backend never returns the actual hash; this boolean is computed
* server-side in transformCustomer. Drives the "Passive — admin only"
* badge + the "Send portal invitation" button on the detail page. */
isPassive?: boolean;
lastLogin: string | null;
createdAt: string;
eventCount?: number;
@@ -22,6 +27,13 @@ export interface CustomerAccountSummary {
featureCalendar?: boolean;
featureQuotes?: boolean;
featureBills?: boolean;
/** Per-customer hour logging (migration 129). When on, the customer
* detail page renders the "Hours" section card. */
featureHoursLogging?: boolean;
/** Default hourly rate in minor units (e.g. CHF 150.00 = 15000).
* null when admin hasn't set one each entry then requires a
* per-block override. */
hourlyRateMinor?: number | null;
}
export interface CustomerAccountDetail extends CustomerAccountSummary {
@@ -34,7 +46,20 @@ export interface CustomerAccountDetail extends CustomerAccountSummary {
city: string | null;
state: string | null;
countryCode: string | null;
/** Free-text country name (migration 107). PDF renderer uses this
* verbatim when set; otherwise falls back to the locale-aware
* lookup on countryCode. Useful when countryCode is the postal /
* vehicle abbreviation ("FL") rather than the ISO code ("LI"). */
countryName: string | null;
preferredLanguage: string;
/**
* CRM billing cadence override (migration 102).
* - 'per_event' (default): respect each quote's installment plan
* - 'monthly' / 'quarterly': snap every scheduled invoice to
* `billingCycleDay` of the next period.
*/
billingCadence?: 'per_event' | 'monthly' | 'quarterly';
billingCycleDay?: number;
notes: string | null;
events: Array<{
id: number;
@@ -62,6 +87,10 @@ export interface CustomerInvitePrefill {
city?: string;
state?: string;
country_code?: string;
country_name?: string;
/** ISO 639 / BCP-47 locale code. Defaults at insert time to the
* business profile's default_locale when not supplied. */
preferred_language?: string;
}
export interface CustomerInvitationSummary {
@@ -115,6 +144,7 @@ export const customerAdminService = {
city: 'city',
state: 'state',
countryCode: 'country_code',
countryName: 'country_name',
preferredLanguage: 'preferred_language',
notes: 'notes',
isActive: 'is_active',
@@ -122,6 +152,12 @@ export const customerAdminService = {
featureCalendar: 'feature_calendar',
featureQuotes: 'feature_quotes',
featureBills: 'feature_bills',
featureHoursLogging: 'feature_hours_logging',
// Hour-logging default rate (migration 129).
hourlyRateMinor: 'hourly_rate_minor',
// CRM billing cadence (migration 102 + 128).
billingCadence: 'billing_cadence',
billingCycleDay: 'billing_cycle_day',
};
for (const [k, v] of Object.entries(payload)) {
if (k in map) snake[map[k]] = v;
@@ -205,4 +241,188 @@ export const customerAdminService = {
async cancelInvitation(id: number): Promise<void> {
await api.delete(`/admin/customers/invitations/${id}`);
},
/**
* Create a "passive" customer directly admin-only record with no
* portal access, no invitation, no email. The customer is created
* with `password_hash = NULL`; the auth middleware rejects login
* for those, so the customer physically can't access the portal
* until the admin promotes them via `sendInvite()`.
*
* Used by the quote/invoice editor's "+ Create new customer" inline
* form: lets the admin spin up an identity in seconds for one-off
* projects (where issuing portal credentials would be overkill).
*/
async createDirect(
email: string,
prefill?: CustomerInvitePrefill,
): Promise<CustomerAccountDetail> {
const response = await api.post<{ data: { customer: CustomerAccountDetail } } | { customer: CustomerAccountDetail }>(
'/admin/customers',
{ email, prefill },
);
return ((response.data as any).data ?? response.data).customer;
},
/**
* Promote a passive customer to active by firing the standard
* portal-invitation email. The customer clicks the link, lands on
* the accept page (pre-populated with their existing profile),
* sets a password, and is now active. The customer's id is
* preserved across promotion all their existing invoices,
* quotes, and gallery assignments survive.
*
* Rejects with 409 CUSTOMER_ALREADY_ACTIVE if the customer
* already has a password set.
*/
async sendInvite(id: number): Promise<{ id: number; email: string; expiresAt: string }> {
const response = await api.post<{ data: { invitation: { id: number; email: string; expiresAt: string } } }>(
`/admin/customers/${id}/send-invite`,
);
return (response.data as any).data?.invitation ?? (response.data as any).invitation;
},
// -------------------------------------------------------------------
// Hour entries (migration 129).
// -------------------------------------------------------------------
async listHourEntries(customerId: number, status?: HourEntryStatus): Promise<HourEntry[]> {
const response = await api.get<{ data: { entries: HourEntry[] } }>(
`/admin/customers/${customerId}/hour-entries`,
{ params: status ? { status } : undefined },
);
return ((response.data as any).data?.entries ?? (response.data as any).entries) || [];
},
async createHourEntry(
customerId: number,
payload: HourEntryCreatePayload,
): Promise<{ id: number; status: HourEntryStatus; invoiceId?: number }> {
const response = await api.post(
`/admin/customers/${customerId}/hour-entries`,
payload,
);
return (response.data as any).data ?? response.data;
},
async updateHourEntry(
customerId: number,
entryId: number,
payload: HourEntryUpdatePayload,
): Promise<{ id: number }> {
const response = await api.put(
`/admin/customers/${customerId}/hour-entries/${entryId}`,
payload,
);
return (response.data as any).data ?? response.data;
},
async deleteHourEntry(customerId: number, entryId: number): Promise<{ deleted: true }> {
const response = await api.delete(
`/admin/customers/${customerId}/hour-entries/${entryId}`,
);
return (response.data as any).data ?? response.data;
},
/** Per-event flow only mints a standalone invoice from all
* unbilled entries and stamps them billed. Monthly-mode customers
* auto-bill on save and get a 409 here. */
async billUnbilledHourEntries(customerId: number): Promise<{ invoiceId: number; entriesBilled: number }> {
const response = await api.post(
`/admin/customers/${customerId}/hour-entries/bill`,
);
return (response.data as any).data ?? response.data;
},
/** Admin override issue the customer's running monthly draft now,
* bypassing the cadence-day wait. 409 when no draft exists or the
* draft is empty. Returns the issued invoice id + number. */
async triggerMonthlyBill(customerId: number): Promise<{ invoiceId: number; invoiceNumber: string }> {
const response = await api.post(
`/admin/customers/${customerId}/trigger-monthly-bill`,
);
return (response.data as any).data ?? response.data;
},
/** Preview the customer's open monthly draft (line items + totals).
* Returns null when nothing has been queued for the current period. */
async getMonthlyDraft(customerId: number): Promise<{ draft: MonthlyDraftPreview | null }> {
const response = await api.get(
`/admin/customers/${customerId}/monthly-draft`,
);
return (response.data as any).data ?? response.data;
},
};
/** Open monthly bill accumulator preview (migration 128). One row in
* the invoices table with is_monthly_draft=true that gathers every
* invoice line created for this customer during the current period;
* ships on the cadence day or via triggerMonthlyBill. */
export interface MonthlyDraftPreview {
id: number;
invoiceNumber: string;
currency: string;
periodStart: string;
periodEnd: string;
netAmountMinor: number;
vatRate: number | null;
vatAmountMinor: number;
totalAmountMinor: number;
lineItems: MonthlyDraftLineItem[];
}
export interface MonthlyDraftLineItem {
id: number;
position: number;
quantity: number;
description: string;
unitPriceMinor: number;
discountPercent: number;
lineTotalMinor: number;
parentPosition: number | null;
detailsText: string;
}
// -------------------------------------------------------------------
// Hour-entry types (migration 129)
// -------------------------------------------------------------------
export type HourEntryStatus = 'unbilled' | 'billed' | 'cancelled';
export interface HourEntry {
id: number;
customerAccountId: number;
entryDate: string;
startTime: string;
endTime: string;
durationMinutes: number;
hourlyRateMinorOverride: number | null;
description: string | null;
status: HourEntryStatus;
invoiceId: number | null;
invoiceLineItemId: number | null;
invoiceNumber: string | null;
invoiceStatus: string | null;
invoiceIsMonthlyDraft: boolean;
invoiceScheduledSendAt: string | null;
billedAt: string | null;
recordedByAdminId: number | null;
createdAt: string;
updatedAt: string;
}
export interface HourEntryCreatePayload {
entryDate: string; // YYYY-MM-DD
startTime: string; // HH:MM
endTime: string; // HH:MM
hourlyRateMinorOverride?: number | null;
description?: string | null;
}
export interface HourEntryUpdatePayload {
entryDate?: string;
startTime?: string;
endTime?: string;
hourlyRateMinorOverride?: number | null;
description?: string | null;
}
+38
View File
@@ -0,0 +1,38 @@
/**
* dealsService typed wrappers for the `/api/admin/deals/:uuid/...`
* routes (migration 140's deal_uuid grouping). Read surface lives in
* `DocumentLineageCard` via inline `useQuery`; mutations land here so
* call sites stay tidy.
*/
import { api } from '../config/api';
import type { PaymentTermInstallment } from './quotes.service';
export interface UpdateInstallmentPlanResult {
invoiceIds: number[];
kept: number[];
created: number[];
deleted: number[];
}
export const dealsService = {
/**
* Atomically reshape an installment plan after siblings have spawned.
* See backend invoiceService.updateInstallmentPlan for the full
* guard/reuse/grow/trim semantics. 409 responses carry one of:
* - INVOICE_LOCKED at least one sibling is past scheduled
* - PLAN_HAS_STORNO a Storno already exists on the deal
* 400 responses carry NOT_INSTALLMENT_PLAN or PERCENT_SUM_INVALID.
*/
async updateInstallmentPlan(
dealUuid: string,
installments: PaymentTermInstallment[],
): Promise<UpdateInstallmentPlanResult> {
const res = await api.put(`/admin/deals/${dealUuid}/installment-plan`, {
installments,
});
return (res.data?.data || res.data) as UpdateInstallmentPlanResult;
},
};
export default dealsService;
+46
View File
@@ -0,0 +1,46 @@
/**
* Admin Dev tools API client. Gated server-side by the
* `crmDevelopment` feature flag; the frontend additionally hides
* the page when the flag is off, but the API check is what
* actually enforces the gate.
*/
import { api } from '../config/api';
export type CrmEmailTemplateKey =
| 'quote_sent'
| 'quote_accepted_customer'
| 'quote_accepted_admin'
| 'quote_declined_admin'
| 'invoice_sent'
| 'invoice_reminder_first'
| 'invoice_reminder_second'
| 'invoice_payment_check_admin'
// Contracts (migration 130). Backend exposes all three flows via the
// dev tester (admin → customer send, customer-signed ping, dual-party
// fully-signed). Without these in the union, useQuery returns rows
// whose `key` doesn't resolve to a label and they render as raw
// template_key strings.
| 'contract_sent'
| 'contract_signed_admin_notification'
| 'contract_fully_signed';
export interface DevEmailTemplateStatus {
key: CrmEmailTemplateKey;
present: boolean;
}
export const devToolsService = {
async listEmailTemplates(): Promise<DevEmailTemplateStatus[]> {
const { data } = await api.get('/admin/dev/email-templates');
return (data.data || data).templates;
},
async sendTestEmail(templateKey: CrmEmailTemplateKey): Promise<{
sent: true;
to: string;
template: CrmEmailTemplateKey;
}> {
const { data } = await api.post('/admin/dev/send-test-email', { templateKey });
return data.data || data;
},
};
+16
View File
@@ -91,6 +91,22 @@ export const emailService = {
await api.put(`/admin/email/templates/${key}`, data);
},
/** Create a new email template. Used by ReminderTemplatesPage to
* spawn a per-event-type reminder (e.g. `event_reminder_wedding`)
* initialised with the default catch-all's content. Returns 409 if
* the key already exists. */
async createTemplate(payload: {
template_key: string;
translations: Record<string, EmailTemplateTranslation>;
category?: string;
subcategory?: string;
feature_flag?: string;
variables?: string[];
}): Promise<{ template_key: string; id: number }> {
const response = await api.post<{ template_key: string; id: number }>('/admin/email/templates', payload);
return response.data;
},
// Preview email template
async previewTemplate(key: string, previewData: Record<string, string>, language: string = 'en'): Promise<EmailPreview> {
const response = await api.post<EmailPreview>(
+23 -1
View File
@@ -19,7 +19,29 @@ export type FeatureKey =
// Customer-side portal surface (#354). Gates /customer/* routes
// (login, dashboard, profile, accept-invite, reset-password) and
// the Accounts sub-page under Clients in the admin UI.
| 'customerPortal';
| 'customerPortal'
// CRM developer tools — hidden by default. When enabled, surfaces
// a "Development" sub-tab under Clients with internal-use buttons
// (test the payment-check email flow, fire dev-only side effects,
// etc.). Strictly opt-in; toggled from Features.
| 'crmDevelopment'
// Tax / Steuer report — period-scoped revenue export under
// Clients. Independent of `bills` so admins who already use the
// billing surface don't get a new menu entry automatically; they
// opt in when they're ready to generate tax reports.
| 'taxReport'
// Hours logging (migration 129). Master switch for the per-customer
// Hours card on the customer detail page. Independent of `bills`
// so admin can log hours without enabling the broader billing
// surface yet.
| 'hoursLogging'
// Contracts (migration 130). Standalone legal-document type alongside
// quotes / bills, composed from a library of reusable blocks and
// signed in-browser (canvas + checkbox) or via wet-signed PDF
// upload. Independent of quotes / bills — contracts can be sent on
// their own. Seeded block bodies are examples only; admins must
// have their lawyer review before sending. See docs/crm-disclaimers.md.
| 'contracts';
export type FeatureFlags = Record<FeatureKey, boolean>;
@@ -0,0 +1,55 @@
/**
* Public payment-check API client no auth header. The token in
* the URL is the only credential.
*/
import { api } from '../config/api';
export type PaymentCheckAction = 'paid_full' | 'paid_with_skonto' | 'partial' | 'unpaid';
export interface PaymentCheckView {
invoiceNumber: string;
customer: { label: string; email?: string };
issueDate: string;
dueDate: string;
totalMinor: number;
paidMinor: number;
lateFeeMinor: number;
outstandingMinor: number;
currency: string;
status: string;
reminderLevel: number;
expiresAt: string;
/** Migration 126 Skonto pre-resolution from the invoice's payment
* terms snapshot. When `hasSkonto` is true the public page renders
* the "Paid with Skonto" action card; otherwise hidden. */
hasSkonto: boolean;
skontoPercent: number | null;
skontoDiscountedTotalMinor: number | null;
}
export interface PaymentCheckIssuer {
companyName: string;
email?: string;
website?: string;
logoUrl: string | null;
}
export interface PaymentCheckResponse {
invoice: PaymentCheckView;
issuer: PaymentCheckIssuer | null;
}
export const paymentCheckService = {
async get(token: string): Promise<PaymentCheckResponse> {
const { data } = await api.get(`/public/payment-check/${token}`);
const body = data.data || data;
return { invoice: body.invoice, issuer: body.issuer || null };
},
async record(token: string, payload: {
action: PaymentCheckAction;
amountMinor?: number;
}): Promise<{ applied: PaymentCheckAction; reminderLevel?: number; reminderSkipped?: string }> {
const { data } = await api.post(`/public/payment-check/${token}`, payload);
return data.data || data;
},
};
@@ -14,7 +14,12 @@ export interface PublicSettings {
branding_logo_url: string;
branding_logo_size?: string;
branding_logo_max_height?: number;
branding_logo_position?: 'left' | 'center' | 'right';
/**
* Logo placement. 'left' | 'center' | 'right' = inside the gallery
* header. 'sidepanel' = moved into the admin sidebar's brand row
* (admin chrome only gallery falls back to 'left').
*/
branding_logo_position?: 'left' | 'center' | 'right' | 'sidepanel';
branding_logo_display_mode?: 'logo_only' | 'text_only' | 'logo_and_text';
branding_logo_display_header?: boolean;
branding_logo_display_hero?: boolean;
@@ -46,6 +51,15 @@ export interface PublicSettings {
default_language: string;
enable_analytics: boolean;
general_date_format: string | { format: string; locale: string };
/** '12h' or '24h' controls how times are rendered in admin +
* customer views. Storage is always 24h; only display toggles. */
general_time_format?: '12h' | '24h';
/** Dashboard CRM-overview tile visibility. All default true; only
* explicit false hides the matching tile. */
crm_overview_show_revenue?: boolean;
crm_overview_show_outstanding?: boolean;
crm_overview_show_quotes?: boolean;
crm_overview_show_invoices?: boolean;
enable_recaptcha: boolean;
recaptcha_site_key: string | null;
maintenance_mode: boolean;
+396
View File
@@ -0,0 +1,396 @@
/**
* Admin Quotes API client. Hits /api/admin/quotes/* (admin auth) and
* /api/public/quotes/:token for the customer-facing accept/decline page.
*/
import { api } from '../config/api';
export type QuoteStatus = 'draft' | 'sent' | 'accepted' | 'declined' | 'expired' | 'converted';
export type QuoteSort = 'newest' | 'oldest' | 'customer_asc' | 'value_asc' | 'value_desc';
export interface QuoteLineItem {
id?: number;
position: number;
quantity: number;
description: string;
unitPriceMinor: number;
discountPercent: number;
lineTotalMinor?: number;
/**
* Hierarchy (migration 119). Sub-items reference their parent by
* position within the same payload. NULL = top-level item, summed
* into the document net. NON-NULL = sub-item, display-only
* itemisation under the parent. Max one level deep.
*/
parentPosition?: number | null;
/** Persisted DB id of the parent, populated by the server on read. */
parentLineItemId?: number | null;
/**
* Optional free-form notes rendered below the description on the
* PDF and customer view. Smaller, italic. Max 2000 chars.
*/
detailsText?: string | null;
}
export interface QuoteSummary {
id: number;
quoteNumber: string;
/** Cross-document lineage UUID (migration 140). Used by the
* DocumentLineageCard on the detail page to fetch every other
* doc contract, invoices, Storni that shares this deal. */
dealUuid: string | null;
customerAccountId: number;
customer: {
email: string | null;
displayName: string | null;
firstName: string | null;
lastName: string | null;
companyName: string | null;
/** Computed server-side from password_hash. true = admin-only
* customer (no portal access). Drives the Passive badge in the
* editor pill + the quotes list. */
isPassive?: boolean;
};
status: QuoteStatus;
language: string;
currency: string;
issueDate: string;
validUntil: string | null;
eventName: string | null;
eventDate: string | null;
totalAmountMinor: number;
sentAt: string | null;
acceptedAt: string | null;
declinedAt: string | null;
convertedEventId: number | null;
/** Migration 130 set by contractService.createFromQuote so the
* QuoteDetailPage can render a "Linked contract" badge alongside
* the existing resulting-invoices list. Null when no contract was
* drafted from the quote. */
convertedContractId?: number | null;
/** Human contract_number of the converted contract (joined-in on
* read). Surfaced so the QuoteDetailPage's Linked-documents card
* shows "Linked contract LBM-C-2026-0010" instead of "#10". */
convertedContractNumber?: string | null;
createdAt: string;
}
export interface QuoteDetail extends QuoteSummary {
eventTimeStart: string | null;
eventTimeEnd: string | null;
expectedDurationHours: number | null;
paymentTermTemplateId: number | null;
/** Migration 124 split payment-term picker. Two new FKs preferred
* by the editor; legacy `paymentTermTemplateId` stays for sent
* quotes authored before the split. */
paymentNetDaysTemplateId: number | null;
paymentTimingTemplateId: number | null;
netAmountMinor: number;
vatRate: number | null;
vatAmountMinor: number;
shippingAmountMinor: number;
introText: string | null;
outroText: string | null;
internalNotes: string | null;
ccPdfEmail: string | null;
respondedAt: string | null;
responseLockedAt: string | null;
pdfPath: string | null;
businessBankAccountId: number | null;
}
export interface QuoteWithLineItems {
quote: QuoteDetail;
lineItems: QuoteLineItem[];
}
export interface PaymentTermInstallment {
label: string;
percent: number;
trigger: 'quote_accepted' | 'before_event' | 'after_event' | 'after_delivery' | 'fixed_date';
offset_days: number;
}
export interface PaymentTermTemplate {
id: number;
name: string;
description: string;
netDays: number;
skontoPercent: number | null;
skontoWithinDays: number | null;
installments: PaymentTermInstallment[];
isSystem: boolean;
isActive: boolean;
displayOrder: number;
}
// Migration 124 — split payment-term picker. Two new template tables
// replace the conflated PaymentTermTemplate for new quotes/invoices.
// The old type stays for back-compat with sent documents whose
// snapshot still references the legacy table.
export interface PaymentNetDaysTemplate {
id: number;
name: string;
description: string | null;
netDays: number;
skontoPercent: number | null;
skontoWithinDays: number | null;
isSystem: boolean;
isActive: boolean;
displayOrder: number;
}
export interface PaymentTimingTemplate {
id: number;
name: string;
description: string | null;
installments: PaymentTermInstallment[];
isSystem: boolean;
isActive: boolean;
displayOrder: number;
}
export interface LineItemPreset {
id: number;
name: string;
description: string;
unitPriceMinor: number;
currency: string;
quantityDefault: number;
displayOrder: number;
isActive: boolean;
}
export interface QuoteCreatePayload {
customerAccountId: number;
language?: string;
currency?: string;
issueDate?: string;
validUntil?: string;
eventName?: string;
eventDate?: string;
eventTimeStart?: string;
eventTimeEnd?: string;
expectedDurationHours?: number;
paymentTermTemplateId?: number;
/** Migration 124 split payment-term picker. Both must be set
* together for the new path to engage on the backend. */
paymentNetDaysTemplateId?: number;
paymentTimingTemplateId?: number;
/** Ad-hoc installments (commit #6). Overrides the picked timing
* template's installments on the snapshot. Empty/missing = use
* the template's value as-is. */
installments?: PaymentTermInstallment[];
vatRate?: number;
shippingAmountMinor?: number;
introText?: string;
outroText?: string;
internalNotes?: string;
ccPdfEmail?: string;
businessBankAccountId?: number;
lineItems: QuoteLineItem[];
}
export interface QuoteListResponse {
quotes: QuoteSummary[];
pagination: { total: number; page: number; pageSize: number; totalPages: number };
}
export const quotesService = {
async list(params: {
status?: QuoteStatus[];
customerAccountId?: number;
q?: string;
from?: string;
to?: string;
sort?: QuoteSort;
page?: number;
pageSize?: number;
} = {}): Promise<QuoteListResponse> {
const { data } = await api.get('/admin/quotes', {
params: {
...params,
status: params.status?.join(','),
},
});
return data.data || data;
},
async get(id: number): Promise<QuoteWithLineItems> {
const { data } = await api.get(`/admin/quotes/${id}`);
return data.data || data;
},
async create(payload: QuoteCreatePayload): Promise<QuoteWithLineItems> {
const { data } = await api.post('/admin/quotes', payload);
return data.data || data;
},
async update(id: number, payload: Partial<QuoteCreatePayload>): Promise<QuoteWithLineItems> {
const { data } = await api.put(`/admin/quotes/${id}`, payload);
return data.data || data;
},
async send(id: number): Promise<{ sent: true; token: string }> {
const { data } = await api.post(`/admin/quotes/${id}/send`);
return data.data || data;
},
async duplicate(id: number): Promise<{ id: number }> {
const { data } = await api.post(`/admin/quotes/${id}/duplicate`);
return data.data || data;
},
/** Admin accept-on-behalf flips the quote to `accepted` without
* going through the customer's public response page. Used for
* phone-call workflows where the customer verbally agrees. */
async acceptOnBehalf(id: number): Promise<{ status: string; lockedAt: string }> {
const { data } = await api.post(`/admin/quotes/${id}/accept`);
return data.data || data;
},
async convert(id: number): Promise<{ eventId: number; alreadyConverted: boolean }> {
const { data } = await api.post(`/admin/quotes/${id}/convert`);
return data.data || data;
},
/** Convert the quote directly into invoice(s) without creating an event. */
async convertToInvoice(id: number): Promise<{ installmentsCreated: number }> {
const { data } = await api.post(`/admin/quotes/${id}/convert-to-invoice`);
return data.data || data;
},
/** Convert the quote into a fresh draft contract (#contracts feature).
* Leaves the quote in 'accepted' status; the contract becomes the
* active deliverable. After the customer + admin both sign, the
* contract detail page exposes its own convert-to-event /
* convert-to-invoice buttons that re-enter the quote conversion
* path via the contract's source_quote_id. */
async convertToContract(id: number): Promise<{ contractId: number; alreadyConverted: boolean }> {
const { data } = await api.post(`/admin/quotes/${id}/convert-to-contract`);
return data.data || data;
},
/** Returns a blob URL the editor can `window.open()` straight into a tab. */
async pdfUrl(id: number): Promise<string> {
const res = await api.get(`/admin/quotes/${id}/pdf`, { responseType: 'blob' });
return URL.createObjectURL(res.data);
},
async previewPdfUrl(payload: QuoteCreatePayload): Promise<string> {
const res = await api.post('/admin/quotes/preview', payload, { responseType: 'blob' });
return URL.createObjectURL(res.data);
},
async listLineItemPresets(): Promise<{ presets: LineItemPreset[] }> {
const { data } = await api.get('/admin/quotes/presets/line-items');
return data.data || data;
},
async createLineItemPreset(payload: Partial<LineItemPreset> & { name: string }): Promise<{ preset: LineItemPreset }> {
const { data } = await api.post('/admin/quotes/presets/line-items', payload);
return data.data || data;
},
async listPaymentTermTemplates(): Promise<{ templates: PaymentTermTemplate[] }> {
const { data } = await api.get('/admin/quotes/presets/payment-terms');
return data.data || data;
},
async createPaymentTermTemplate(payload: Omit<PaymentTermTemplate, 'id' | 'isSystem'>): Promise<{ template: PaymentTermTemplate }> {
const { data } = await api.post('/admin/quotes/presets/payment-terms', payload);
return data.data || data;
},
async updatePaymentTermTemplate(id: number, payload: Partial<PaymentTermTemplate>): Promise<{ template: PaymentTermTemplate }> {
const { data } = await api.put(`/admin/quotes/presets/payment-terms/${id}`, payload);
return data.data || data;
},
async deletePaymentTermTemplate(id: number): Promise<{ deleted: true }> {
const { data } = await api.delete(`/admin/quotes/presets/payment-terms/${id}`);
return data.data || data;
},
// Split payment-term templates (migration 124).
async listPaymentNetDaysTemplates(): Promise<{ templates: PaymentNetDaysTemplate[] }> {
const { data } = await api.get('/admin/quotes/presets/payment-net-days');
return data.data || data;
},
async listPaymentTimingTemplates(): Promise<{ templates: PaymentTimingTemplate[] }> {
const { data } = await api.get('/admin/quotes/presets/payment-timing');
return data.data || data;
},
};
// -------------------------------------------------------------------
// Public (no-auth) — accept / decline page
// -------------------------------------------------------------------
export interface PublicQuoteView {
quoteNumber: string;
status: QuoteStatus;
language: string;
currency: string;
issueDate: string;
validUntil: string | null;
eventName: string | null;
eventDate: string | null;
eventTimeStart: string | null;
eventTimeEnd: string | null;
introText: string | null;
outroText: string | null;
netAmountMinor: number;
vatRate: number | null;
vatAmountMinor: number;
shippingAmountMinor: number;
totalAmountMinor: number;
respondedAt: string | null;
responseLockedAt: string | null;
canRespond: boolean;
lineItems: Array<{
position: number;
quantity: number;
description: string;
unitPriceMinor: number;
discountPercent: number;
lineTotalMinor: number;
}>;
/** Terms of Service block driven by the global `crm_quotes_tos_*`
* settings. When `required` is true, the public page must show a
* checkbox the customer ticks before Accept can fire. The text +
* url are optional content the admin curates in CRM Settings. */
tos?: {
required: boolean;
text: string;
url: string;
acceptedAt: string | null;
};
recipient: { displayName: string; email: string; companyName: string | null } | null;
issuer: {
companyName: string;
email: string;
website: string;
footerLine: string;
/** Absolute or /uploads/-prefixed URL set by the public route. */
logoUrl?: string | null;
} | null;
}
export const publicQuotesService = {
async get(token: string): Promise<{ quote: PublicQuoteView }> {
const { data } = await api.get(`/public/quotes/${token}`);
return data.data || data;
},
async respond(
token: string,
action: 'accept' | 'decline',
options: { tosAccepted?: boolean } = {},
): Promise<{ status: QuoteStatus; lockedAt: string }> {
const { data } = await api.post(`/public/quotes/${token}/respond`, {
action,
tosAccepted: options.tosAccepted,
});
return data.data || data;
},
};
+23 -1
View File
@@ -14,7 +14,16 @@ export interface BrandingSettings {
favicon_url?: string;
logo_size?: 'small' | 'medium' | 'large' | 'xlarge' | 'custom';
logo_max_height?: number;
logo_position?: 'left' | 'center' | 'right';
/**
* Logo placement.
* - 'left' | 'center' | 'right': position inside the gallery header bar.
* - 'sidepanel': moved out of the gallery header into the admin
* sidebar's brand row (replaces the "PicPeak Admin" text; switches
* to the favicon when the sidebar is collapsed). The gallery
* silently falls back to 'left' for its own rendering since it has
* no sidepanel.
*/
logo_position?: 'left' | 'center' | 'right' | 'sidepanel';
logo_display_header?: boolean;
logo_display_hero?: boolean;
logo_display_mode?: 'logo_only' | 'text_only' | 'logo_and_text';
@@ -166,6 +175,19 @@ export const settingsService = {
return response.data;
},
// Get a subset of settings by key. Use this when a caller knows
// exactly which keys it needs — saves transferring (and JSON-parsing)
// the ~100-row dict. The backend accepts a comma-separated list and
// returns the same shape as getAllSettings(), just narrower.
async getSettings(keys: string[]): Promise<Record<string, any>> {
if (!keys || keys.length === 0) return {};
const response = await api.get<Record<string, any>>(
'/admin/settings',
{ params: { keys: keys.join(',') } },
);
return response.data;
},
// Get settings by type
async getSettingsByType(type: 'branding' | 'theme' | 'general'): Promise<Record<string, any>> {
const response = await api.get<Record<string, any>>(`/admin/settings/${type}`);
+107
View File
@@ -0,0 +1,107 @@
/**
* Admin Tax Report API client. Hits /api/admin/tax-report/*.
*
* Three endpoints, one query-string contract:
* from, to YYYY-MM-DD (inclusive)
* currency ISO 4217 alpha-3 (uppercased server-side)
* locale optional, defaults to the business profile default
*/
import { api } from '../config/api';
export interface TaxReportRow {
id: number;
invoiceNumber: string;
issueDate: string;
currency: string;
status: string;
isCancelled: boolean;
/** Document kind discriminator (migration 114). Drives the "Storno"
* badge on rows where kind='storno'. */
kind: 'invoice' | 'storno';
/** True when this invoice was created via Cancel & reissue
* (replaces_invoice_id is set on a non-cancelled row). Drives the
* "Reissue" badge. */
isReissue: boolean;
/** Replacement invoice number when this cancelled row was reissued. */
replacedByInvoiceNumber: string | null;
/** Aggregated from `invoice_payment_log` true when any payment for
* this invoice was recorded as Skonto-applied (migration 126). */
skontoApplied: boolean;
/** Sum of discount in minor units across all Skonto-applied payments
* on this invoice. 0 when `skontoApplied` is false. */
skontoAmountMinor: number;
vatRate: number;
customerLabel: string;
eventName: string;
/** Minor units (cents/Rappen). */
netMinor: number;
vatMinor: number;
totalMinor: number;
}
export interface TaxReportBucket {
vatRate: number;
netMinor: number;
vatMinor: number;
totalMinor: number;
}
export interface TaxReport {
rows: TaxReportRow[];
totalsByVatRate: TaxReportBucket[];
grandTotalNet: number;
grandTotalVat: number;
grandTotal: number;
cancelledCount: number;
currency: string;
period: { from: string; to: string };
}
export interface TaxReportParams {
from: string;
to: string;
currency: string;
locale?: string;
}
function buildQueryString(params: TaxReportParams): string {
const usp = new URLSearchParams({
from: params.from,
to: params.to,
currency: params.currency,
});
if (params.locale) usp.set('locale', params.locale);
return usp.toString();
}
export const taxReportService = {
async getReport(params: TaxReportParams): Promise<TaxReport> {
const res = await api.get<{ report: TaxReport }>(
`/admin/tax-report?${buildQueryString(params)}`,
);
return res.data.report;
},
/**
* Trigger a browser download for the PDF. Returns the blob URL the
* caller can either assign to `window.location` or open in a new tab.
* The caller is responsible for revoking the URL when done.
*/
async downloadPdfUrl(params: TaxReportParams): Promise<{ url: string; filename: string }> {
const res = await api.get(`/admin/tax-report/pdf?${buildQueryString(params)}`, {
responseType: 'blob',
});
const url = URL.createObjectURL(res.data);
const filename = `tax_report_${params.from}_to_${params.to}_${params.currency}.pdf`;
return { url, filename };
},
async downloadCsvUrl(params: TaxReportParams): Promise<{ url: string; filename: string }> {
const res = await api.get(`/admin/tax-report/csv?${buildQueryString(params)}`, {
responseType: 'blob',
});
const url = URL.createObjectURL(res.data);
const filename = `tax_report_${params.from}_to_${params.to}_${params.currency}.csv`;
return { url, filename };
},
};
+5
View File
@@ -69,6 +69,11 @@ export interface Event {
client_share_token?: string;
// Default photo sort order
default_photo_sort?: string;
// Pre-event reminder override (migration 143)
event_reminder_disabled?: boolean;
event_reminder_offset_days?: number | null;
event_reminder_body_override?: string | null;
event_reminder_sent_at?: string | null;
}
export type GalleryAccessLevel = 'guest' | 'client';
+52
View File
@@ -0,0 +1,52 @@
/**
* calendarPrefs localStorage-backed admin calendar preferences.
*
* Single key today: the last-used view (Month or Week). Stored per
* browser/admin pair via localStorage so the toggle persists across
* page reloads. No server round-trip.
*
* If more admin-tunable preferences land (e.g. default week start,
* legend visibility), extend this module with a JSON object keyed at
* `picpeak.calendar.prefs` instead of more individual keys.
*
* The getter swallows malformed values (e.g. someone hand-edits the
* stored value) and falls back to the documented default never
* throws on read.
*/
const VIEW_KEY = 'picpeak.calendar.view';
export type CalendarView = 'dayGridMonth' | 'timeGridWeek';
const ALLOWED_VIEWS: ReadonlyArray<CalendarView> = ['dayGridMonth', 'timeGridWeek'];
/**
* Return the persisted view or the default ('dayGridMonth' Month).
* Safe to call before localStorage exists (SSR / test envs).
*/
export function getCalendarView(): CalendarView {
if (typeof window === 'undefined' || !window.localStorage) return 'dayGridMonth';
try {
const raw = window.localStorage.getItem(VIEW_KEY);
if (raw && (ALLOWED_VIEWS as readonly string[]).includes(raw)) {
return raw as CalendarView;
}
} catch (_) {
// ignore — fall through to default
}
return 'dayGridMonth';
}
/**
* Persist the active view. Silently no-ops when localStorage is
* unavailable.
*/
export function setCalendarView(view: CalendarView): void {
if (typeof window === 'undefined' || !window.localStorage) return;
if (!(ALLOWED_VIEWS as readonly string[]).includes(view)) return;
try {
window.localStorage.setItem(VIEW_KEY, view);
} catch (_) {
// ignore — quota / disabled storage
}
}
+20
View File
@@ -0,0 +1,20 @@
/**
* formatShortDate canonical DD.MM.YYYY formatter for public + customer
* surfaces that don't have access to admin Settings (general_date_format).
*
* Five page files (PaymentCheckPage, QuoteResponsePage, CustomerBills/
* Quotes/ContractsPage) previously carried identical copies. The
* customer portal + public token routes deliberately use a fixed format
* because they render on links the customer opens without any admin
* locale context.
*
* Admin-side surfaces should use `useLocalizedDate` instead so they
* honor the operator's general_date_format / general_time_format
* settings. See `feedback_respect_general_format_settings.md`.
*/
export function formatShortDate(value: string | null | undefined): string {
if (!value) return '';
const d = new Date(value);
if (Number.isNaN(d.getTime())) return value;
return `${String(d.getDate()).padStart(2, '0')}.${String(d.getMonth() + 1).padStart(2, '0')}.${d.getFullYear()}`;
}
+106
View File
@@ -0,0 +1,106 @@
/**
* Unified money formatting for the picpeak UI.
*
* Replaces the six near-duplicate `function formatMoney` blocks that
* lived in LineItemsTable, CrmOverviewSection, PaymentCheckPage,
* QuoteResponsePage, CustomerBillsPage, and CustomerQuotesPage. Each
* copy had the SAME hardcoded `de-CH` locale fallback, meaning an
* EN-locale operator saw German thousand-separators on every page; and
* each copy varied on whether the `amount` was minor or major units
* leading to occasional 100× factor bugs when call-sites used the
* wrong copy.
*
* Decisions baked in here:
* - Locale defaults to `i18next.language` (i.e. the active UI
* language), not de-CH. Call-sites can override with the `locale`
* option for special cases (e.g. always-DE PDF previews).
* - Two entry points: `formatMoney` for MAJOR units (preferred
* matches what most templates and editor forms already pass), and
* `formatMoneyMinor` for MINOR units (use this when reading raw
* `*_amount_minor` columns).
* - Currency defaults to CHF when the call-site passes an empty
* string, mirroring the previous helpers' behaviour and matching
* the operator's default issuer currency.
*/
import i18next from 'i18next';
/**
* Map an i18next language code to a BCP 47 locale that
* `Intl.NumberFormat` accepts and that yields the conventional
* separator + grouping rules for that language.
*
* We intentionally lean toward CH-flavoured variants for DE because
* the operator's primary market is Switzerland (apostrophe-grouped
* thousands, period decimal). Other locales fall through to their
* canonical defaults.
*/
function languageToLocale(language?: string): string {
if (!language) return 'de-CH';
const base = language.toLowerCase().split('-')[0];
switch (base) {
case 'de': return 'de-CH';
case 'en': return 'en-US';
case 'fr': return 'fr-CH';
case 'nl': return 'nl-NL';
case 'pt': return 'pt-BR';
case 'ru': return 'ru-RU';
default: return language;
}
}
export interface FormatMoneyOptions {
/**
* BCP 47 locale override. When omitted, the active i18next language
* is mapped to a sensible locale via {@link languageToLocale}.
*/
locale?: string;
/**
* Force a specific fraction-digit count. Defaults to the
* currency's `Intl.NumberFormat` default (2 for most currencies,
* 0 for JPY etc.). Useful for whole-CHF totals in summary pills.
*/
fractionDigits?: number;
}
function buildFormatter(currency: string, opts?: FormatMoneyOptions): Intl.NumberFormat {
const locale = opts?.locale || languageToLocale(i18next.language);
const numFormatOpts: Intl.NumberFormatOptions = {
style: 'currency',
currency: (currency || 'CHF').toUpperCase(),
};
if (opts?.fractionDigits !== undefined) {
numFormatOpts.minimumFractionDigits = opts.fractionDigits;
numFormatOpts.maximumFractionDigits = opts.fractionDigits;
}
return new Intl.NumberFormat(locale, numFormatOpts);
}
/**
* Format a MAJOR-unit amount (e.g. 12.5 for 12.50) as a localised
* currency string. Pass `*_amount_minor` columns through
* {@link formatMoneyMinor} instead passing minor units here yields
* a 100× over-display.
*/
export function formatMoney(
amount: number,
currency: string,
opts?: FormatMoneyOptions,
): string {
const safe = Number.isFinite(amount) ? amount : 0;
return buildFormatter(currency, opts).format(safe);
}
/**
* Format a MINOR-unit amount (the integer cents/Rappen stored in
* `*_amount_minor` columns) as a localised currency string. Divides
* by 100 before handing to Intl.NumberFormat.
*/
export function formatMoneyMinor(
minor: number,
currency: string,
opts?: FormatMoneyOptions,
): string {
const safe = Number.isFinite(minor) ? minor : 0;
return buildFormatter(currency, opts).format(safe / 100);
}
+101
View File
@@ -160,6 +160,107 @@ export const toArray = <T>(value: unknown, defaultValue: T[] = []): T[] => {
return defaultValue;
};
/**
* Locale-tolerant decimal parser. Accepts strings using either '.' or
* ',' as the decimal separator and either thousand-separator
* convention (German "1.234,50" or English "1,234.50"). Crucially, this
* is what we use to read CRM money/quantity fields whose <input> is
* typed by humans in either an EN or DE locale `Number('12,50')`
* silently returns NaN, which the call-sites then coerce to 0, eating
* real money.
*
* Heuristic when both separators appear: the LAST one is the decimal
* separator; all earlier instances of either symbol are thousands and
* are stripped. When only one separator appears, it's treated as the
* decimal separator. Leading/trailing whitespace, currency glyphs
* (, $, CHF, etc.), and stray spaces inside the number are tolerated.
*
* Returns `NaN` when the input can't be coerced callers should check
* with `Number.isFinite` before persisting.
*
* Examples:
* parseLocaleDecimal('12,50') // → 12.5 (DE)
* parseLocaleDecimal('12.50') // → 12.5 (EN)
* parseLocaleDecimal('1.234,50') // → 1234.5 (DE with thousands)
* parseLocaleDecimal('1,234.50') // → 1234.5 (EN with thousands)
* parseLocaleDecimal('-7,5') // → -7.5
* parseLocaleDecimal('€ 12,50') // → 12.5
* parseLocaleDecimal('') // → NaN
* parseLocaleDecimal('foo') // → NaN
*/
export const parseLocaleDecimal = (value: unknown): number => {
if (typeof value === 'number') return value;
if (value === null || value === undefined) return NaN;
let s = String(value).trim();
if (!s) return NaN;
// Strip whitespace + common currency symbols; leave digits, signs,
// and the two separator characters.
s = s.replace(/[\s€$£¥]/g, '').replace(/CHF/gi, '');
const lastDot = s.lastIndexOf('.');
const lastComma = s.lastIndexOf(',');
if (lastDot === -1 && lastComma === -1) {
// No separator — pure integer or noise.
const n = Number(s);
return Number.isFinite(n) ? n : NaN;
}
// The later separator wins as the decimal point; earlier separators
// of either type are treated as thousands and stripped.
const decimalAt = Math.max(lastDot, lastComma);
const intPart = s.slice(0, decimalAt).replace(/[.,]/g, '');
const fracPart = s.slice(decimalAt + 1);
if (!fracPart && !intPart) return NaN;
const normalised = `${intPart}.${fracPart}`;
const n = Number(normalised);
return Number.isFinite(n) ? n : NaN;
};
/**
* Parse a duration shortcut into whole minutes.
*
* Accepts the three formats the maintainer types into the hours-log
* form so they don't have to compute end-time mentally for a known
* duration:
* - "1h", "2h", "0.5h", "1,5h" hours, optional decimal
* - "1.5", "1,5", "0.75" bare decimal hours (DE comma OK)
* - "1:30", "0:45" H:MM
*
* Returns the duration in MINUTES (so call-sites adding to a start time
* don't have to round). Returns `null` for empty, unparseable, or
* negative input the caller should ignore null instead of substituting
* a default, so a typo doesn't silently overwrite an end-time the admin
* already set.
*
* Examples:
* parseDuration('1h') 60
* parseDuration('1.5') 90
* parseDuration('1,5') 90
* parseDuration('1:30') 90
* parseDuration('0:45') 45
* parseDuration('') null
* parseDuration('1:99') null
*/
export const parseDuration = (value: unknown): number | null => {
if (typeof value !== 'string') return null;
const s = value.trim().toLowerCase();
if (!s) return null;
// H:MM form.
const colonMatch = s.match(/^(\d+):([0-5]\d)$/);
if (colonMatch) {
const h = Number(colonMatch[1]);
const m = Number(colonMatch[2]);
if (!Number.isFinite(h) || !Number.isFinite(m)) return null;
const total = h * 60 + m;
return total > 0 ? total : null;
}
// Strip a trailing 'h' (with optional whitespace).
const stripped = s.replace(/\s*h$/, '');
const hours = parseLocaleDecimal(stripped);
if (!Number.isFinite(hours) || hours <= 0) return null;
return Math.round(hours * 60);
};
// Re-export with alternative names for backwards compatibility
export const parseBooleanInput = toBoolean;
export const parseNumberInput = toNumber;
+11
View File
@@ -14,6 +14,17 @@ const config: VitestUserConfig = {
manualChunks: {
'react-vendor': ['react', 'react-dom', 'react-router-dom'],
'ui-vendor': ['lucide-react', 'react-toastify'],
// Migration 137 — admin calendar deps (~200 KB). Carved into
// their own chunk so the main bundle isn't penalised on
// every page load; the calendar route lazy-loads this chunk
// on demand via React.lazy.
'fullcalendar': [
'@fullcalendar/react',
'@fullcalendar/core',
'@fullcalendar/daygrid',
'@fullcalendar/timegrid',
'@fullcalendar/interaction',
],
},
},
},