From 2a5fbffaa2e02c4297b51f933242bf57f3c9aec7 Mon Sep 17 00:00:00 2001 From: Max Amundsen Date: Wed, 8 Jul 2026 16:36:17 -0400 Subject: [PATCH] restructure project, add claudemd --- CLAUDE.md | 101 + {appenv => go/appenv}/appenv.go | 0 {appenv => go/appenv}/env_development.go | 0 {appenv => go/appenv}/env_production.go | 0 {appenv => go/appenv}/env_staging.go | 0 {basic => go/basic}/basic.go | 0 {basic => go/basic}/basic_test.go | 0 {bundler => go/bundler}/build.go | 0 {bundler => go/bundler}/compile_solid.go | 0 {bundler => go/bundler}/compile_solid_gen.go | 0 .../bundler}/compile_solid_render_test.go | 0 {bundler => go/bundler}/compile_solid_test.go | 0 {bundler => go/bundler}/css.go | 0 {bundler => go/bundler}/export_shim.go | 0 {bundler => go/bundler}/faicons.go | 0 {bundler => go/bundler}/genroutes.go | 0 {bundler => go/bundler}/genssr.go | 0 {bundler => go/bundler}/hmr_browser_test.go | 0 {bundler => go/bundler}/hmr_client.go | 0 {bundler => go/bundler}/hmr_client_test.go | 0 {bundler => go/bundler}/hmr_e2e_test.go | 0 {bundler => go/bundler}/hmr_error_test.go | 0 {bundler => go/bundler}/hmr_server.go | 0 {bundler => go/bundler}/hmr_vendor.go | 0 {bundler => go/bundler}/hmr_watch.go | 0 {bundler => go/bundler}/hmr_ws.go | 0 .../bundler}/hmr_ws_integration_test.go | 0 {bundler => go/bundler}/hmr_ws_test.go | 0 {bundler => go/bundler}/import_check.go | 0 {bundler => go/bundler}/js.go | 0 {bundler => go/bundler}/js/ssr/dom.js | 0 {bundler => go/bundler}/jsx.go | 0 {bundler => go/bundler}/jsx_bench_test.go | 0 {bundler => go/bundler}/jsx_dev_test.go | 0 {bundler => go/bundler}/jsx_test.go | 0 {bundler => go/bundler}/renderer.go | 0 {bundler => go/bundler}/segment.go | 0 {bundler => go/bundler}/segment_test.go | 0 {bundler => go/bundler}/ssr.go | 0 {bundler => go/bundler}/ssr_test.go | 0 {bundler => go/bundler}/ssrcache.go | 0 {bundler => go/bundler}/tailwind.go | 0 {bundler => go/bundler}/tailwind_test.go | 0 {bundler => go/bundler}/tw_preflight.css | 0 {bundler => go/bundler}/tw_theme.css | 0 {bundler => go/bundler}/vendor_plugins.go | 0 {chrono => go/chrono}/chrono.go | 0 {chrono => go/chrono}/chrono_test.go | 0 {cmd => go/cmd}/bundle/main.go | 0 {cmd => go/cmd}/loc/main.go | 0 {cmd => go/cmd}/migrate/database.go | 0 {cmd => go/cmd}/migrate/engine.go | 0 {cmd => go/cmd}/migrate/main.go | 0 {cmd => go/cmd}/migrate/source.go | 0 {cmd => go/cmd}/passgen/main.go | 0 {cmd => go/cmd}/typecheck/main.go | 0 {config => go/config}/config.go | 0 {csv => go/csv}/csv.go | 0 {dbutil => go/dbutil}/automapper.go | 0 {dbutil => go/dbutil}/automapper_test.go | 0 {dbutil => go/dbutil}/builder.go | 0 {dbutil => go/dbutil}/builder_test.go | 0 {dbutil => go/dbutil}/db_connect.go | 0 {dbutil => go/dbutil}/filters.go | 0 {dbutil => go/dbutil}/registry.go | 0 {dbutil => go/dbutil}/test_models_test.go | 0 {finance => go/finance}/helpers.go | 0 go.mod => go/go.mod | 0 go.sum => go/go.sum | 0 {httputil => go/httputil}/cors.go | 0 {httputil => go/httputil}/cors_test.go | 0 {httputil => go/httputil}/doc.go | 0 {httputil => go/httputil}/respond.go | 0 {httputil => go/httputil}/useragent.go | 0 {l4g => go/l4g}/database.go | 0 {l4g => go/l4g}/debug.go | 0 {l4g => go/l4g}/doc.go | 0 {l4g => go/l4g}/entry.go | 0 {l4g => go/l4g}/file.go | 0 {l4g => go/l4g}/logger.go | 0 {l4g => go/l4g}/terminal.go | 0 {security => go/security}/crypt.go | 0 {security => go/security}/crypt_test.go | 0 {security => go/security}/init.go | 0 {security => go/security}/random.go | 0 {security => go/security}/random_test.go | 0 {snailmail => go/snailmail}/cloudflare.go | 0 {snailmail => go/snailmail}/smtp.go | 0 {snailmail => go/snailmail}/snailmail.go | 0 {validation => go/validation}/validation.go | 0 web/auth/AuthContext.ts | 308 + web/auth/ProtectedRoute.ts | 61 + web/auth/checkBundleVersion.js | 51 + web/auth/useAuthFetch.js | 106 + web/basic.ts | 56 + web/env.ts | 16 + web/finance.ts | 98 + web/hooks/useDocumentTitle.js | 20 + web/kit/Accordion.tsx | 127 + web/kit/Alerts.tsx | 44 + web/kit/AutoTable.tsx | 4213 +++ web/kit/Badges.tsx | 53 + web/kit/Buttons.tsx | 191 + web/kit/Calendar.tsx | 211 + web/kit/Cards.tsx | 98 + web/kit/CellGrid.tsx | 434 + web/kit/Chart.tsx | 91 + web/kit/CrmTabs.tsx | 166 + web/kit/DatePicker.tsx | 542 + web/kit/EnvBadge.tsx | 37 + web/kit/Floating.tsx | 397 + web/kit/Formatters.ts | 120 + web/kit/Forms.tsx | 1898 ++ web/kit/FuzzyMatch.tsx | 321 + web/kit/General.tsx | 119 + web/kit/Icons.tsx | 137 + web/kit/Menu.tsx | 328 + web/kit/Modal.tsx | 580 + web/kit/Popovers.tsx | 115 + web/kit/PrettyTable.tsx | 126 + web/kit/RemoteUpdateFlash.tsx | 38 + web/kit/Sidebar.tsx | 103 + web/kit/Tabs.tsx | 172 + web/kit/Toast.tsx | 216 + web/kit/ToggleSwitch.tsx | 61 + web/kit/Tooltips.tsx | 178 + web/kit/Tutorial.tsx | 685 + web/kit/Validation.ts | 105 + web/runtime/@solidjs/router/LICENSE | 21 + web/runtime/@solidjs/router/README.md | 1038 + .../@solidjs/router/dist/components.d.ts | 31 + .../@solidjs/router/dist/components.jsx | 39 + .../@solidjs/router/dist/data/action.d.ts | 17 + .../@solidjs/router/dist/data/action.js | 163 + .../router/dist/data/createAsync.d.ts | 32 + .../@solidjs/router/dist/data/createAsync.js | 96 + .../@solidjs/router/dist/data/events.d.ts | 9 + .../@solidjs/router/dist/data/events.js | 123 + .../@solidjs/router/dist/data/index.d.ts | 4 + .../@solidjs/router/dist/data/index.js | 4 + .../@solidjs/router/dist/data/query.d.ts | 23 + .../@solidjs/router/dist/data/query.js | 232 + .../@solidjs/router/dist/data/response.d.ts | 4 + .../@solidjs/router/dist/data/response.js | 42 + web/runtime/@solidjs/router/dist/index.d.ts | 7 + web/runtime/@solidjs/router/dist/index.js | 1879 ++ web/runtime/@solidjs/router/dist/index.jsx | 6 + .../@solidjs/router/dist/lifecycle.d.ts | 5 + web/runtime/@solidjs/router/dist/lifecycle.js | 69 + .../router/dist/routers/HashRouter.d.ts | 9 + .../router/dist/routers/HashRouter.js | 41 + .../router/dist/routers/MemoryRouter.d.ts | 24 + .../router/dist/routers/MemoryRouter.js | 57 + .../@solidjs/router/dist/routers/Router.d.ts | 9 + .../@solidjs/router/dist/routers/Router.js | 45 + .../router/dist/routers/StaticRouter.d.ts | 6 + .../router/dist/routers/StaticRouter.js | 15 + .../router/dist/routers/components.d.ts | 27 + .../router/dist/routers/components.jsx | 118 + .../router/dist/routers/createRouter.d.ts | 10 + .../router/dist/routers/createRouter.js | 41 + .../@solidjs/router/dist/routers/index.d.ts | 11 + .../@solidjs/router/dist/routers/index.js | 6 + web/runtime/@solidjs/router/dist/routing.d.ts | 175 + web/runtime/@solidjs/router/dist/routing.js | 560 + web/runtime/@solidjs/router/dist/types.d.ts | 200 + web/runtime/@solidjs/router/dist/types.js | 1 + web/runtime/@solidjs/router/dist/utils.d.ts | 13 + web/runtime/@solidjs/router/dist/utils.js | 185 + web/runtime/@solidjs/router/package.json | 61 + web/runtime/csstype/LICENSE | 19 + web/runtime/csstype/README.md | 291 + web/runtime/csstype/index.d.ts | 22569 ++++++++++++++++ web/runtime/csstype/index.js.flow | 6863 +++++ web/runtime/csstype/package.json | 70 + web/runtime/solid-js/LICENSE | 21 + web/runtime/solid-js/README.md | 223 + web/runtime/solid-js/dist/dev.cjs | 1863 ++ web/runtime/solid-js/dist/dev.js | 1808 ++ web/runtime/solid-js/dist/server.cjs | 817 + web/runtime/solid-js/dist/server.js | 763 + web/runtime/solid-js/dist/solid.cjs | 1785 ++ web/runtime/solid-js/dist/solid.js | 1730 ++ web/runtime/solid-js/h/dist/h.cjs | 115 + web/runtime/solid-js/h/dist/h.js | 113 + .../solid-js/h/jsx-dev-runtime/package.json | 8 + .../solid-js/h/jsx-runtime/dist/jsx.cjs | 15 + .../solid-js/h/jsx-runtime/dist/jsx.js | 10 + .../solid-js/h/jsx-runtime/package.json | 8 + .../solid-js/h/jsx-runtime/types/index.d.ts | 11 + .../solid-js/h/jsx-runtime/types/jsx.d.ts | 4241 +++ web/runtime/solid-js/h/package.json | 8 + web/runtime/solid-js/h/types/hyperscript.d.ts | 20 + web/runtime/solid-js/h/types/index.d.ts | 3 + web/runtime/solid-js/html/dist/html.cjs | 583 + web/runtime/solid-js/html/dist/html.js | 581 + web/runtime/solid-js/html/package.json | 8 + web/runtime/solid-js/html/types/index.d.ts | 3 + web/runtime/solid-js/html/types/lit.d.ts | 38 + web/runtime/solid-js/jsx-runtime.d.ts | 1 + web/runtime/solid-js/package.json | 243 + web/runtime/solid-js/store/dist/dev.cjs | 485 + web/runtime/solid-js/store/dist/dev.js | 476 + web/runtime/solid-js/store/dist/server.cjs | 133 + web/runtime/solid-js/store/dist/server.js | 121 + web/runtime/solid-js/store/dist/store.cjs | 463 + web/runtime/solid-js/store/dist/store.js | 454 + web/runtime/solid-js/store/package.json | 46 + web/runtime/solid-js/store/types/index.d.ts | 12 + .../solid-js/store/types/modifiers.d.ts | 6 + web/runtime/solid-js/store/types/mutable.d.ts | 5 + web/runtime/solid-js/store/types/server.d.ts | 17 + web/runtime/solid-js/store/types/store.d.ts | 108 + web/runtime/solid-js/types/index.d.ts | 23 + web/runtime/solid-js/types/jsx.d.ts | 4253 +++ .../solid-js/types/reactive/array.d.ts | 44 + .../solid-js/types/reactive/observable.d.ts | 36 + .../solid-js/types/reactive/scheduler.d.ts | 10 + .../solid-js/types/reactive/signal.d.ts | 581 + .../solid-js/types/render/Suspense.d.ts | 26 + .../solid-js/types/render/component.d.ts | 111 + web/runtime/solid-js/types/render/flow.d.ts | 118 + .../solid-js/types/render/hydration.d.ts | 24 + web/runtime/solid-js/types/render/index.d.ts | 4 + web/runtime/solid-js/types/server/index.d.ts | 3 + .../solid-js/types/server/reactive.d.ts | 98 + .../solid-js/types/server/rendering.d.ts | 160 + web/runtime/solid-js/universal/dist/dev.cjs | 245 + web/runtime/solid-js/universal/dist/dev.js | 243 + .../solid-js/universal/dist/universal.cjs | 245 + .../solid-js/universal/dist/universal.js | 243 + web/runtime/solid-js/universal/package.json | 20 + .../solid-js/universal/types/index.d.ts | 3 + .../solid-js/universal/types/universal.d.ts | 30 + web/runtime/solid-js/web/dist/dev.cjs | 894 + web/runtime/solid-js/web/dist/dev.js | 782 + web/runtime/solid-js/web/dist/server.cjs | 896 + web/runtime/solid-js/web/dist/server.js | 782 + web/runtime/solid-js/web/dist/web.cjs | 883 + web/runtime/solid-js/web/dist/web.js | 771 + web/runtime/solid-js/web/package.json | 46 + .../solid-js/web/storage/dist/storage.cjs | 12 + .../solid-js/web/storage/dist/storage.js | 10 + web/runtime/solid-js/web/storage/package.json | 15 + .../solid-js/web/storage/types/index.d.ts | 2 + web/runtime/solid-js/web/types/client.d.ts | 93 + web/runtime/solid-js/web/types/core.d.ts | 2 + web/runtime/solid-js/web/types/index.d.ts | 50 + web/runtime/solid-js/web/types/jsx.d.ts | 1 + .../solid-js/web/types/server-mock.d.ts | 65 + web/runtime/solid-js/web/types/server.d.ts | 187 + web/runtime/solid-refresh/LICENSE | 21 + web/runtime/solid-refresh/README.md | 228 + web/runtime/solid-refresh/dist/babel.cjs | 1082 + web/runtime/solid-refresh/dist/babel.cjs.map | 1 + web/runtime/solid-refresh/dist/babel.mjs | 1061 + web/runtime/solid-refresh/dist/babel.mjs.map | 1 + .../solid-refresh/dist/solid-refresh.cjs | 329 + .../solid-refresh/dist/solid-refresh.cjs.map | 1 + .../solid-refresh/dist/solid-refresh.mjs | 323 + .../solid-refresh/dist/solid-refresh.mjs.map | 1 + .../dist/src/babel/core/checks.d.ts | 4 + .../dist/src/babel/core/checks.d.ts.map | 1 + .../dist/src/babel/core/constants.d.ts | 8 + .../dist/src/babel/core/constants.d.ts.map | 1 + .../dist/src/babel/core/create-registry.d.ts | 5 + .../src/babel/core/create-registry.d.ts.map | 1 + .../src/babel/core/generate-unique-name.d.ts | 4 + .../babel/core/generate-unique-name.d.ts.map | 1 + .../dist/src/babel/core/generator.d.ts | 3 + .../dist/src/babel/core/generator.d.ts.map | 1 + .../src/babel/core/get-descriptive-name.d.ts | 3 + .../babel/core/get-descriptive-name.d.ts.map | 1 + .../src/babel/core/get-foreign-bindings.d.ts | 4 + .../babel/core/get-foreign-bindings.d.ts.map | 1 + .../src/babel/core/get-hmr-decline-call.d.ts | 4 + .../babel/core/get-hmr-decline-call.d.ts.map | 1 + .../src/babel/core/get-hot-identifier.d.ts | 4 + .../babel/core/get-hot-identifier.d.ts.map | 1 + .../src/babel/core/get-import-identifier.d.ts | 5 + .../babel/core/get-import-identifier.d.ts.map | 1 + .../babel/core/get-root-statement-path.d.ts | 3 + .../core/get-root-statement-path.d.ts.map | 1 + .../src/babel/core/get-statement-path.d.ts | 3 + .../babel/core/get-statement-path.d.ts.map | 1 + .../babel/core/is-statement-top-level.d.ts | 4 + .../core/is-statement-top-level.d.ts.map | 1 + .../dist/src/babel/core/is-valid-callee.d.ts | 5 + .../src/babel/core/is-valid-callee.d.ts.map | 1 + .../core/register-import-specifiers.d.ts | 5 + .../core/register-import-specifiers.d.ts.map | 1 + .../dist/src/babel/core/transform-jsx.d.ts | 4 + .../src/babel/core/transform-jsx.d.ts.map | 1 + .../dist/src/babel/core/types.d.ts | 42 + .../dist/src/babel/core/types.d.ts.map | 1 + .../dist/src/babel/core/unwrap.d.ts | 11 + .../dist/src/babel/core/unwrap.d.ts.map | 1 + .../dist/src/babel/core/xxhash32.d.ts | 7 + .../dist/src/babel/core/xxhash32.d.ts.map | 1 + .../solid-refresh/dist/src/babel/index.d.ts | 8 + .../dist/src/babel/index.d.ts.map | 1 + .../dist/src/runtime/create-proxy.d.ts | 6 + .../dist/src/runtime/create-proxy.d.ts.map | 1 + .../solid-refresh/dist/src/runtime/index.d.ts | 60 + .../dist/src/runtime/index.d.ts.map | 1 + .../dist/src/runtime/is-list-updated.d.ts | 2 + .../dist/src/runtime/is-list-updated.d.ts.map | 1 + .../solid-refresh/dist/src/shared/types.d.ts | 4 + .../dist/src/shared/types.d.ts.map | 1 + web/runtime/solid-refresh/package.json | 74 + web/runtime/vendor.json | 11 + web/ssr/serverData.ts | 17 + web/superfun.ts | 300 + web/types.d.ts | 30 + web/utils/accessors.ts | 14 + 315 files changed, 81075 insertions(+) create mode 100644 CLAUDE.md rename {appenv => go/appenv}/appenv.go (100%) rename {appenv => go/appenv}/env_development.go (100%) rename {appenv => go/appenv}/env_production.go (100%) rename {appenv => go/appenv}/env_staging.go (100%) rename {basic => go/basic}/basic.go (100%) rename {basic => go/basic}/basic_test.go (100%) rename {bundler => go/bundler}/build.go (100%) rename {bundler => go/bundler}/compile_solid.go (100%) rename {bundler => go/bundler}/compile_solid_gen.go (100%) rename {bundler => go/bundler}/compile_solid_render_test.go (100%) rename {bundler => go/bundler}/compile_solid_test.go (100%) rename {bundler => go/bundler}/css.go (100%) rename {bundler => go/bundler}/export_shim.go (100%) rename {bundler => go/bundler}/faicons.go (100%) rename {bundler => go/bundler}/genroutes.go (100%) rename {bundler => go/bundler}/genssr.go (100%) rename {bundler => go/bundler}/hmr_browser_test.go (100%) rename {bundler => go/bundler}/hmr_client.go (100%) rename {bundler => go/bundler}/hmr_client_test.go (100%) rename {bundler => go/bundler}/hmr_e2e_test.go (100%) rename {bundler => go/bundler}/hmr_error_test.go (100%) rename {bundler => go/bundler}/hmr_server.go (100%) rename {bundler => go/bundler}/hmr_vendor.go (100%) rename {bundler => go/bundler}/hmr_watch.go (100%) rename {bundler => go/bundler}/hmr_ws.go (100%) rename {bundler => go/bundler}/hmr_ws_integration_test.go (100%) rename {bundler => go/bundler}/hmr_ws_test.go (100%) rename {bundler => go/bundler}/import_check.go (100%) rename {bundler => go/bundler}/js.go (100%) rename {bundler => go/bundler}/js/ssr/dom.js (100%) rename {bundler => go/bundler}/jsx.go (100%) rename {bundler => go/bundler}/jsx_bench_test.go (100%) rename {bundler => go/bundler}/jsx_dev_test.go (100%) rename {bundler => go/bundler}/jsx_test.go (100%) rename {bundler => go/bundler}/renderer.go (100%) rename {bundler => go/bundler}/segment.go (100%) rename {bundler => go/bundler}/segment_test.go (100%) rename {bundler => go/bundler}/ssr.go (100%) rename {bundler => go/bundler}/ssr_test.go (100%) rename {bundler => go/bundler}/ssrcache.go (100%) rename {bundler => go/bundler}/tailwind.go (100%) rename {bundler => go/bundler}/tailwind_test.go (100%) rename {bundler => go/bundler}/tw_preflight.css (100%) rename {bundler => go/bundler}/tw_theme.css (100%) rename {bundler => go/bundler}/vendor_plugins.go (100%) rename {chrono => go/chrono}/chrono.go (100%) rename {chrono => go/chrono}/chrono_test.go (100%) rename {cmd => go/cmd}/bundle/main.go (100%) rename {cmd => go/cmd}/loc/main.go (100%) rename {cmd => go/cmd}/migrate/database.go (100%) rename {cmd => go/cmd}/migrate/engine.go (100%) rename {cmd => go/cmd}/migrate/main.go (100%) rename {cmd => go/cmd}/migrate/source.go (100%) rename {cmd => go/cmd}/passgen/main.go (100%) rename {cmd => go/cmd}/typecheck/main.go (100%) rename {config => go/config}/config.go (100%) rename {csv => go/csv}/csv.go (100%) rename {dbutil => go/dbutil}/automapper.go (100%) rename {dbutil => go/dbutil}/automapper_test.go (100%) rename {dbutil => go/dbutil}/builder.go (100%) rename {dbutil => go/dbutil}/builder_test.go (100%) rename {dbutil => go/dbutil}/db_connect.go (100%) rename {dbutil => go/dbutil}/filters.go (100%) rename {dbutil => go/dbutil}/registry.go (100%) rename {dbutil => go/dbutil}/test_models_test.go (100%) rename {finance => go/finance}/helpers.go (100%) rename go.mod => go/go.mod (100%) rename go.sum => go/go.sum (100%) rename {httputil => go/httputil}/cors.go (100%) rename {httputil => go/httputil}/cors_test.go (100%) rename {httputil => go/httputil}/doc.go (100%) rename {httputil => go/httputil}/respond.go (100%) rename {httputil => go/httputil}/useragent.go (100%) rename {l4g => go/l4g}/database.go (100%) rename {l4g => go/l4g}/debug.go (100%) rename {l4g => go/l4g}/doc.go (100%) rename {l4g => go/l4g}/entry.go (100%) rename {l4g => go/l4g}/file.go (100%) rename {l4g => go/l4g}/logger.go (100%) rename {l4g => go/l4g}/terminal.go (100%) rename {security => go/security}/crypt.go (100%) rename {security => go/security}/crypt_test.go (100%) rename {security => go/security}/init.go (100%) rename {security => go/security}/random.go (100%) rename {security => go/security}/random_test.go (100%) rename {snailmail => go/snailmail}/cloudflare.go (100%) rename {snailmail => go/snailmail}/smtp.go (100%) rename {snailmail => go/snailmail}/snailmail.go (100%) rename {validation => go/validation}/validation.go (100%) create mode 100644 web/auth/AuthContext.ts create mode 100644 web/auth/ProtectedRoute.ts create mode 100644 web/auth/checkBundleVersion.js create mode 100644 web/auth/useAuthFetch.js create mode 100644 web/basic.ts create mode 100644 web/env.ts create mode 100644 web/finance.ts create mode 100644 web/hooks/useDocumentTitle.js create mode 100644 web/kit/Accordion.tsx create mode 100644 web/kit/Alerts.tsx create mode 100644 web/kit/AutoTable.tsx create mode 100644 web/kit/Badges.tsx create mode 100644 web/kit/Buttons.tsx create mode 100644 web/kit/Calendar.tsx create mode 100644 web/kit/Cards.tsx create mode 100644 web/kit/CellGrid.tsx create mode 100644 web/kit/Chart.tsx create mode 100644 web/kit/CrmTabs.tsx create mode 100644 web/kit/DatePicker.tsx create mode 100644 web/kit/EnvBadge.tsx create mode 100644 web/kit/Floating.tsx create mode 100644 web/kit/Formatters.ts create mode 100644 web/kit/Forms.tsx create mode 100644 web/kit/FuzzyMatch.tsx create mode 100644 web/kit/General.tsx create mode 100644 web/kit/Icons.tsx create mode 100644 web/kit/Menu.tsx create mode 100644 web/kit/Modal.tsx create mode 100644 web/kit/Popovers.tsx create mode 100644 web/kit/PrettyTable.tsx create mode 100644 web/kit/RemoteUpdateFlash.tsx create mode 100644 web/kit/Sidebar.tsx create mode 100644 web/kit/Tabs.tsx create mode 100644 web/kit/Toast.tsx create mode 100644 web/kit/ToggleSwitch.tsx create mode 100644 web/kit/Tooltips.tsx create mode 100644 web/kit/Tutorial.tsx create mode 100644 web/kit/Validation.ts create mode 100644 web/runtime/@solidjs/router/LICENSE create mode 100644 web/runtime/@solidjs/router/README.md create mode 100644 web/runtime/@solidjs/router/dist/components.d.ts create mode 100644 web/runtime/@solidjs/router/dist/components.jsx create mode 100644 web/runtime/@solidjs/router/dist/data/action.d.ts create mode 100644 web/runtime/@solidjs/router/dist/data/action.js create mode 100644 web/runtime/@solidjs/router/dist/data/createAsync.d.ts create mode 100644 web/runtime/@solidjs/router/dist/data/createAsync.js create mode 100644 web/runtime/@solidjs/router/dist/data/events.d.ts create mode 100644 web/runtime/@solidjs/router/dist/data/events.js create mode 100644 web/runtime/@solidjs/router/dist/data/index.d.ts create mode 100644 web/runtime/@solidjs/router/dist/data/index.js create mode 100644 web/runtime/@solidjs/router/dist/data/query.d.ts create mode 100644 web/runtime/@solidjs/router/dist/data/query.js create mode 100644 web/runtime/@solidjs/router/dist/data/response.d.ts create mode 100644 web/runtime/@solidjs/router/dist/data/response.js create mode 100644 web/runtime/@solidjs/router/dist/index.d.ts create mode 100644 web/runtime/@solidjs/router/dist/index.js create mode 100644 web/runtime/@solidjs/router/dist/index.jsx create mode 100644 web/runtime/@solidjs/router/dist/lifecycle.d.ts create mode 100644 web/runtime/@solidjs/router/dist/lifecycle.js create mode 100644 web/runtime/@solidjs/router/dist/routers/HashRouter.d.ts create mode 100644 web/runtime/@solidjs/router/dist/routers/HashRouter.js create mode 100644 web/runtime/@solidjs/router/dist/routers/MemoryRouter.d.ts create mode 100644 web/runtime/@solidjs/router/dist/routers/MemoryRouter.js create mode 100644 web/runtime/@solidjs/router/dist/routers/Router.d.ts create mode 100644 web/runtime/@solidjs/router/dist/routers/Router.js create mode 100644 web/runtime/@solidjs/router/dist/routers/StaticRouter.d.ts create mode 100644 web/runtime/@solidjs/router/dist/routers/StaticRouter.js create mode 100644 web/runtime/@solidjs/router/dist/routers/components.d.ts create mode 100644 web/runtime/@solidjs/router/dist/routers/components.jsx create mode 100644 web/runtime/@solidjs/router/dist/routers/createRouter.d.ts create mode 100644 web/runtime/@solidjs/router/dist/routers/createRouter.js create mode 100644 web/runtime/@solidjs/router/dist/routers/index.d.ts create mode 100644 web/runtime/@solidjs/router/dist/routers/index.js create mode 100644 web/runtime/@solidjs/router/dist/routing.d.ts create mode 100644 web/runtime/@solidjs/router/dist/routing.js create mode 100644 web/runtime/@solidjs/router/dist/types.d.ts create mode 100644 web/runtime/@solidjs/router/dist/types.js create mode 100644 web/runtime/@solidjs/router/dist/utils.d.ts create mode 100644 web/runtime/@solidjs/router/dist/utils.js create mode 100644 web/runtime/@solidjs/router/package.json create mode 100644 web/runtime/csstype/LICENSE create mode 100644 web/runtime/csstype/README.md create mode 100644 web/runtime/csstype/index.d.ts create mode 100644 web/runtime/csstype/index.js.flow create mode 100644 web/runtime/csstype/package.json create mode 100644 web/runtime/solid-js/LICENSE create mode 100644 web/runtime/solid-js/README.md create mode 100644 web/runtime/solid-js/dist/dev.cjs create mode 100644 web/runtime/solid-js/dist/dev.js create mode 100644 web/runtime/solid-js/dist/server.cjs create mode 100644 web/runtime/solid-js/dist/server.js create mode 100644 web/runtime/solid-js/dist/solid.cjs create mode 100644 web/runtime/solid-js/dist/solid.js create mode 100644 web/runtime/solid-js/h/dist/h.cjs create mode 100644 web/runtime/solid-js/h/dist/h.js create mode 100644 web/runtime/solid-js/h/jsx-dev-runtime/package.json create mode 100644 web/runtime/solid-js/h/jsx-runtime/dist/jsx.cjs create mode 100644 web/runtime/solid-js/h/jsx-runtime/dist/jsx.js create mode 100644 web/runtime/solid-js/h/jsx-runtime/package.json create mode 100644 web/runtime/solid-js/h/jsx-runtime/types/index.d.ts create mode 100644 web/runtime/solid-js/h/jsx-runtime/types/jsx.d.ts create mode 100644 web/runtime/solid-js/h/package.json create mode 100644 web/runtime/solid-js/h/types/hyperscript.d.ts create mode 100644 web/runtime/solid-js/h/types/index.d.ts create mode 100644 web/runtime/solid-js/html/dist/html.cjs create mode 100644 web/runtime/solid-js/html/dist/html.js create mode 100644 web/runtime/solid-js/html/package.json create mode 100644 web/runtime/solid-js/html/types/index.d.ts create mode 100644 web/runtime/solid-js/html/types/lit.d.ts create mode 100644 web/runtime/solid-js/jsx-runtime.d.ts create mode 100644 web/runtime/solid-js/package.json create mode 100644 web/runtime/solid-js/store/dist/dev.cjs create mode 100644 web/runtime/solid-js/store/dist/dev.js create mode 100644 web/runtime/solid-js/store/dist/server.cjs create mode 100644 web/runtime/solid-js/store/dist/server.js create mode 100644 web/runtime/solid-js/store/dist/store.cjs create mode 100644 web/runtime/solid-js/store/dist/store.js create mode 100644 web/runtime/solid-js/store/package.json create mode 100644 web/runtime/solid-js/store/types/index.d.ts create mode 100644 web/runtime/solid-js/store/types/modifiers.d.ts create mode 100644 web/runtime/solid-js/store/types/mutable.d.ts create mode 100644 web/runtime/solid-js/store/types/server.d.ts create mode 100644 web/runtime/solid-js/store/types/store.d.ts create mode 100644 web/runtime/solid-js/types/index.d.ts create mode 100644 web/runtime/solid-js/types/jsx.d.ts create mode 100644 web/runtime/solid-js/types/reactive/array.d.ts create mode 100644 web/runtime/solid-js/types/reactive/observable.d.ts create mode 100644 web/runtime/solid-js/types/reactive/scheduler.d.ts create mode 100644 web/runtime/solid-js/types/reactive/signal.d.ts create mode 100644 web/runtime/solid-js/types/render/Suspense.d.ts create mode 100644 web/runtime/solid-js/types/render/component.d.ts create mode 100644 web/runtime/solid-js/types/render/flow.d.ts create mode 100644 web/runtime/solid-js/types/render/hydration.d.ts create mode 100644 web/runtime/solid-js/types/render/index.d.ts create mode 100644 web/runtime/solid-js/types/server/index.d.ts create mode 100644 web/runtime/solid-js/types/server/reactive.d.ts create mode 100644 web/runtime/solid-js/types/server/rendering.d.ts create mode 100644 web/runtime/solid-js/universal/dist/dev.cjs create mode 100644 web/runtime/solid-js/universal/dist/dev.js create mode 100644 web/runtime/solid-js/universal/dist/universal.cjs create mode 100644 web/runtime/solid-js/universal/dist/universal.js create mode 100644 web/runtime/solid-js/universal/package.json create mode 100644 web/runtime/solid-js/universal/types/index.d.ts create mode 100644 web/runtime/solid-js/universal/types/universal.d.ts create mode 100644 web/runtime/solid-js/web/dist/dev.cjs create mode 100644 web/runtime/solid-js/web/dist/dev.js create mode 100644 web/runtime/solid-js/web/dist/server.cjs create mode 100644 web/runtime/solid-js/web/dist/server.js create mode 100644 web/runtime/solid-js/web/dist/web.cjs create mode 100644 web/runtime/solid-js/web/dist/web.js create mode 100644 web/runtime/solid-js/web/package.json create mode 100644 web/runtime/solid-js/web/storage/dist/storage.cjs create mode 100644 web/runtime/solid-js/web/storage/dist/storage.js create mode 100644 web/runtime/solid-js/web/storage/package.json create mode 100644 web/runtime/solid-js/web/storage/types/index.d.ts create mode 100644 web/runtime/solid-js/web/types/client.d.ts create mode 100644 web/runtime/solid-js/web/types/core.d.ts create mode 100644 web/runtime/solid-js/web/types/index.d.ts create mode 100644 web/runtime/solid-js/web/types/jsx.d.ts create mode 100644 web/runtime/solid-js/web/types/server-mock.d.ts create mode 100644 web/runtime/solid-js/web/types/server.d.ts create mode 100644 web/runtime/solid-refresh/LICENSE create mode 100644 web/runtime/solid-refresh/README.md create mode 100644 web/runtime/solid-refresh/dist/babel.cjs create mode 100644 web/runtime/solid-refresh/dist/babel.cjs.map create mode 100644 web/runtime/solid-refresh/dist/babel.mjs create mode 100644 web/runtime/solid-refresh/dist/babel.mjs.map create mode 100644 web/runtime/solid-refresh/dist/solid-refresh.cjs create mode 100644 web/runtime/solid-refresh/dist/solid-refresh.cjs.map create mode 100644 web/runtime/solid-refresh/dist/solid-refresh.mjs create mode 100644 web/runtime/solid-refresh/dist/solid-refresh.mjs.map create mode 100644 web/runtime/solid-refresh/dist/src/babel/core/checks.d.ts create mode 100644 web/runtime/solid-refresh/dist/src/babel/core/checks.d.ts.map create mode 100644 web/runtime/solid-refresh/dist/src/babel/core/constants.d.ts create mode 100644 web/runtime/solid-refresh/dist/src/babel/core/constants.d.ts.map create mode 100644 web/runtime/solid-refresh/dist/src/babel/core/create-registry.d.ts create mode 100644 web/runtime/solid-refresh/dist/src/babel/core/create-registry.d.ts.map create mode 100644 web/runtime/solid-refresh/dist/src/babel/core/generate-unique-name.d.ts create mode 100644 web/runtime/solid-refresh/dist/src/babel/core/generate-unique-name.d.ts.map create mode 100644 web/runtime/solid-refresh/dist/src/babel/core/generator.d.ts create mode 100644 web/runtime/solid-refresh/dist/src/babel/core/generator.d.ts.map create mode 100644 web/runtime/solid-refresh/dist/src/babel/core/get-descriptive-name.d.ts create mode 100644 web/runtime/solid-refresh/dist/src/babel/core/get-descriptive-name.d.ts.map create mode 100644 web/runtime/solid-refresh/dist/src/babel/core/get-foreign-bindings.d.ts create mode 100644 web/runtime/solid-refresh/dist/src/babel/core/get-foreign-bindings.d.ts.map create mode 100644 web/runtime/solid-refresh/dist/src/babel/core/get-hmr-decline-call.d.ts create mode 100644 web/runtime/solid-refresh/dist/src/babel/core/get-hmr-decline-call.d.ts.map create mode 100644 web/runtime/solid-refresh/dist/src/babel/core/get-hot-identifier.d.ts create mode 100644 web/runtime/solid-refresh/dist/src/babel/core/get-hot-identifier.d.ts.map create mode 100644 web/runtime/solid-refresh/dist/src/babel/core/get-import-identifier.d.ts create mode 100644 web/runtime/solid-refresh/dist/src/babel/core/get-import-identifier.d.ts.map create mode 100644 web/runtime/solid-refresh/dist/src/babel/core/get-root-statement-path.d.ts create mode 100644 web/runtime/solid-refresh/dist/src/babel/core/get-root-statement-path.d.ts.map create mode 100644 web/runtime/solid-refresh/dist/src/babel/core/get-statement-path.d.ts create mode 100644 web/runtime/solid-refresh/dist/src/babel/core/get-statement-path.d.ts.map create mode 100644 web/runtime/solid-refresh/dist/src/babel/core/is-statement-top-level.d.ts create mode 100644 web/runtime/solid-refresh/dist/src/babel/core/is-statement-top-level.d.ts.map create mode 100644 web/runtime/solid-refresh/dist/src/babel/core/is-valid-callee.d.ts create mode 100644 web/runtime/solid-refresh/dist/src/babel/core/is-valid-callee.d.ts.map create mode 100644 web/runtime/solid-refresh/dist/src/babel/core/register-import-specifiers.d.ts create mode 100644 web/runtime/solid-refresh/dist/src/babel/core/register-import-specifiers.d.ts.map create mode 100644 web/runtime/solid-refresh/dist/src/babel/core/transform-jsx.d.ts create mode 100644 web/runtime/solid-refresh/dist/src/babel/core/transform-jsx.d.ts.map create mode 100644 web/runtime/solid-refresh/dist/src/babel/core/types.d.ts create mode 100644 web/runtime/solid-refresh/dist/src/babel/core/types.d.ts.map create mode 100644 web/runtime/solid-refresh/dist/src/babel/core/unwrap.d.ts create mode 100644 web/runtime/solid-refresh/dist/src/babel/core/unwrap.d.ts.map create mode 100644 web/runtime/solid-refresh/dist/src/babel/core/xxhash32.d.ts create mode 100644 web/runtime/solid-refresh/dist/src/babel/core/xxhash32.d.ts.map create mode 100644 web/runtime/solid-refresh/dist/src/babel/index.d.ts create mode 100644 web/runtime/solid-refresh/dist/src/babel/index.d.ts.map create mode 100644 web/runtime/solid-refresh/dist/src/runtime/create-proxy.d.ts create mode 100644 web/runtime/solid-refresh/dist/src/runtime/create-proxy.d.ts.map create mode 100644 web/runtime/solid-refresh/dist/src/runtime/index.d.ts create mode 100644 web/runtime/solid-refresh/dist/src/runtime/index.d.ts.map create mode 100644 web/runtime/solid-refresh/dist/src/runtime/is-list-updated.d.ts create mode 100644 web/runtime/solid-refresh/dist/src/runtime/is-list-updated.d.ts.map create mode 100644 web/runtime/solid-refresh/dist/src/shared/types.d.ts create mode 100644 web/runtime/solid-refresh/dist/src/shared/types.d.ts.map create mode 100644 web/runtime/solid-refresh/package.json create mode 100644 web/runtime/vendor.json create mode 100644 web/ssr/serverData.ts create mode 100644 web/superfun.ts create mode 100644 web/types.d.ts create mode 100644 web/utils/accessors.ts diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 00000000..32163973 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,101 @@ +# kjol + +`kjol` ("keel" in Norwegian) is a **shared, multilingual base layer** factored out of the +user's applications so they stay in sync. It is consumed as a **git submodule** inside each +app (at `/kjol`). Current consumers: `cdrateline.com_2.0` and `Hotlap` — near-identical +forks it was extracted from. Scope will grow to more projects and languages. + +## Golden rules + +1. **The framework NEVER imports application code.** Wherever kjol needs app-specific + behavior, the app injects it (interfaces, registration functions, config structs, + callbacks). See **Coupling inversions** below. If you find yourself wanting to + `import "/internal/..."` from kjol, invert it instead. +2. **Never run git operations here on the user's behalf.** The user creates the submodule and + makes all commits. You may edit files, build, and test. +3. kjol is edited **in place** via the submodule + `go.work` — there is no publish / `go get` + step. Editing a kjol file takes effect in the consuming app immediately. +4. When a file exists in both apps and has drifted, reconcile by **merging best-of-both**. + +## Organization — by language + +Each top-level directory is one language / build root: + +``` +kjol/ + go/ all Go. Module `kjol` (go.mod lives in go/). Imports are `kjol/`. + web/ all JS/TS (browser + SSR). No build system of its own; built by go/bundler. + # future: cpp/ kotlin/ swift/ +``` + +Language-first, **not** feature-first. Consequence: the **bundler is Go** and lives in +`go/bundler` even though it builds `web/`. Don't "fix" this by splitting it. + +### go/ — module `kjol` + +Packages, imported as `kjol/`: `appenv basic chrono config csv dbutil finance httputil +l4g security snailmail validation bundler`, plus `cmd/{bundle,migrate,loc,passgen,typecheck}`. + +Build / test (run from repo root): +``` +go -C go build ./... +go -C go vet ./... +go -C go test ./... +``` + +### web/ + +- `kit/` — Solid.js `.tsx` component kit. Apps import components as `@ui/*`. +- `runtime/` — vendored Solid runtime + `vendor.json` (base entrypoints). The app merges its + own `vendor.json` (chart.js, pdf-lib, ...) on top; **kjol's solid-js must resolve first** so + there is a single reactive instance. +- `icons/` — FontAwesome SVG source kit (the bundler scans usage and generates a per-app + registry; the generated file is app-owned, not committed here). +- `styles/` — `theme.css` (`@theme` scaffold + `:root` fa vars). Brand color/font tokens stay + app-side; the app's `style.css` `@import`s this. +- `auth/ utils/ hooks/ ssr/ env.ts basic.ts finance.ts superfun.ts types.d.ts` — generic TS + scaffolding. Apps import as `@kjol/*`. (Concrete permission constants stay app-side.) + +**Frontend import aliases** (resolved by the bundler and mirrored in each app's tsconfig +`paths`): `@ui/*` → `web/kit`, `@kjol/*` → `web/`, `@appgen/*` → the app's generated dir +(e.g. the FA `faIcons` registry — app-owned, gitignored, regenerated each build). The kit's +own imports of sibling components stay relative (`./Buttons.tsx`). + +## Consumption (per app) + +- `go.work` at the app root: + ``` + use ( + . + ./kjol/go + ) + ``` + then vendor with `go work vendor` (not `go mod vendor`). +- Startup wiring the app performs (this is how the inversions get their app-side halves): + `dbutil.RegisterAll(models.Tables)`, `l4g.SetDatabaseWriter(...)`, + `dbutil.Init(dbutil.ConnConfig{...})`, `snailmail.Configure(snailmail.Settings{...})`, a thin + `config.Load` wrapper over `config.Load[T]`, and an app-side `internal/httpauth` for the + session/authn middleware. + +## Coupling inversions (how the framework stays app-agnostic) + +| Package | Inversion | +|---|---| +| `dbutil` | table names via `Register`/`RegisterAll` (not the app's `models.Tables`) | +| `l4g` | owns the `Entry` type; DB persistence via `SetDatabaseWriter(func(Entry) error)` | +| `config` | generic `Load[T](file, *T) error`; each app defines its own config struct | +| `dbutil.ConnConfig`, `snailmail.Settings` | DB / mail credentials injected, never read from app config | +| `appenv` | compile-time environment via build tags (`-tags staging` / `-tags production`); the bundler reads `appenv.Environment` for the JS `__ENV_TYPE__` define | +| `httputil.CorsMiddleware(CorsConfig{...})` | allowed domains + bundle-version source injected | + +## Stays app-side (never moves into kjol) + +Domain models/repository/handlers-api, migrations, pages/routes/layouts, brand UI +(`TopBar`/`AppSidebar`/`TransitionOverlay`), concrete permission constants, the app config +struct, `embed.go` + `wwwroot/`, and generated artifacts (`faIcons` registry, `routes.gen.ts`, +`public_pages.gen.go`). + +## Provenance + +Big-bang extraction from cdrateline + Hotlap. The detailed migration plan lives on the +author's machine at `~/.claude/plans/foamy-humming-tulip.md`. diff --git a/appenv/appenv.go b/go/appenv/appenv.go similarity index 100% rename from appenv/appenv.go rename to go/appenv/appenv.go diff --git a/appenv/env_development.go b/go/appenv/env_development.go similarity index 100% rename from appenv/env_development.go rename to go/appenv/env_development.go diff --git a/appenv/env_production.go b/go/appenv/env_production.go similarity index 100% rename from appenv/env_production.go rename to go/appenv/env_production.go diff --git a/appenv/env_staging.go b/go/appenv/env_staging.go similarity index 100% rename from appenv/env_staging.go rename to go/appenv/env_staging.go diff --git a/basic/basic.go b/go/basic/basic.go similarity index 100% rename from basic/basic.go rename to go/basic/basic.go diff --git a/basic/basic_test.go b/go/basic/basic_test.go similarity index 100% rename from basic/basic_test.go rename to go/basic/basic_test.go diff --git a/bundler/build.go b/go/bundler/build.go similarity index 100% rename from bundler/build.go rename to go/bundler/build.go diff --git a/bundler/compile_solid.go b/go/bundler/compile_solid.go similarity index 100% rename from bundler/compile_solid.go rename to go/bundler/compile_solid.go diff --git a/bundler/compile_solid_gen.go b/go/bundler/compile_solid_gen.go similarity index 100% rename from bundler/compile_solid_gen.go rename to go/bundler/compile_solid_gen.go diff --git a/bundler/compile_solid_render_test.go b/go/bundler/compile_solid_render_test.go similarity index 100% rename from bundler/compile_solid_render_test.go rename to go/bundler/compile_solid_render_test.go diff --git a/bundler/compile_solid_test.go b/go/bundler/compile_solid_test.go similarity index 100% rename from bundler/compile_solid_test.go rename to go/bundler/compile_solid_test.go diff --git a/bundler/css.go b/go/bundler/css.go similarity index 100% rename from bundler/css.go rename to go/bundler/css.go diff --git a/bundler/export_shim.go b/go/bundler/export_shim.go similarity index 100% rename from bundler/export_shim.go rename to go/bundler/export_shim.go diff --git a/bundler/faicons.go b/go/bundler/faicons.go similarity index 100% rename from bundler/faicons.go rename to go/bundler/faicons.go diff --git a/bundler/genroutes.go b/go/bundler/genroutes.go similarity index 100% rename from bundler/genroutes.go rename to go/bundler/genroutes.go diff --git a/bundler/genssr.go b/go/bundler/genssr.go similarity index 100% rename from bundler/genssr.go rename to go/bundler/genssr.go diff --git a/bundler/hmr_browser_test.go b/go/bundler/hmr_browser_test.go similarity index 100% rename from bundler/hmr_browser_test.go rename to go/bundler/hmr_browser_test.go diff --git a/bundler/hmr_client.go b/go/bundler/hmr_client.go similarity index 100% rename from bundler/hmr_client.go rename to go/bundler/hmr_client.go diff --git a/bundler/hmr_client_test.go b/go/bundler/hmr_client_test.go similarity index 100% rename from bundler/hmr_client_test.go rename to go/bundler/hmr_client_test.go diff --git a/bundler/hmr_e2e_test.go b/go/bundler/hmr_e2e_test.go similarity index 100% rename from bundler/hmr_e2e_test.go rename to go/bundler/hmr_e2e_test.go diff --git a/bundler/hmr_error_test.go b/go/bundler/hmr_error_test.go similarity index 100% rename from bundler/hmr_error_test.go rename to go/bundler/hmr_error_test.go diff --git a/bundler/hmr_server.go b/go/bundler/hmr_server.go similarity index 100% rename from bundler/hmr_server.go rename to go/bundler/hmr_server.go diff --git a/bundler/hmr_vendor.go b/go/bundler/hmr_vendor.go similarity index 100% rename from bundler/hmr_vendor.go rename to go/bundler/hmr_vendor.go diff --git a/bundler/hmr_watch.go b/go/bundler/hmr_watch.go similarity index 100% rename from bundler/hmr_watch.go rename to go/bundler/hmr_watch.go diff --git a/bundler/hmr_ws.go b/go/bundler/hmr_ws.go similarity index 100% rename from bundler/hmr_ws.go rename to go/bundler/hmr_ws.go diff --git a/bundler/hmr_ws_integration_test.go b/go/bundler/hmr_ws_integration_test.go similarity index 100% rename from bundler/hmr_ws_integration_test.go rename to go/bundler/hmr_ws_integration_test.go diff --git a/bundler/hmr_ws_test.go b/go/bundler/hmr_ws_test.go similarity index 100% rename from bundler/hmr_ws_test.go rename to go/bundler/hmr_ws_test.go diff --git a/bundler/import_check.go b/go/bundler/import_check.go similarity index 100% rename from bundler/import_check.go rename to go/bundler/import_check.go diff --git a/bundler/js.go b/go/bundler/js.go similarity index 100% rename from bundler/js.go rename to go/bundler/js.go diff --git a/bundler/js/ssr/dom.js b/go/bundler/js/ssr/dom.js similarity index 100% rename from bundler/js/ssr/dom.js rename to go/bundler/js/ssr/dom.js diff --git a/bundler/jsx.go b/go/bundler/jsx.go similarity index 100% rename from bundler/jsx.go rename to go/bundler/jsx.go diff --git a/bundler/jsx_bench_test.go b/go/bundler/jsx_bench_test.go similarity index 100% rename from bundler/jsx_bench_test.go rename to go/bundler/jsx_bench_test.go diff --git a/bundler/jsx_dev_test.go b/go/bundler/jsx_dev_test.go similarity index 100% rename from bundler/jsx_dev_test.go rename to go/bundler/jsx_dev_test.go diff --git a/bundler/jsx_test.go b/go/bundler/jsx_test.go similarity index 100% rename from bundler/jsx_test.go rename to go/bundler/jsx_test.go diff --git a/bundler/renderer.go b/go/bundler/renderer.go similarity index 100% rename from bundler/renderer.go rename to go/bundler/renderer.go diff --git a/bundler/segment.go b/go/bundler/segment.go similarity index 100% rename from bundler/segment.go rename to go/bundler/segment.go diff --git a/bundler/segment_test.go b/go/bundler/segment_test.go similarity index 100% rename from bundler/segment_test.go rename to go/bundler/segment_test.go diff --git a/bundler/ssr.go b/go/bundler/ssr.go similarity index 100% rename from bundler/ssr.go rename to go/bundler/ssr.go diff --git a/bundler/ssr_test.go b/go/bundler/ssr_test.go similarity index 100% rename from bundler/ssr_test.go rename to go/bundler/ssr_test.go diff --git a/bundler/ssrcache.go b/go/bundler/ssrcache.go similarity index 100% rename from bundler/ssrcache.go rename to go/bundler/ssrcache.go diff --git a/bundler/tailwind.go b/go/bundler/tailwind.go similarity index 100% rename from bundler/tailwind.go rename to go/bundler/tailwind.go diff --git a/bundler/tailwind_test.go b/go/bundler/tailwind_test.go similarity index 100% rename from bundler/tailwind_test.go rename to go/bundler/tailwind_test.go diff --git a/bundler/tw_preflight.css b/go/bundler/tw_preflight.css similarity index 100% rename from bundler/tw_preflight.css rename to go/bundler/tw_preflight.css diff --git a/bundler/tw_theme.css b/go/bundler/tw_theme.css similarity index 100% rename from bundler/tw_theme.css rename to go/bundler/tw_theme.css diff --git a/bundler/vendor_plugins.go b/go/bundler/vendor_plugins.go similarity index 100% rename from bundler/vendor_plugins.go rename to go/bundler/vendor_plugins.go diff --git a/chrono/chrono.go b/go/chrono/chrono.go similarity index 100% rename from chrono/chrono.go rename to go/chrono/chrono.go diff --git a/chrono/chrono_test.go b/go/chrono/chrono_test.go similarity index 100% rename from chrono/chrono_test.go rename to go/chrono/chrono_test.go diff --git a/cmd/bundle/main.go b/go/cmd/bundle/main.go similarity index 100% rename from cmd/bundle/main.go rename to go/cmd/bundle/main.go diff --git a/cmd/loc/main.go b/go/cmd/loc/main.go similarity index 100% rename from cmd/loc/main.go rename to go/cmd/loc/main.go diff --git a/cmd/migrate/database.go b/go/cmd/migrate/database.go similarity index 100% rename from cmd/migrate/database.go rename to go/cmd/migrate/database.go diff --git a/cmd/migrate/engine.go b/go/cmd/migrate/engine.go similarity index 100% rename from cmd/migrate/engine.go rename to go/cmd/migrate/engine.go diff --git a/cmd/migrate/main.go b/go/cmd/migrate/main.go similarity index 100% rename from cmd/migrate/main.go rename to go/cmd/migrate/main.go diff --git a/cmd/migrate/source.go b/go/cmd/migrate/source.go similarity index 100% rename from cmd/migrate/source.go rename to go/cmd/migrate/source.go diff --git a/cmd/passgen/main.go b/go/cmd/passgen/main.go similarity index 100% rename from cmd/passgen/main.go rename to go/cmd/passgen/main.go diff --git a/cmd/typecheck/main.go b/go/cmd/typecheck/main.go similarity index 100% rename from cmd/typecheck/main.go rename to go/cmd/typecheck/main.go diff --git a/config/config.go b/go/config/config.go similarity index 100% rename from config/config.go rename to go/config/config.go diff --git a/csv/csv.go b/go/csv/csv.go similarity index 100% rename from csv/csv.go rename to go/csv/csv.go diff --git a/dbutil/automapper.go b/go/dbutil/automapper.go similarity index 100% rename from dbutil/automapper.go rename to go/dbutil/automapper.go diff --git a/dbutil/automapper_test.go b/go/dbutil/automapper_test.go similarity index 100% rename from dbutil/automapper_test.go rename to go/dbutil/automapper_test.go diff --git a/dbutil/builder.go b/go/dbutil/builder.go similarity index 100% rename from dbutil/builder.go rename to go/dbutil/builder.go diff --git a/dbutil/builder_test.go b/go/dbutil/builder_test.go similarity index 100% rename from dbutil/builder_test.go rename to go/dbutil/builder_test.go diff --git a/dbutil/db_connect.go b/go/dbutil/db_connect.go similarity index 100% rename from dbutil/db_connect.go rename to go/dbutil/db_connect.go diff --git a/dbutil/filters.go b/go/dbutil/filters.go similarity index 100% rename from dbutil/filters.go rename to go/dbutil/filters.go diff --git a/dbutil/registry.go b/go/dbutil/registry.go similarity index 100% rename from dbutil/registry.go rename to go/dbutil/registry.go diff --git a/dbutil/test_models_test.go b/go/dbutil/test_models_test.go similarity index 100% rename from dbutil/test_models_test.go rename to go/dbutil/test_models_test.go diff --git a/finance/helpers.go b/go/finance/helpers.go similarity index 100% rename from finance/helpers.go rename to go/finance/helpers.go diff --git a/go.mod b/go/go.mod similarity index 100% rename from go.mod rename to go/go.mod diff --git a/go.sum b/go/go.sum similarity index 100% rename from go.sum rename to go/go.sum diff --git a/httputil/cors.go b/go/httputil/cors.go similarity index 100% rename from httputil/cors.go rename to go/httputil/cors.go diff --git a/httputil/cors_test.go b/go/httputil/cors_test.go similarity index 100% rename from httputil/cors_test.go rename to go/httputil/cors_test.go diff --git a/httputil/doc.go b/go/httputil/doc.go similarity index 100% rename from httputil/doc.go rename to go/httputil/doc.go diff --git a/httputil/respond.go b/go/httputil/respond.go similarity index 100% rename from httputil/respond.go rename to go/httputil/respond.go diff --git a/httputil/useragent.go b/go/httputil/useragent.go similarity index 100% rename from httputil/useragent.go rename to go/httputil/useragent.go diff --git a/l4g/database.go b/go/l4g/database.go similarity index 100% rename from l4g/database.go rename to go/l4g/database.go diff --git a/l4g/debug.go b/go/l4g/debug.go similarity index 100% rename from l4g/debug.go rename to go/l4g/debug.go diff --git a/l4g/doc.go b/go/l4g/doc.go similarity index 100% rename from l4g/doc.go rename to go/l4g/doc.go diff --git a/l4g/entry.go b/go/l4g/entry.go similarity index 100% rename from l4g/entry.go rename to go/l4g/entry.go diff --git a/l4g/file.go b/go/l4g/file.go similarity index 100% rename from l4g/file.go rename to go/l4g/file.go diff --git a/l4g/logger.go b/go/l4g/logger.go similarity index 100% rename from l4g/logger.go rename to go/l4g/logger.go diff --git a/l4g/terminal.go b/go/l4g/terminal.go similarity index 100% rename from l4g/terminal.go rename to go/l4g/terminal.go diff --git a/security/crypt.go b/go/security/crypt.go similarity index 100% rename from security/crypt.go rename to go/security/crypt.go diff --git a/security/crypt_test.go b/go/security/crypt_test.go similarity index 100% rename from security/crypt_test.go rename to go/security/crypt_test.go diff --git a/security/init.go b/go/security/init.go similarity index 100% rename from security/init.go rename to go/security/init.go diff --git a/security/random.go b/go/security/random.go similarity index 100% rename from security/random.go rename to go/security/random.go diff --git a/security/random_test.go b/go/security/random_test.go similarity index 100% rename from security/random_test.go rename to go/security/random_test.go diff --git a/snailmail/cloudflare.go b/go/snailmail/cloudflare.go similarity index 100% rename from snailmail/cloudflare.go rename to go/snailmail/cloudflare.go diff --git a/snailmail/smtp.go b/go/snailmail/smtp.go similarity index 100% rename from snailmail/smtp.go rename to go/snailmail/smtp.go diff --git a/snailmail/snailmail.go b/go/snailmail/snailmail.go similarity index 100% rename from snailmail/snailmail.go rename to go/snailmail/snailmail.go diff --git a/validation/validation.go b/go/validation/validation.go similarity index 100% rename from validation/validation.go rename to go/validation/validation.go diff --git a/web/auth/AuthContext.ts b/web/auth/AuthContext.ts new file mode 100644 index 00000000..89881d48 --- /dev/null +++ b/web/auth/AuthContext.ts @@ -0,0 +1,308 @@ +import { createContext, useContext, createSignal, createMemo, onMount, JSXElement } from "solid-js"; +import html from "solid-js/html"; +import { getToken, setToken, clearToken, isTokenExpired } from "./useAuthFetch.js"; +import { apiFetch } from "./checkBundleVersion.js"; + +export interface Identity { + id: string; + username: string; + email: string; + first_name: string; + last_name: string; + permissions: string[]; + org_id: string | null; + org_name: string | null; + org_cert_num: string | null; +} + +export interface MemberOrg { + id: string; + name: string; + certnum: string | null; +} + +export interface SuperAdminState { + enabled: boolean; + orgName: string | null; + firstMemberOrgId: string | null; +} + +export interface LoginResult { + success: boolean; + error?: string; +} + +export interface SwitchOrgResult { + success: boolean; + org_id?: string; + org_name?: string; + org_cert_num?: string | null; + error?: string; +} + +export interface AuthContextValue { + identity: () => Identity | null; + loading: () => boolean; + error: () => string | null; + isAuthenticated: () => boolean; + superAdminMode: () => SuperAdminState; + memberOrganizations: () => MemberOrg[]; + login: (username: string, password: string, rememberMe?: boolean) => Promise; + logout: () => Promise; + checkAuth: () => Promise; + switchOrganization: (orgId: string) => Promise; + disableSuperAdminMode: () => Promise; +} + +interface AuthProviderProps { + children?: JSXElement; +} + +const AuthContext = createContext(); + +export function AuthProvider(props: AuthProviderProps) { + const [identity, setIdentity] = createSignal(null); + const [loading, setLoading] = createSignal(true); + const [error, setError] = createSignal(null); + const [memberOrganizations, setMemberOrganizations] = createSignal([]); + const [isAdmin, setIsAdmin] = createSignal(false); + + const isAuthenticated = createMemo(() => !!identity()); + + const checkAuth = async (): Promise => { + try { + setLoading(true); + setError(null); + + const token = getToken(); + + if (!token) { + setIdentity(null); + setLoading(false); + return; + } + + if (isTokenExpired()) { + clearToken(); + setIdentity(null); + setLoading(false); + return; + } + + // Retry transient network failures a few times so a brief blip + // self-heals instead of dropping the user to an error screen. A + // 401 is a real response and is handled below, not retried. -mta 6/3/26 + let response; + for (let attempt = 0; ; attempt++) { + try { + response = await apiFetch("/api/auth/me", { + headers: { + "Authorization": `Bearer ${token}`, + }, + }); + break; + } catch (err) { + if (attempt >= 2) throw err; + await new Promise((resolve) => setTimeout(resolve, 400 * (attempt + 1))); + } + } + + // Only a definitive 401 means the session is actually invalid. A + // network error, an aborted request (e.g. the user refreshed + // mid-flight), or a 5xx must NOT clear the token -- otherwise a + // quick refresh aborts this request and silently logs the user + // out. -mta 6/3/26 + if (response.status === 401) { + clearToken(); + setIdentity(null); + setMemberOrganizations([]); + setIsAdmin(false); + return; + } + + if (!response.ok) { + throw new Error(`Auth check failed: ${response.status}`); + } + + const data = await response.json(); + setIdentity(data); + + const orgsResponse = await apiFetch("/api/auth/my-organizations", { + headers: { + "Authorization": `Bearer ${token}`, + }, + }); + + if (orgsResponse.ok) { + const orgsData = await orgsResponse.json(); + setMemberOrganizations(orgsData.member_organizations || []); + setIsAdmin(orgsData.is_admin || false); + } + } catch (err) { + // Transient failure (network/abort/5xx). Preserve the token so the + // next load can retry instead of forcing a logout. -mta 6/3/26 + setError(err instanceof Error ? err.message : "Unknown error"); + } finally { + setLoading(false); + } + }; + + onMount(() => { + checkAuth(); + }); + + const login = async (username: string, password: string, rememberMe = false): Promise => { + try { + setLoading(true); + setError(null); + + const response = await apiFetch("/api/auth/login", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + username, + password, + remember_me: rememberMe, + }), + }); + + const data = await response.json(); + + if (!response.ok || !data.success) { + throw new Error(data.error || "Login failed"); + } + + setToken(data.token, data.expires_at); + setIdentity(data.user); + + const orgsResponse = await apiFetch("/api/auth/my-organizations", { + headers: { + "Authorization": `Bearer ${data.token}`, + }, + }); + + if (orgsResponse.ok) { + const orgsData = await orgsResponse.json(); + setMemberOrganizations(orgsData.member_organizations || []); + setIsAdmin(orgsData.is_admin || false); + } + + return { success: true }; + } catch (err) { + const message = err instanceof Error ? err.message : "Unknown error"; + setError(message); + return { success: false, error: message }; + } finally { + setLoading(false); + } + }; + + const logout = async (): Promise => { + const token = getToken(); + + if (token) { + try { + await apiFetch("/api/auth/logout", { + method: "POST", + headers: { + "Authorization": `Bearer ${token}`, + }, + }); + } catch { + // Continue with logout even if API call fails + } + } + + clearToken(); + setIdentity(null); + window.location.href = "/login"; + }; + + const switchOrganization = async (orgId: string): Promise => { + try { + setError(null); + + const token = getToken(); + if (!token) { + throw new Error("Not authenticated"); + } + + const response = await apiFetch("/api/auth/switch-org", { + method: "POST", + headers: { + "Content-Type": "application/json", + "Authorization": `Bearer ${token}`, + }, + body: JSON.stringify({ org_id: orgId }), + }); + + const data = await response.json(); + + if (!response.ok || !data.success) { + throw new Error(data.error || "Failed to switch organization"); + } + + setIdentity((prev) => prev ? { + ...prev, + org_id: data.org_id, + org_name: data.org_name, + org_cert_num: data.org_cert_num, + } : null); + + return { + success: true, + org_id: data.org_id, + org_name: data.org_name, + org_cert_num: data.org_cert_num, + }; + } catch (err) { + const message = err instanceof Error ? err.message : "Unknown error"; + setError(message); + return { success: false, error: message }; + } + }; + + const disableSuperAdminMode = async (): Promise => { + const orgs = memberOrganizations(); + if (orgs.length > 0) { + await switchOrganization(orgs[0].id); + } + }; + + const superAdminMode = (): SuperAdminState => { + const id = identity(); + const orgs = memberOrganizations(); + return { + enabled: isAdmin() && id?.org_id != null && !orgs.some((org) => org.id === id.org_id), + orgName: id?.org_name || null, + firstMemberOrgId: orgs.length > 0 ? orgs[0].id : null, + }; + }; + + const value: AuthContextValue = { + identity, + loading, + error, + isAuthenticated, + superAdminMode, + memberOrganizations, + login, + logout, + checkAuth, + switchOrganization, + disableSuperAdminMode, + }; + + // CRITICAL: Use lazy children evaluation + return html`<${AuthContext.Provider} value=${value}>${() => props.children}`; +} + +export function useAuth(): AuthContextValue { + const context = useContext(AuthContext); + if (!context) { + throw new Error("useAuth must be used within an AuthProvider"); + } + return context; +} diff --git a/web/auth/ProtectedRoute.ts b/web/auth/ProtectedRoute.ts new file mode 100644 index 00000000..d97fc091 --- /dev/null +++ b/web/auth/ProtectedRoute.ts @@ -0,0 +1,61 @@ +import { createEffect, createSignal, createMemo, JSXElement } from "solid-js"; +import html from "solid-js/html"; +import { useAuth } from "./AuthContext.ts"; +import { getToken } from "./useAuthFetch.js"; +import { hasPermission } from "./Permissions.js"; +import { Loader } from "../ui/General.tsx"; + +interface ProtectedRouteProps { + permissions?: string[]; + children?: JSXElement; +} + +export function ProtectedRoute(props: ProtectedRouteProps) { + const auth = useAuth(); + const [isRedirecting, setIsRedirecting] = createSignal(false); + + createEffect(() => { + // Redirect to login ONLY when there is genuinely no session. A real + // 401 clears the token (so getToken() is null here), but a transient + // check failure preserves it -- in that case we keep the user here and + // show a retry screen instead of logging them out. -mta 6/3/26 + if (!auth.loading() && !auth.isAuthenticated() && !getToken() && !isRedirecting()) { + setIsRedirecting(true); + const returnUrl = encodeURIComponent(window.location.pathname + window.location.search); + window.location.replace(`/login?redirect=${returnUrl}`); + } + }); + + const hasRequiredPermissions = createMemo(() => { + const permissions = props.permissions || []; + if (permissions.length === 0) return true; + const userPermissions = auth.identity()?.permissions || []; + return permissions.every((perm) => hasPermission(userPermissions, perm)); + }); + + const state = createMemo(() => { + if (isRedirecting() || auth.loading()) return 'loading'; + if (!auth.isAuthenticated()) { + // Token still present => the check failed transiently => offer a + // retry. No token => the redirect effect is taking over. + return getToken() ? 'error' : 'loading'; + } + if (!hasRequiredPermissions()) return 'unauthorized'; + return 'ready'; + }); + + return html`
+ ${() => state() === 'loading' && html`<${Loader} />`} + ${() => state() === 'error' && html`
+

Connection problem

+

We couldn't verify your session. You're still signed in -- please try again.

+ +
`} + ${() => state() === 'unauthorized' && html`
+

Access Denied

+

You don't have permission to access this page.

+ Return to Dashboard +
`} + ${() => state() === 'ready' && props.children} +
`; +} diff --git a/web/auth/checkBundleVersion.js b/web/auth/checkBundleVersion.js new file mode 100644 index 00000000..e29f2f23 --- /dev/null +++ b/web/auth/checkBundleVersion.js @@ -0,0 +1,51 @@ +let bundleStaleNotified = false; + +export function checkBundleVersion(response) { + const serverVersion = response.headers.get("X-Bundle-Version"); + const clientVersion = window.__BUNDLE_VERSION__; + if (!serverVersion || !clientVersion || serverVersion === clientVersion) { + return; + } + + if (bundleStaleNotified) { + return; + } + bundleStaleNotified = true; + + const bar = document.createElement("div"); + bar.setAttribute("role", "status"); + bar.style.cssText = [ + "position:fixed", + "top:0", + "left:0", + "right:0", + "z-index:99999", + "padding:12px 16px", + "background:#141620", + "color:#fff", + "text-align:center", + "font:14px system-ui,sans-serif", + "box-shadow:0 2px 8px rgba(0,0,0,0.2)", + ].join(";"); + + const message = document.createElement("span"); + message.textContent = "A new version of National CD Rateline is available."; + + const button = document.createElement("button"); + button.type = "button"; + button.textContent = "Refresh"; + button.style.cssText = "margin-left:12px;padding:4px 12px;cursor:pointer;border:0;border-radius:4px;background:#fff;color:#141620;font:inherit"; + + button.addEventListener("click", () => { + window.location.reload(); + }); + + bar.append(message, button); + document.body.prepend(bar); +} + +export async function apiFetch(url, options) { + const response = await fetch(url, options); + checkBundleVersion(response); + return response; +} diff --git a/web/auth/useAuthFetch.js b/web/auth/useAuthFetch.js new file mode 100644 index 00000000..8053fbfb --- /dev/null +++ b/web/auth/useAuthFetch.js @@ -0,0 +1,106 @@ +import { apiFetch } from "./checkBundleVersion.js"; + +const TOKEN_KEY = "session_key"; +const TOKEN_EXPIRY_KEY = "session_key_expiry"; + +export function getToken() { + return localStorage.getItem(TOKEN_KEY); +} + +export function setToken(token, expiresAt) { + localStorage.setItem(TOKEN_KEY, token); + if (expiresAt) { + localStorage.setItem(TOKEN_EXPIRY_KEY, expiresAt.toString()); + } +} + +export function clearToken() { + localStorage.removeItem(TOKEN_KEY); + localStorage.removeItem(TOKEN_EXPIRY_KEY); +} + +export function isTokenExpired() { + const expiry = localStorage.getItem(TOKEN_EXPIRY_KEY); + if (!expiry) return true; + return Date.now() >= parseInt(expiry, 10) * 1000; +} + +export function useAuthFetch(options = {}) { + const { onUnauthorized } = options; + + return async (url, fetchOptions = {}) => { + const fullUrl = url.startsWith("http") ? url : url; + const token = getToken(); + + const headers = { + "Content-Type": "application/json", + ...fetchOptions.headers, + }; + + if (token) { + headers["Authorization"] = `Bearer ${token}`; + } + + const response = await apiFetch(fullUrl, { + ...fetchOptions, + headers, + }); + + if (response.status === 401) { + clearToken(); + if (onUnauthorized) { + onUnauthorized(); + } else { + const returnUrl = encodeURIComponent(window.location.pathname + window.location.search); + window.location.href = `/login?redirect=${returnUrl}`; + } + throw new AuthError("Unauthorized", 401); + } + + if (response.status === 403) { + throw new AuthError("Forbidden: insufficient permissions", 403); + } + + return response; + }; +} + +export async function authFetch(url, options = {}) { + const fullUrl = url.startsWith("http") ? url : url; + const token = getToken(); + + const headers = { + "Content-Type": "application/json", + ...options.headers, + }; + + if (token) { + headers["Authorization"] = `Bearer ${token}`; + } + + const response = await fetch(fullUrl, { + ...options, + headers, + }); + + if (response.status === 401) { + clearToken(); + const returnUrl = encodeURIComponent(window.location.pathname + window.location.search); + window.location.href = `/login?redirect=${returnUrl}`; + throw new AuthError("Unauthorized", 401); + } + + if (response.status === 403) { + throw new AuthError("Forbidden: insufficient permissions", 403); + } + + return response; +} + +export class AuthError extends Error { + constructor(message, status) { + super(message); + this.name = "AuthError"; + this.status = status; + } +} diff --git a/web/basic.ts b/web/basic.ts new file mode 100644 index 00000000..cac01479 --- /dev/null +++ b/web/basic.ts @@ -0,0 +1,56 @@ +export function capitalizeFirstLetter(input: string): string { + return input.charAt(0).toUpperCase() + input.slice(1); +} + +export function toSnakeCase(input: string): string { + return input + .replace(/([a-z])([A-Z])/g, "$1_$2") + .replace(/[\s\-]+/g, "_") + .toLowerCase(); +} + +export function snakeCaseToTitleCase(input: string): string { + return input + .split("_") + .map((word) => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase()) + .join(" "); +} + +// Strips non-numeric characters from a string including spaces. +export function sanitizeNum(input: string): string { + return input.replaceAll(/[^\d]+/g, ""); +} + +// Strips non-alphanumeric, non-space characters from a string. +export function sanitizeAlphaNum(input: string): string { + return input.replaceAll(/[^a-zA-Z0-9 ]+/g, ""); +} + +// Strips non-alphanumeric characters from a string including spaces. +export function sanitizeAlphaNumStrict(input: string): string { + return input.replaceAll(/[^a-zA-Z0-9]+/g, ""); +} + +export function numberToStringWithCommas(n: number):string { + var str = n.toString(); + + var negative = false; + if (str.startsWith("-")) { + negative = true; + str = str.slice(1); + } + + var result = ""; + for (let i=0; i < str.length; i++) { + if (i > 0 && (str.length - i)%3 === 0) { + result += ","; + } + result += str[i] + } + + if (negative) { + result = "-" + result; + } + + return result; +} \ No newline at end of file diff --git a/web/env.ts b/web/env.ts new file mode 100644 index 00000000..4707371a --- /dev/null +++ b/web/env.ts @@ -0,0 +1,16 @@ +// Compile-time deployment environment: "development" | "staging" | "production". +// +// `__ENV_TYPE__` is substituted at bundle time by esbuild's `define`, fed the Go +// compile-time constant `internal/constants.AppEnvironment` (see the +// `esbuildDefine` helper in internal/bundler). The production bundles and the dev +// HMR per-module transform both define it. The build-time SSR render of the +// public pages does NOT, so the `typeof` guard yields "" there — the env badge +// renders only after the client takeover, matching the prior runtime behavior. +declare const __ENV_TYPE__: string; + +export const ENV_TYPE: string = typeof __ENV_TYPE__ !== "undefined" ? __ENV_TYPE__ : ""; + +// True in every environment except production (and the badge-less SSR render). +export function isNonProdEnv(): boolean { + return ENV_TYPE !== "" && ENV_TYPE !== "production"; +} diff --git a/web/finance.ts b/web/finance.ts new file mode 100644 index 00000000..b2a3eb77 --- /dev/null +++ b/web/finance.ts @@ -0,0 +1,98 @@ +// Format a number as USD currency. +// If `showChangeIfExists` is false, the cents value is only shown if there are leftover cents +// after converting to dollars (i.e. amount=1000 would be shown as $10, but amount=1001 would be +// shown as $10.01). If true, cents are always displayed. +export function formatCurrency(amount: number, showChangeIfExists: boolean = true): string { + const dollars = amount / 100; + return new Intl.NumberFormat("en-US", { + minimumFractionDigits: 2, + maximumFractionDigits: 2, + trailingZeroDisplay: (showChangeIfExists ? "auto" : "stripIfInteger"), + useGrouping: false, + }).format(dollars); +} + +// // Same as formatMoney() but with commas. +export function formatCurrencyWithCommas(amount: number, showChangeIfExists: boolean = true): string { + const dollars = amount / 100; + return new Intl.NumberFormat("en-US", { + minimumFractionDigits: 2, + maximumFractionDigits: 2, + trailingZeroDisplay: (showChangeIfExists ? "auto" : "stripIfInteger"), + }).format(dollars); +} + +// takes a number such as 123456, and outputs (1234, 56) as strings +export function splitCurrency(amount: number): [string, string] { + // Calculate dollars as a string (without cents) + const dollars = Math.floor(amount / 100).toString(); + // Calculate cents as a string with leading zero if necessary; + const cents = (amount % 100).toString().padStart(2, "0"); + return [dollars, cents]; +} + +export function moneyToNumber(input: string): number { + if (!input.includes(".")) { + input += ".00"; + } else if (input.split(".").length - 1 === 1) { + const digits = input.split("."); + if (digits[1].length === 1) { + input += "0"; + } + } + let processed = input + .replaceAll(".", "") + .replaceAll(",", "") + .replaceAll(" ", ""); + return parseInt(processed); +} + +export function formatRate(rate: number): string { + return (rate / 1000).toFixed(3); +} + +export function processDiscount(discount: string): number { + const discountFloat = parseFloat(discount); + + if (discountFloat < 0) { + return 0; + } else if (discountFloat > 100) { + return 100; + } + + return discountFloat; +} + +// Takes days as an input and outputs the string representation of the number +// of days, months, or years in the term based on which unit is the best fit +// for the amount of days along with the corresponding unit string. +export function daysToRateTerm(days: number): [string, string] { + const years = Math.floor(days / 365); + const months = Math.floor((days - years*365) / 30); + const dayRemainder = days - years*365 - months*30; + + if (days == 0) { // Return empty string for value + return ["", "days"]; + } else if (days <= 270 || dayRemainder > 0) { + return [days.toString(), "days"] + } else if (months > 0) { + return [(months + years*12).toString(), "months"]; + } else { + return [years.toString(), "years"]; + } +} + +export function rateTermToDays(value: string, unit: string): number { + const valueNum = parseInt(value); + + switch (unit) { + case "months": + const years = Math.floor(valueNum / 12); + const months = valueNum % 12; + return years*365 + months*30; + case "years": + return valueNum * 365; + default: // case "days": + return valueNum; + } +} \ No newline at end of file diff --git a/web/hooks/useDocumentTitle.js b/web/hooks/useDocumentTitle.js new file mode 100644 index 00000000..5d3500f0 --- /dev/null +++ b/web/hooks/useDocumentTitle.js @@ -0,0 +1,20 @@ +import { createEffect, onCleanup } from "solid-js"; + +const SITE_NAME = "National CD Rateline"; + +export function createDocumentTitle(title) { + createEffect(() => { + const previousTitle = document.title; + const t = title(); + document.title = t ? `${t} | ${SITE_NAME}` : SITE_NAME; + + onCleanup(() => { + document.title = previousTitle; + }); + }); +} + +export function PageWithTitle(props) { + createDocumentTitle(() => props.route.title || props.route.label); + return props.children; +} diff --git a/web/kit/Accordion.tsx b/web/kit/Accordion.tsx new file mode 100644 index 00000000..95f8d7cc --- /dev/null +++ b/web/kit/Accordion.tsx @@ -0,0 +1,127 @@ +import { createSignal, createEffect, For, Show, JSXElement } from "solid-js"; +import { Icon } from "./Icons.tsx"; + +// Baseline Tailwind for the scoped .ui-accordion stack. The ui-* class +// names are kept so page-specific CSS overrides (e.g. sale.css, +// licensing.css) can continue to layer on top. + +const ROOT = "ui-accordion border border-neutral-200 rounded-default overflow-hidden"; +const ITEM = "ui-accordion-item border-b border-neutral-200 last:border-b-0"; +const TRIGGER = "ui-accordion-trigger flex items-center justify-between w-full py-3 px-4 text-left font-medium text-neutral-900 bg-neutral-50 cursor-pointer border-none transition-colors hover:bg-neutral-100 active:bg-neutral-200 focus:outline-hidden disabled:text-neutral-400 disabled:cursor-not-allowed disabled:bg-neutral-50"; +const TITLE = "ui-accordion-title flex-1"; +const CONTENT = "ui-accordion-content px-4 pb-4 text-neutral-700"; + +function iconCls(open: boolean): string { + return "ui-accordion-icon text-neutral-500 leading-none transition-transform duration-200" + + (open ? " rotate-180" : ""); +} + +interface AccordionItemProps { + startOpen?: boolean; + isOpen?: boolean; + disabled?: boolean; + title?: string; + children?: JSXElement; +} + +export function AccordionItem(props: AccordionItemProps) { + const getStartOpen = () => typeof props.startOpen === "function" ? (props.startOpen as () => boolean)() : !!props.startOpen; + const [isOpen, setIsOpen] = createSignal(getStartOpen()); + + createEffect(() => { + if (props.isOpen === undefined) return; + const v = typeof props.isOpen === "function" ? (props.isOpen as () => boolean)() : !!props.isOpen; + setIsOpen(v); + }); + + const isDisabled = () => typeof props.disabled === "function" ? (props.disabled as () => boolean)() : !!props.disabled; + const title = () => typeof props.title === "function" ? (props.title as () => string)() : props.title; + + const toggle = () => { + if (isDisabled()) return; + setIsOpen(!isOpen()); + }; + + return ( +
+ + +
+ {props.children} +
+
+
+ ); +} + +interface AccordionItemData { + title: string; + content: JSXElement; + disabled?: boolean; +} + +interface AccordionProps { + items?: AccordionItemData[]; +} + +export function Accordion(props: AccordionProps) { + const items = () => props.items || []; + return ( +
+ {(item) => ( + { + const t = item.title; + return typeof t === "function" ? (t as () => string)() : t; + })()} disabled={(() => { + const d = item.disabled; + return typeof d === "function" ? (d as () => boolean)() : !!d; + })()}> + {item.content} + + )} +
+ ); +} + +interface SingleAccordionProps { + items?: AccordionItemData[]; + startOpen?: number; +} + +export function SingleAccordion(props: SingleAccordionProps) { + const items = () => props.items || []; + const [openIndex, setOpenIndex] = createSignal(props.startOpen ?? 0); + + const toggleItem = (index: number) => { + setOpenIndex(openIndex() === index ? -1 : index); + }; + + return ( +
+ {(item, index) => ( +
+ + +
+ {item.content} +
+
+
+ )}
+
+ ); +} diff --git a/web/kit/Alerts.tsx b/web/kit/Alerts.tsx new file mode 100644 index 00000000..ea7ab0f3 --- /dev/null +++ b/web/kit/Alerts.tsx @@ -0,0 +1,44 @@ +import { JSXElement, Show } from "solid-js"; + +type AlertColor = "white" | "gray" | "blue" | "green" | "red" | "yellow"; + +interface AlertProps { + header?: string; + class?: string; + children: JSXElement; +} + +const BASE = "p-4 rounded-default shadow-xs border"; + +const COLORS: Record = { + white: "bg-white border-neutral-100", + gray: "bg-neutral-50 border-neutral-200", + blue: "bg-sky-50 border-sky-200", + green: "bg-green-50 border-green-200", + red: "bg-red-50 border-red-200", + yellow: "bg-yellow-50 border-yellow-200", +}; + +function alertClass(color: AlertColor, extra?: string): string { + return BASE + " " + COLORS[color] + (extra ? " " + extra : ""); +} + +function makeAlert(color: AlertColor) { + return function Alert(props: AlertProps) { + return ( +
+ +

{props.header}

+
+

{props.children}

+
+ ); + }; +} + +export const AlertWhite = makeAlert("white"); +export const AlertGray = makeAlert("gray"); +export const AlertBlue = makeAlert("blue"); +export const AlertGreen = makeAlert("green"); +export const AlertRed = makeAlert("red"); +export const AlertYellow = makeAlert("yellow"); diff --git a/web/kit/AutoTable.tsx b/web/kit/AutoTable.tsx new file mode 100644 index 00000000..f7d123b5 --- /dev/null +++ b/web/kit/AutoTable.tsx @@ -0,0 +1,4213 @@ +import { createSignal, createEffect, createMemo, onCleanup, untrack, on, For, Show, JSXElement, JSX } from "solid-js"; +import { PDFDocument, StandardFonts, rgb } from "pdf-lib"; +import { getDocument, GlobalWorkerOptions } from "pdfjs-dist"; +// The worker reads import.meta.url, so it must load from a real URL (not a +// Blob). The bundler's asset-url plugin emits it from frontend/vendor into +// wwwroot/vendor and resolves this import to its served URL. +import { FormInput, FormSelect, FormMultiSelect, FormMultiSelectTrigger, FormCombobox, FormLabel, type FormSelectOption } from "./Forms.tsx"; +import { DatePicker } from "./DatePicker.tsx"; +import {Icon} from "./Icons.tsx"; +import { Menu, MenuContent, MenuDivider, MenuItem, MenuSection, MenuTrigger } from "./Menu.tsx"; +import { Popover, PopoverContent, PopoverTrigger, HoverPopover, HoverPopoverTrigger, HoverPopoverContent } from "./Popovers.tsx"; +import { useFloatingContext } from "./Floating.tsx"; +import { BUTTON_COLOR_LIGHT_NEUTRAL, BUTTON_COLOR_PRIMARY, ButtonUI, ButtonLinkRed, SegmentedButtons } from "./Buttons.tsx"; +import { authFetch } from "../auth/useAuthFetch.js"; +import { compareRowsGeneric } from "./CellGrid.tsx"; +import { accessor, readAccessor, type MaybeAccessor } from "../utils/accessors.ts"; +import { formatNumber, formatDecimal } from "./Formatters.ts"; + +const pdfWorkerUrl = new URL("pdfjs-dist/build/pdf.worker.min.mjs", import.meta.url).toString(); + +if (!GlobalWorkerOptions.workerSrc) { + GlobalWorkerOptions.workerSrc = pdfWorkerUrl; +} + +export type ColumnPosition = 0 | 1 | 2; +export type AutoTableHeaderColor = 0 | 1 | 2 | 3 | 4; +export type AutoTableSize = 0 | 1 | 2; +export type PDFOrientation = 0 | 1; + +// Predefined spreadsheet-style functions for calculated columns. A fixed +// enumeration whose behavior is obvious and reviewable: "sum/average/min/max/ +// count" ignore blank/non-numeric operands (like Excel's aggregate functions); +// "subtract/multiply/divide" require every operand to resolve to a number, +// otherwise the cell shows `emptyValue`. The "custom" function instead evaluates +// an Excel-style `formula` expression (see compileFormula). +export type CalculatedFunction = + | "sum" + | "subtract" + | "multiply" + | "divide" + | "average" + | "median" + | "mode" + | "min" + | "max" + | "count"; + +// How a calculated result is formatted for display/CSV/PDF. +export type CalculatedDataType = "plain" | "number" | "integer" | "decimal" | "money" | "percent"; + +// A user-created calculated column, configured at runtime through the UI and +// (optionally) persisted to localStorage. For predefined functions, `operands` +// reference other columns by key — a data column's sortIdentifier, or +// "_calc_" to reference another calculated column. For fn "custom", +// `formula` holds an Excel-style expression that references columns as +// [Display Name] (see compileFormula). +export interface UserCalculatedColumn { + id: string; + displayName: string; + fn: CalculatedFunction | "custom"; + operands: string[]; + formula?: string; + dataType?: CalculatedDataType; + precision?: number; + displayPosition?: ColumnPosition; // header + cell alignment (default right) +} + +// A user-created footer summary line (Total, Subtotal, …). Unlike a calculated +// column, the formula is evaluated ONCE (no current row), so [cell] refs are NaN +// and only whole-column aggregates like SUM({Revenue}) are meaningful. The single +// result is shown right-aligned in the footer (rightmost column), with `label` to +// its left. +export interface UserSummaryRow { + id: string; + label: string; + // Basic mode: a predefined fn aggregating `operands[0]` down the rows. + // Advanced mode: fn "custom" with an Excel-style `formula`. + fn?: CalculatedFunction | "custom"; + operands?: string[]; + formula?: string; + dataType?: CalculatedDataType; + precision?: number; +} + +const CALC_DATATYPE_OPTIONS: { value: CalculatedDataType; label: string }[] = [ + { value: "number", label: "Number" }, + { value: "integer", label: "Integer" }, + { value: "decimal", label: "Decimal" }, + { value: "money", label: "Money" }, + { value: "percent", label: "Percent" }, + { value: "plain", label: "Plain" }, +]; + +// Basic-mode functions. For a calculated COLUMN these combine the chosen columns +// per row (column-based); binary ones (subtract/divide) take two ordered columns, +// the rest take a multi-select. For a SUMMARY they aggregate one column down the +// rows (row-based), so only the aggregates apply. +const CALC_FUNCTION_OPTIONS: { value: string; label: string }[] = [ + { value: "sum", label: "Sum" }, + { value: "subtract", label: "Subtract" }, + { value: "multiply", label: "Multiply" }, + { value: "divide", label: "Divide" }, + { value: "average", label: "Average (mean)" }, + { value: "median", label: "Median" }, + { value: "mode", label: "Mode" }, + { value: "min", label: "Minimum" }, + { value: "max", label: "Maximum" }, + { value: "count", label: "Count" }, +]; +const SUMMARY_FUNCTION_OPTIONS: { value: string; label: string }[] = [ + { value: "sum", label: "Sum" }, + { value: "average", label: "Average (mean)" }, + { value: "median", label: "Median" }, + { value: "mode", label: "Mode" }, + { value: "min", label: "Minimum" }, + { value: "max", label: "Maximum" }, + { value: "count", label: "Count" }, +]; +const isBinaryCalcFn = (fn: string): boolean => fn === "subtract" || fn === "divide"; + +// Functions offered by the editor's "Function" menu. Names must match the +// formula engine (see makeFormulaFunction); each is inserted as NAME() with the +// caret placed between the parentheses. +const FORMULA_FUNCTION_GROUPS: { label: string; fns: { name: string; sig: string; desc: string }[] }[] = [ + { label: "Aggregate", fns: [ + { name: "SUM", sig: "SUM(range)", desc: "Total of the values" }, + { name: "AVERAGE", sig: "AVERAGE(range)", desc: "Mean of the values" }, + { name: "MEDIAN", sig: "MEDIAN(range)", desc: "Middle value" }, + { name: "MODE", sig: "MODE(range)", desc: "Most frequent value" }, + { name: "MIN", sig: "MIN(range)", desc: "Smallest value" }, + { name: "MAX", sig: "MAX(range)", desc: "Largest value" }, + { name: "COUNT", sig: "COUNT(range)", desc: "How many numbers" }, + ] }, + { label: "Math", fns: [ + { name: "ABS", sig: "ABS(n)", desc: "Absolute value" }, + { name: "ROUND", sig: "ROUND(n, digits)", desc: "Round to digits" }, + { name: "FLOOR", sig: "FLOOR(n)", desc: "Round down" }, + { name: "CEILING", sig: "CEILING(n)", desc: "Round up" }, + { name: "SQRT", sig: "SQRT(n)", desc: "Square root" }, + { name: "POWER", sig: "POWER(n, p)", desc: "n to the power p" }, + { name: "MOD", sig: "MOD(n, d)", desc: "Remainder of n ÷ d" }, + { name: "EXP", sig: "EXP(n)", desc: "e to the power n" }, + { name: "LN", sig: "LN(n)", desc: "Natural log (base e)" }, + { name: "LOG", sig: "LOG(n, base)", desc: "Log, base 10 by default" }, + ] }, + { label: "Trigonometry", fns: [ + { name: "SIN", sig: "SIN(angle)", desc: "Sine (radians)" }, + { name: "COS", sig: "COS(angle)", desc: "Cosine (radians)" }, + { name: "TAN", sig: "TAN(angle)", desc: "Tangent (radians)" }, + { name: "ASIN", sig: "ASIN(n)", desc: "Inverse sine" }, + { name: "ACOS", sig: "ACOS(n)", desc: "Inverse cosine" }, + { name: "ATAN", sig: "ATAN(n)", desc: "Inverse tangent" }, + { name: "ATAN2", sig: "ATAN2(x, y)", desc: "Angle of point (x, y)" }, + { name: "SINH", sig: "SINH(n)", desc: "Hyperbolic sine" }, + { name: "COSH", sig: "COSH(n)", desc: "Hyperbolic cosine" }, + { name: "TANH", sig: "TANH(n)", desc: "Hyperbolic tangent" }, + { name: "PI", sig: "PI()", desc: "π constant" }, + { name: "RADIANS", sig: "RADIANS(deg)", desc: "Degrees → radians" }, + { name: "DEGREES", sig: "DEGREES(rad)", desc: "Radians → degrees" }, + ] }, + { label: "Logic", fns: [ + { name: "IF", sig: "IF(test, then, else)", desc: "Choose by condition" }, + { name: "AND", sig: "AND(a, b, …)", desc: "True if all are true" }, + { name: "OR", sig: "OR(a, b, …)", desc: "True if any are true" }, + { name: "NOT", sig: "NOT(a)", desc: "Negate" }, + ] }, + { label: "Row", fns: [ + { name: "ROW", sig: "ROW()", desc: "Current row number" }, + ] }, +]; + +// Named mathematical constants — written bare in a formula (e.g. 2 * PI * [r]). +const FORMULA_CONSTANTS: Record = { + PI: Math.PI, + E: Math.E, + TAU: Math.PI * 2, + PHI: (1 + Math.sqrt(5)) / 2, + SQRT2: Math.SQRT2, +}; +const FORMULA_CONSTANT_OPTIONS: { name: string; desc: string }[] = [ + { name: "PI", desc: "π ≈ 3.14159" }, + { name: "E", desc: "Euler's number ≈ 2.71828" }, + { name: "TAU", desc: "2π ≈ 6.28319" }, + { name: "PHI", desc: "Golden ratio ≈ 1.61803" }, + { name: "SQRT2", desc: "√2 ≈ 1.41421" }, +]; + + +function makeCalcColumnId(): string { + return "c" + Date.now().toString(36) + Math.floor(Math.random() * 0x1000000).toString(36); +} + +export interface CalculatedColumnSpec { + fn: CalculatedFunction | "custom"; + // Operands are either a row field key (its raw value is parsed as a number, + // tolerating "$", ",", "%" and whitespace) or a numeric literal constant. + operands: (string | number)[]; + dataType?: CalculatedDataType; // drives default formatting + // Default display formatting (ignored when `format` is supplied): + precision?: number; // fixed number of decimal places + prefix?: string; // e.g. "$" + suffix?: string; // e.g. "%" + emptyValue?: string; // shown when the result isn't a finite number (default "—") + // Full override of display/CSV/PDF text. Receives the computed number (which + // may be NaN/Infinity) and the row. + format?: (result: number, item: any) => any; + // Overrides operand/fn computation. User columns set this to resolve + // calc-in-calc references and custom formulas; see computeCalculatedValue. + compute?: (item: any) => number; +} + +export interface AutoTableColumn { + displayName: string; + displayPosition?: ColumnPosition; + sortable?: boolean; + sortIdentifier?: string; + // sortType controls the local-sort comparator (see compareRowsGeneric): + // "numeric" sorts by leading digits then suffix (so "4" < "33", "4NY" < "4X"), + // "money" by parsed float, otherwise locale string compare. Only applies to + // client-side (local) sorting; server-side sorting is driven by the backend. + sortType?: string; + // sortValue extracts the value to compare for this column when sorting + // locally (e.g. a computed column). Defaults to row[sortIdentifier]. + sortValue?: (item: any) => unknown; + headerClasses?: string; + csv?: boolean; + csvValue?: (item: any) => any; + toggleable?: boolean; + hiddenByDefault?: boolean; + // Marks this as a calculated column: AutoTable renders, exports, and sorts + // its value itself from `calculated`, so your cellRenderer does NOT handle + // it. Calculated columns require `cellRenderer` (not `rowRenderer`) because + // the table needs per-cell control to inject the computed value, and are + // evaluated client-side only. See computeCalculatedValue. + calculated?: CalculatedColumnSpec; +} + +export interface PDFBelowTableContext { + page: any; + y: number; + pageWidth: number; + pageHeight: number; + margin: number; + contentWidth: number; + font: any; + fontBold: any; + colors: { text: any; muted: any; border: any }; + response: any; + pdf: any; + addPage: () => { page: any; y: number }; +} + +export interface AutoTablePDFHeader { + title?: string; + subtitle?: string; + showDate?: boolean; + logoUrl?: string; + showLogo?: boolean; + orientation?: PDFOrientation; + belowTable?: (ctx: PDFBelowTableContext) => void | Promise; +} + +export interface AutoTableOptions { + size?: AutoTableSize; + shadow?: boolean; + hover?: boolean; + alternate?: boolean; + headerBorderY?: boolean; + surroundingBorder?: boolean; + borderX?: boolean; + borderY?: boolean; + color?: AutoTableHeaderColor; + tableLayoutAuto?: boolean; + hidePagination?: boolean; + paginationShowAll?: boolean; + draggableColumns?: boolean; + columnOrderStorageKey?: string; + toggleColumns?: boolean; + columnVisibilityStorageKey?: string; + // Let users drag the right edge of a column header to resize it. Works best + // with the default fixed table layout (not tableLayoutAuto). + resizableColumns?: boolean; + // Persist user-set column widths (px, keyed by column) to localStorage. + columnWidthStorageKey?: string; + // Show an in-table Reset menu to restore column order/widths/calculated + // columns (only the resets for enabled features are listed). + resetButton?: boolean; + exportCSV?: boolean; + exportFilename?: string; + accordion?: boolean; + accordionSingle?: boolean; + pdfHeader?: AutoTablePDFHeader; + // Render `searchFields` as a card to the left of the table instead of inline + // above it. The toolbar (export, column toggles, custom actions) stays on top. + searchAside?: boolean; + /** Place export/column toggles at the end of the inline filter row (desktop). */ + inlineToolbar?: boolean; + // Let end users add/edit/remove their own calculated columns at runtime via + // a toolbar button. Calculated columns are appended after the data columns, + // computed client-side from the table's numeric columns. + calculatedColumns?: boolean; + // Persist user-created calculated columns to localStorage under this key. + calculatedColumnsStorageKey?: string; + // Let end users add/edit/remove footer summary lines (Total, Subtotal, …) at + // runtime via a toolbar button. Each is a formula evaluated once over the + // filtered rows, shown in a aligned under the chosen column. + summaryRows?: boolean; + // Persist user-created summary rows to localStorage under this key. + summaryRowsStorageKey?: string; + // When true, the Export button opens a popover (instead of the menu) that + // lets the user edit otherwise developer-fixed export settings — file name, + // PDF title/subtitle, orientation, show date/logo — before downloading. + userCustomizeExport?: boolean; +} + +export interface AutoTableSearchEntry { + Identifier: string; + Values: string[]; + Exact?: boolean; +} + +export interface AutoTablePagination { + Disabled?: boolean; + CurrentPage: number; + NextPage?: number; + PreviousPage?: number; + TotalPages: number; + TotalItems: number; + MaxItemsPerPage: number; + ItemsThisPage?: number; + ViewRangeLower: number; + ViewRangeUpper: number; +} + +export interface AutoTableOrderBy { + Identifier: string; + Descending: boolean; +} + +export interface AutoTableFilter { + Pagination: AutoTablePagination; + OrderBy: AutoTableOrderBy; + Search: AutoTableSearchEntry[]; +} + +export interface SearchFieldsContext { + getSearchValue: (identifier: string) => string; + setSearchValue: (identifier: string, value: string) => void; + setSearchValueExact: (identifier: string, value: string) => void; + getSearchValues: (identifier: string) => string[]; + setSearchValues: (identifier: string, values: string[]) => void; + getMultiSearchValue: (fields: string[]) => string; + setMultiSearchValue: (fields: string[], value: string) => void; + resetColumnOrder: () => void; +} + +export interface ToolbarActionsContext { + allFilteredData: () => any[]; +} + +export interface AutoTableProps { + url?: string; + data?: any[]; + remoteFiltering?: boolean; + requireAuth?: boolean; + // Reading this prop inside the initial remote-fetch effect lets a parent force + // a reload (e.g. after creating a row) by changing the tracked value. Passed as + // a signal accessor, wrapProps unwraps it to its current value here — so we read + // the property reactively rather than calling it. + refreshSignal?: unknown; + columns: AutoTableColumn[]; + options?: AutoTableOptions; + initialFilter?: Partial; + accordionKey?: string; + searchFields?: (ctx: SearchFieldsContext) => any; + aboveTable?: any; + belowTable?: any; + emptyMessage?: string | (() => string); + rowRenderer?: (item: any, position: number, rowIdx: number) => any; + cellRenderer?: (item: any, col: AutoTableColumn, colIdx: number, position: number, rowIdx: number) => any; + accordionRenderer?: (item: any, accordionData: any) => any; + toolbarActions?: (ctx: ToolbarActionsContext) => any; + /** Extra items rendered inside the Export dropdown (e.g. QuickBooks CSV). */ + exportMenuItems?: (ctx: ToolbarActionsContext) => any; + // Predicate that flags one or more rows for highlighting. When the + // returned predicate matches an item that lives on a different page than + // the one currently displayed, the table jumps to that page so the row is + // visible. Re-evaluated whenever the predicate function reference changes. + highlightMatch?: (item: any) => boolean; + // Called whenever the effective filter (search/sort/pagination) changes, so + // a parent can build features off the current query without re-deriving it + // (e.g. fetching the full result set to seed an "open as queue" action). + onFilterChange?: (filter: AutoTableFilter) => void; +} + +export interface AutoTableSearchProps { + label: string; + placeholder?: string; + value: string; + onchange: (value: string) => void; + class?: string; +} + +export interface AutoTableMultiSearchProps { + label?: string; + placeholder?: string; + value: string; + onchange: (value: string) => void; + class?: string; +} + +const MULTI_SEARCH_PREFIX = "_multi_"; + +export function AutoTableSearch(props: AutoTableSearchProps) { + const fieldCls = () => props.class || AUTOTABLE_SEARCH_FIELD; + return
+ + props.onchange(e.currentTarget.value)} + /> +
; +} + +export interface AutoTableDateSearchProps { + label: string; + identifier?: string; + getSearchValue?: (identifier: string) => string; + value?: MaybeAccessor; + onchange: (value: string) => void; + placeholder?: string; + class?: string; +} + +export function AutoTableDateSearch(props: AutoTableDateSearchProps) { + const liveValue = () => { + if (props.identifier != null && props.getSearchValue) { + return props.getSearchValue(props.identifier); + } + return readAccessor(props.value, ""); + }; + + const fieldCls = () => props.class || AUTOTABLE_DATE_SEARCH_FIELD; + return ( +
+ + +
+ ); +} + +export function AutoTableMultiSearch(props: AutoTableMultiSearchProps) { + return
+ + + + props.onchange(e.currentTarget.value)} + /> +
; +} + +function createMultiSearchIdentifier(fields: string[]): string { + return MULTI_SEARCH_PREFIX + fields.join(","); +} + +function isMultiSearchIdentifier(identifier: string): boolean { + return identifier.startsWith(MULTI_SEARCH_PREFIX); +} + +function parseMultiSearchFields(identifier: string): string[] { + if (!isMultiSearchIdentifier(identifier)) return []; + return identifier.slice(MULTI_SEARCH_PREFIX.length).split(","); +} + +export const COL_POS_LEFT: ColumnPosition = 0; +export const COL_POS_RIGHT: ColumnPosition = 1; +export const COL_POS_CENTER: ColumnPosition = 2; + +// Alignment picker options for the calculated-column editor (value is the +// stringified ColumnPosition; declared after the COL_POS_* consts). +const CALC_POSITION_OPTIONS: { value: string; label: string }[] = [ + { value: String(COL_POS_LEFT), label: "Left" }, + { value: String(COL_POS_CENTER), label: "Center" }, + { value: String(COL_POS_RIGHT), label: "Right" }, +]; + +export const AUTOTABLE_HEADER_COLOR_DEFAULT = 0; +export const AUTOTABLE_HEADER_COLOR_BLUE = 1; +export const AUTOTABLE_HEADER_COLOR_GREEN = 2; +export const AUTOTABLE_HEADER_COLOR_GRAY = 3; +export const AUTOTABLE_HEADER_COLOR_DARK_BLUE = 4; + +export const AUTOTABLE_SIZE_DEFAULT: AutoTableSize = 0; +export const AUTOTABLE_SIZE_COMPACT: AutoTableSize = 1; +export const AUTOTABLE_SIZE_SUPERCOMPACT: AutoTableSize = 2; + +export const PDF_ORIENTATION_LANDSCAPE: PDFOrientation = 0; +export const PDF_ORIENTATION_PORTRAIT: PDFOrientation = 1; + +// -- Tailwind class maps --------------------------------------------- +// Background + text colors per header color. +export const HEADER_COLOR_CLS: Record = { + [AUTOTABLE_HEADER_COLOR_DEFAULT]: "bg-neutral-50", + [AUTOTABLE_HEADER_COLOR_BLUE]: "bg-sky-700 text-white", + [AUTOTABLE_HEADER_COLOR_GREEN]: "bg-green-700 text-white", + [AUTOTABLE_HEADER_COLOR_GRAY]: "bg-neutral-600 text-white", + [AUTOTABLE_HEADER_COLOR_DARK_BLUE]: "bg-sky-900 text-white", +}; + +// Sortable hover override per color (applied when `sortable`). +const HEADER_SORT_HOVER_CLS: Record = { + [AUTOTABLE_HEADER_COLOR_DEFAULT]: "hover:bg-neutral-300", + [AUTOTABLE_HEADER_COLOR_BLUE]: "hover:bg-sky-500", + [AUTOTABLE_HEADER_COLOR_GREEN]: "hover:bg-green-800", + [AUTOTABLE_HEADER_COLOR_GRAY]: "hover:bg-neutral-500", + [AUTOTABLE_HEADER_COLOR_DARK_BLUE]: "hover:bg-sky-800", +}; + +// Header text weight/case styling per color. +export const HEADER_TEXT_CLS: Record = { + [AUTOTABLE_HEADER_COLOR_DEFAULT]: "font-bold uppercase tracking-wider", + [AUTOTABLE_HEADER_COLOR_BLUE]: "font-semibold", + [AUTOTABLE_HEADER_COLOR_GREEN]: "font-semibold", + [AUTOTABLE_HEADER_COLOR_GRAY]: "font-semibold", + [AUTOTABLE_HEADER_COLOR_DARK_BLUE]: "font-semibold", +}; + +// Drag-handle color (muted variant of header color). +const HEADER_DRAG_CLS: Record = { + [AUTOTABLE_HEADER_COLOR_DEFAULT]: "text-neutral-500", + [AUTOTABLE_HEADER_COLOR_BLUE]: "text-white/70", + [AUTOTABLE_HEADER_COLOR_GREEN]: "text-white/70", + [AUTOTABLE_HEADER_COLOR_GRAY]: "text-white/70", + [AUTOTABLE_HEADER_COLOR_DARK_BLUE]: "text-white/70", +}; + +// Sort icon color (same as header text color). +const HEADER_SORT_ICON_CLS: Record = { + [AUTOTABLE_HEADER_COLOR_DEFAULT]: "text-black", + [AUTOTABLE_HEADER_COLOR_BLUE]: "text-white", + [AUTOTABLE_HEADER_COLOR_GREEN]: "text-white", + [AUTOTABLE_HEADER_COLOR_GRAY]: "text-white", + [AUTOTABLE_HEADER_COLOR_DARK_BLUE]: "text-white", +}; + +// Th cell padding per table size. +export const HEADER_PADDING_CLS: Record = { + [AUTOTABLE_SIZE_DEFAULT]: "p-4 text-sm", + [AUTOTABLE_SIZE_COMPACT]: "py-1.5 px-2 text-sm", + [AUTOTABLE_SIZE_SUPERCOMPACT]: "py-1 px-2 text-xs", +}; + +// Body cell padding per table size — applied via [&_td]: on the tbody. +export const BODY_PADDING_CLS: Record = { + [AUTOTABLE_SIZE_DEFAULT]: "text-sm [&_td]:p-4", + [AUTOTABLE_SIZE_COMPACT]: "text-sm [&_td]:py-1 [&_td]:px-2", + [AUTOTABLE_SIZE_SUPERCOMPACT]: "text-xs [&_td]:py-0.5 [&_td]:px-2", +}; + +// Pagination padding per table size. +const PAGINATION_PADDING_CLS: Record = { + [AUTOTABLE_SIZE_DEFAULT]: "py-3 px-4", + [AUTOTABLE_SIZE_COMPACT]: "py-1 px-4", + [AUTOTABLE_SIZE_SUPERCOMPACT]: "py-1 px-4", +}; + +// Hover background per color (applied to body rows when `hover` is on). +const ROW_HOVER_CLS: Record = { + [AUTOTABLE_HEADER_COLOR_DEFAULT]: "hover:bg-neutral-200", + [AUTOTABLE_HEADER_COLOR_BLUE]: "hover:bg-sky-100", + [AUTOTABLE_HEADER_COLOR_GREEN]: "hover:bg-green-100", + [AUTOTABLE_HEADER_COLOR_GRAY]: "hover:bg-neutral-200", + [AUTOTABLE_HEADER_COLOR_DARK_BLUE]: "hover:bg-sky-100", +}; + +// Alignment per ColumnPosition. +export const POS_CLS: Record = { + [COL_POS_LEFT]: "text-left", + [COL_POS_RIGHT]: "text-right", + [COL_POS_CENTER]: "text-center", +}; + +// Header inner flex direction per position. +export const HEADER_INNER_POS: Record = { + [COL_POS_LEFT]: "", + [COL_POS_RIGHT]: "flex-row-reverse", + [COL_POS_CENTER]: "justify-center", +}; + +// -- Class string constants for parts that don't vary by config -- +export const TBL_CONTAINER = "relative flex flex-col w-full h-full bg-white rounded-default overflow-hidden"; +export const TBL_WRAPPER = "overflow-x-auto w-full"; +export const TBL_BASE = "min-w-full"; + +export const HEADER_CONTENT = "transition-transform duration-150 ease-in-out"; +export const HEADER_INNER_BASE = "flex justify-between gap-2 items-center"; +const DRAG_HANDLE = "opacity-40 shrink-0"; +const SORT_ICON_WRAP = "leading-none shrink-0 opacity-50"; + +const ACCORDION_TOGGLE_TH = "w-10 text-center text-neutral-400"; +const ACCORDION_TOGGLE_TD = "w-10 text-center text-neutral-400"; +const ACCORDION_ICON = "inline-block transition-transform duration-200"; + +const SKELETON = "h-4 bg-neutral-200 rounded-default animate-pulse"; +const ERROR_CELL = "text-center text-red-600"; +const EMPTY_CELL = "text-center text-neutral-500"; + +const PAGINATION_BASE = "flex justify-between items-center border-t border-neutral-300"; +const PAGINATION_INFO = "hidden sm:flex items-center text-sm text-neutral-500"; +const PAGINATION_CONTROLS = "flex items-center"; +const PAGINATION_LABEL = "hidden sm:block text-sm text-neutral-500 mr-2"; +const PAGINATION_PAGE = "text-sm text-neutral-500 px-3"; +const PAGINATION_BTN = "p-1 min-h-9 text-sm font-normal leading-none bg-transparent border-0 cursor-pointer hover:bg-neutral-100 disabled:text-neutral-300 disabled:cursor-not-allowed disabled:hover:bg-transparent"; + +const TOOLBAR = "min-w-0 max-w-full mb-1.5 overflow-visible"; +const TOOLBAR_MOBILE_BAR = "flex flex-col items-stretch gap-2 mb-2 min-w-0 max-w-full lg:hidden overflow-visible"; +const TOOLBAR_DESKTOP_ROW = "flex flex-col gap-3 min-w-0 max-w-full lg:flex-row lg:flex-wrap lg:items-end lg:gap-4 overflow-visible"; +const TOOLBAR_ACTIONS = "flex flex-wrap items-center gap-2 min-w-0 max-w-full shrink-0 lg:flex-nowrap lg:ml-auto lg:flex-[0_0_auto] lg:justify-end"; +const TOOLBAR_ACTIONS_INLINE = "flex flex-wrap items-center gap-2 min-w-0 shrink-0 lg:flex-nowrap lg:flex-[0_0_auto] lg:justify-end"; + +const PANEL_ACTIONS_BASE = "w-full min-w-0 max-lg:flex max-lg:flex-wrap max-lg:items-center max-lg:justify-end max-lg:gap-2 max-lg:pt-2.5 max-lg:mt-0.5 max-lg:border-t max-lg:border-neutral-200"; +const PANEL_ACTIONS_INLINE = PANEL_ACTIONS_BASE + " lg:flex lg:ml-auto lg:shrink-0 lg:flex-[0_0_auto] lg:self-end lg:w-auto lg:border-t-0 lg:pt-0 lg:mt-0"; +const PANEL_ACTIONS_DEFAULT = PANEL_ACTIONS_BASE + " lg:contents"; +// Mirrors a small light-neutral ButtonUI for use as a bare PopoverTrigger +// + + } + + +
No columns match.
+
+ + + + ; + + const functionMenu = () => + Function + +
+ { funcSearchInput = el; }} type="text" spellcheck="false" + placeholder="Search functions…" value={funcSearch()} + oninput={(e: InputEvent & { currentTarget: HTMLInputElement }) => setFuncSearch(e.currentTarget.value)} + class={MENU_SEARCH} /> +
+
+ + {(g: { label: string; fns: { name: string; sig: string; desc: string }[] }) =>
+
{g.label}
+ + {(f: { name: string; sig: string; desc: string }) => } + +
} +
+ +
No functions match.
+
+
+
+
; + + const constantMenu = () => + Constant + +
+ { constSearchInput = el; }} type="text" spellcheck="false" + placeholder="Search constants…" value={constSearch()} + oninput={(e: InputEvent & { currentTarget: HTMLInputElement }) => setConstSearch(e.currentTarget.value)} + class={MENU_SEARCH} /> +
+
+ + {(c: { name: string; desc: string }) => } + + +
No constants match.
+
+
+
+
; + + const advancedEditor = () =>
+
+
{ overlay = el; }} class={FORMULA_OVERLAY_CLS} aria-hidden="true">
+ +
+
+ {columnMenu()} + {functionMenu()} + {constantMenu()} +
+
; + + return
+
+ Formula + + + + + + +
+
References
+
+ [Col]this row's cell + {"{Col}"}the whole column + {"{Col:n}"}value in row n + {"{Col:a:b}"}rows a through b +
+
+
+
Operators
+
+ {"+ - * /"}add, subtract, multiply, divide + ^power (a to the b) + %remainder (modulo) + {"= <> < > <= >="}compare (yields 1 or 0) +
+
+
+
+
+ setMode(v === "advanced")} /> +
+ + {advancedEditor()} + +
; +} + +interface CalculatedColumnFormProps { + column: () => UserCalculatedColumn | null; // null = add mode + operandOptions: () => FormSelectOption[]; + onSave: (col: UserCalculatedColumn) => void; + onRemove?: (id: string) => void; + // Highlight columns in the table while editing (operand keys, or null/[]). + onHighlight?: (keys: string[] | null) => void; +} + +// Editor for a calculated column, rendered inside a Popover. Reads the floating +// context to prefill when the popover opens and to close itself on save/cancel. +// A column is defined by an Excel-style formula (see compileFormula); the insert +// chips reference other columns as [Col]/{Col}, including other calculated +// columns (but never this one). Saved columns always have fn "custom". +function CalculatedColumnForm(props: CalculatedColumnFormProps) { + const ctx = useFloatingContext(); + const [name, setName] = createSignal(""); + const [spec, setSpec] = createSignal({ fn: "sum", operands: [] as string[], formula: "" } as FormulaSpec); + const [dataType, setDataType] = createSignal("number" as CalculatedDataType); + const [decimals, setDecimals] = createSignal(""); + const [position, setPosition] = createSignal(String(COL_POS_RIGHT)); + const [errorMsg, setErrorMsg] = createSignal(""); + let formulaApi: FormulaFieldApi | undefined; + + // wrapProps may deliver these as the unwrapped value or as a function. + const column = () => typeof props.column === "function" ? (props.column as () => UserCalculatedColumn | null)() : (props.column as unknown as UserCalculatedColumn | null); + const allOptions = () => typeof props.operandOptions === "function" ? (props.operandOptions as () => FormSelectOption[])() : (props.operandOptions as unknown as FormSelectOption[]); + // Exclude this column from its own reference choices. + const operandOptions = () => { + const ed = column(); + const selfRef = ed ? "_calc_" + ed.id : null; + return allOptions().filter(o => o.value !== selfRef); + }; + const highlight = (keys: string[] | null) => props.onHighlight && props.onHighlight(keys); + + // Prefill the form each time the popover opens (FormulaField is seeded via its api). + createEffect(() => { + if (!ctx.isOpen()) return; + untrack(() => { + const ed = column(); + setName(ed?.displayName ?? ""); + setDataType(ed?.dataType ?? "number"); + setDecimals(ed?.precision != null ? String(ed.precision) : ""); + setPosition(String(ed?.displayPosition ?? COL_POS_RIGHT)); + setErrorMsg(""); + formulaApi?.setSpec({ fn: ed?.fn ?? (ed?.formula ? "custom" : "sum"), operands: ed?.operands ?? [], formula: ed?.formula ?? "" }); + }); + }); + + // Live formula validation feedback (Advanced/custom only). + const formulaError = createMemo(() => { + const s = spec(); + if (s.fn !== "custom" || !s.formula.trim()) return ""; + try { compileFormula(s.formula); return ""; } + catch (e) { return e instanceof Error ? e.message : "Invalid formula"; } + }); + + const close = () => ctx.setIsOpen(false); + + const handleSave = () => { + const trimmedName = name().trim(); + if (!trimmedName) { setErrorMsg("Enter a column name."); return; } + const s = spec(); + let operands: string[] = []; + let formulaText: string | undefined; + if (s.fn === "custom") { + formulaText = s.formula.trim(); + if (!formulaText) { setErrorMsg("Enter a formula."); return; } + try { compileFormula(formulaText); } + catch (e) { setErrorMsg(e instanceof Error ? e.message : "Invalid formula"); return; } + } else { + operands = s.operands.filter(Boolean); + if (isBinaryCalcFn(s.fn)) { if (operands.length < 2) { setErrorMsg("Pick both columns."); return; } } + else if (operands.length < 1) { setErrorMsg("Pick at least one column."); return; } + } + const ed = column(); + const decTrimmed = decimals().trim(); + const precision = decTrimmed === "" ? undefined : parseInt(decTrimmed, 10); + const col: UserCalculatedColumn = { + id: ed?.id ?? makeCalcColumnId(), + displayName: trimmedName, + fn: s.fn as CalculatedFunction | "custom", + operands, + formula: formulaText, + dataType: dataType(), + precision: (precision === undefined || Number.isNaN(precision)) ? undefined : precision, + displayPosition: parseInt(position(), 10) as ColumnPosition, + }; + // Close before onSave: editing replaces this column's object, which + // disposes this popover's row, so closing afterward would target a + // disposed floating context. + close(); + props.onSave(col); + }; + + const handleRemove = () => { + const ed = column(); + close(); + if (ed && props.onRemove) props.onRemove(ed.id); + }; + + return
+
{column() ? "Edit calculation" : "New calculation"}
+
+ Column Name + setName(e.currentTarget.value)} /> +
+
+ { formulaApi = api; }} /> + +

{formulaError()}

+
+
+
+
+ Data type + setDataType(e.currentTarget.value as CalculatedDataType)}> + + {(d: { value: string; label: string }) => } + + +
+
+ Decimals + setDecimals(e.currentTarget.value)} /> +
+
+ Alignment + setPosition(e.currentTarget.value)}> + + {(p: { value: string; label: string }) => } + + +
+
+ +

{errorMsg()}

+
+
+ + handleRemove()}>Remove + +
+ close()}>Cancel + handleSave()}>Save +
+
+
; +} + +interface SummaryRowFormProps { + row: () => UserSummaryRow | null; // null = add mode + operandOptions: () => FormSelectOption[]; // display-under columns + formula refs + onSave: (row: UserSummaryRow) => void; + onRemove?: (id: string) => void; + onHighlight?: (keys: string[] | null) => void; +} + +// Editor for a footer summary line (Total, Subtotal, …): a label and a single-value +// calculation (FormulaField). The value is always shown right-aligned in the footer. +// Mirrors CalculatedColumnForm's lifecycle (prefill on open, close before onSave). +function SummaryRowForm(props: SummaryRowFormProps) { + const ctx = useFloatingContext(); + const [label, setLabel] = createSignal(""); + const [spec, setSpec] = createSignal({ fn: "sum", operands: [] as string[], formula: "" } as FormulaSpec); + const [dataType, setDataType] = createSignal("number" as CalculatedDataType); + const [decimals, setDecimals] = createSignal(""); + const [errorMsg, setErrorMsg] = createSignal(""); + let formulaApi: FormulaFieldApi | undefined; + + const row = () => typeof props.row === "function" ? (props.row as () => UserSummaryRow | null)() : (props.row as unknown as UserSummaryRow | null); + const operandOptions = () => typeof props.operandOptions === "function" ? (props.operandOptions as () => FormSelectOption[])() : (props.operandOptions as unknown as FormSelectOption[]) || []; + const highlight = (keys: string[] | null) => props.onHighlight && props.onHighlight(keys); + + createEffect(() => { + if (!ctx.isOpen()) return; + untrack(() => { + const ed = row(); + setLabel(ed?.label ?? ""); + setDataType(ed?.dataType ?? "number"); + setDecimals(ed?.precision != null ? String(ed.precision) : ""); + setErrorMsg(""); + formulaApi?.setSpec({ fn: ed?.fn ?? (ed?.formula ? "custom" : "sum"), operands: ed?.operands ?? [], formula: ed?.formula ?? "" }); + }); + }); + + const formulaError = createMemo(() => { + const s = spec(); + if (s.fn !== "custom" || !s.formula.trim()) return ""; + try { compileFormula(s.formula); return ""; } + catch (e) { return e instanceof Error ? e.message : "Invalid formula"; } + }); + + const close = () => ctx.setIsOpen(false); + const handleSave = () => { + const trimmedLabel = label().trim(); + if (!trimmedLabel) { setErrorMsg("Enter a label."); return; } + const s = spec(); + let operands: string[] = []; + let formulaText: string | undefined; + if (s.fn === "custom") { + formulaText = s.formula.trim(); + if (!formulaText) { setErrorMsg("Enter a formula."); return; } + try { compileFormula(formulaText); } catch (e) { setErrorMsg(e instanceof Error ? e.message : "Invalid formula"); return; } + } else { + operands = s.operands.filter(Boolean); + if (operands.length < 1) { setErrorMsg("Pick a column."); return; } + } + const ed = row(); + const decTrimmed = decimals().trim(); + const precision = decTrimmed === "" ? undefined : parseInt(decTrimmed, 10); + const out: UserSummaryRow = { + id: ed?.id ?? makeCalcColumnId(), + label: trimmedLabel, + fn: s.fn as CalculatedFunction | "custom", + operands, + formula: formulaText, + dataType: dataType(), + precision: (precision === undefined || Number.isNaN(precision)) ? undefined : precision, + }; + close(); + props.onSave(out); + }; + const handleRemove = () => { const ed = row(); close(); if (ed && props.onRemove) props.onRemove(ed.id); }; + + return
+
{row() ? "Edit summary" : "New summary"}
+
+ Label + setLabel(e.currentTarget.value)} /> +
+
+ { formulaApi = api; }} /> + +

{formulaError()}

+
+
+
+
+ Data type + setDataType(e.currentTarget.value as CalculatedDataType)}> + + {(d: { value: string; label: string }) => } + + +
+
+ Decimals + setDecimals(e.currentTarget.value)} /> +
+
+ +

{errorMsg()}

+
+
+ + handleRemove()}>Remove + +
+ close()}>Cancel + handleSave()}>Save +
+
+
; +} + +interface AddCalcMenuProps { + allowColumn: boolean; + allowSummary: boolean; + operandOptions: () => FormSelectOption[]; + onSaveColumn: (col: UserCalculatedColumn) => void; + onSaveSummary: (row: UserSummaryRow) => void; + onHighlight?: (keys: string[] | null) => void; +} + +// Content of the single toolbar "Add Calculation" popover: a chooser listing the +// enabled options (calculated column and/or summary row), which then swaps to the +// chosen editor in the same popover (so both editors share this floating context). +// The chooser is always shown and resets when the popover closes. +function AddCalcMenu(props: AddCalcMenuProps) { + const ctx = useFloatingContext(); + const allowColumn = () => typeof props.allowColumn === "function" ? (props.allowColumn as () => boolean)() : props.allowColumn; + const allowSummary = () => typeof props.allowSummary === "function" ? (props.allowSummary as () => boolean)() : props.allowSummary; + // Forward as an accessor (a function) so the binding stays reactive — passing + // props.operandOptions directly would snapshot the array (losing new columns). + const operandOptions = () => typeof props.operandOptions === "function" ? (props.operandOptions as () => FormSelectOption[])() : (props.operandOptions as unknown as FormSelectOption[]); + const [view, setView] = createSignal("menu"); + createEffect(() => { if (!ctx.isOpen()) setView("menu"); }); + + const ITEM = "w-full text-left px-2 py-2 rounded-default hover:bg-neutral-100 cursor-pointer border-0 bg-transparent flex items-start gap-2.5"; + return <> + +
+ + + + + + +
+
+ + null} + operandOptions={operandOptions} + onSave={props.onSaveColumn} + onHighlight={props.onHighlight} /> + + + null} + operandOptions={operandOptions} + onSave={props.onSaveSummary} + onHighlight={props.onHighlight} /> + + ; +} + +/** + * Feature-rich data table with sorting, pagination, search, drag-to-reorder columns, + * column visibility toggle, CSV export, accordion rows, and user-defined + * calculated columns. + * Supports both local data and remote data fetching with optional server-side filtering. + */ +export function AutoTable(props: AutoTableProps) { + const isRemote = () => !!props.url; + const isLocalFiltering = () => !isRemote() || !props.remoteFiltering; + + const opts = createMemo(() => { + const merged = { + size: AUTOTABLE_SIZE_DEFAULT, + shadow: false, + hover: false, + alternate: false, + headerBorderY: false, + surroundingBorder: false, + borderX: false, + borderY: false, + color: AUTOTABLE_HEADER_COLOR_DEFAULT, + tableLayoutAuto: false, + hidePagination: false, + paginationShowAll: false, + draggableColumns: false, + columnOrderStorageKey: undefined, + toggleColumns: false, + columnVisibilityStorageKey: undefined, + resizableColumns: false, + columnWidthStorageKey: undefined, + resetButton: false, + exportCSV: false, + exportFilename: "export", + pdfHeader: undefined, + accordion: false, + accordionSingle: false, + searchAside: false, + calculatedColumns: false, + calculatedColumnsStorageKey: undefined, + summaryRows: false, + summaryRowsStorageKey: undefined, + userCustomizeExport: false, + ...props.options, + }; + if (merged.inlineToolbar === undefined && props.searchFields && !merged.searchAside) { + merged.inlineToolbar = !!( + merged.exportCSV + || merged.toggleColumns + || props.exportMenuItems + || props.toolbarActions + ); + } + return merged; + }); + + const accordionKey = () => props.accordionKey ?? "accordion"; + + const [expandedRows, setExpandedRows] = createSignal(new Set() as Set); + + const toggleAccordion = (rowKey: any) => { + setExpandedRows(prev => { + const next = new Set(prev); + if (next.has(rowKey)) { + next.delete(rowKey); + } else { + if (opts().accordionSingle) { + next.clear(); + } + next.add(rowKey); + } + return next; + }); + }; + + const [remoteData, setRemoteData] = createSignal([] as any[]); + const [initialLoading, setInitialLoading] = createSignal(isRemote()); + const [showLoading, setShowLoading] = createSignal(false); + const [error, setError] = createSignal(null as string | null); + const [isExporting, setIsExporting] = createSignal(false); + + // User-editable export settings (used when opts().userCustomizeExport). Seeded + // from the developer-provided defaults; persist for the table's lifetime. + const [exportFilenameInput, setExportFilenameInput] = createSignal(opts().exportFilename); + const [pdfTitle, setPdfTitle] = createSignal(opts().pdfHeader?.title ?? ""); + const [pdfSubtitle, setPdfSubtitle] = createSignal(opts().pdfHeader?.subtitle ?? ""); + const [pdfOrientation, setPdfOrientation] = createSignal(String(opts().pdfHeader?.orientation ?? PDF_ORIENTATION_LANDSCAPE)); + const [pdfShowDate, setPdfShowDate] = createSignal(opts().pdfHeader?.showDate ?? true); + + const effectiveExportFilename = () => opts().userCustomizeExport ? (exportFilenameInput().trim() || "export") : opts().exportFilename; + const effectivePdfHeader = (): AutoTablePDFHeader | undefined => { + if (!opts().userCustomizeExport) return opts().pdfHeader; + // showLogo and logoUrl stay developer-controlled (not user-editable). + return { + ...opts().pdfHeader, + title: pdfTitle(), + subtitle: pdfSubtitle(), + orientation: parseInt(pdfOrientation(), 10) as PDFOrientation, + showDate: pdfShowDate(), + }; + }; + + const [draggedColumn, setDraggedColumn] = createSignal(null as number | null); + const [dragOverColumn, setDragOverColumn] = createSignal(null as number | null); + const [draggedSummary, setDraggedSummary] = createSignal(null as number | null); + const [dragOverSummary, setDragOverSummary] = createSignal(null as number | null); + + // -- Resizable columns ---------------------------------------------------- + // Widths (px) keyed by the same stable keys as the order system + // ("d" for data columns, "_calc_" for calc columns). + const loadColumnWidths = (): Record => { + const key = opts().columnWidthStorageKey; + if (key) { + try { + const stored = localStorage.getItem(key); + if (stored) { + const parsed = JSON.parse(stored); + if (parsed && typeof parsed === "object") return parsed; + } + } catch { + // Ignore localStorage errors + } + } + return {}; + }; + const [columnWidths, setColumnWidths] = createSignal(loadColumnWidths()); + const [resizingColumn, setResizingColumn] = createSignal(null as string | null); + + createEffect(() => { + const key = opts().columnWidthStorageKey; + if (key && opts().resizableColumns) { + try { + localStorage.setItem(key, JSON.stringify(columnWidths())); + } catch { + // Ignore localStorage errors + } + } + }); + + const MIN_COLUMN_WIDTH = 56; + // Dragging a column boundary trades width between that column and its right + // neighbor, keeping the total constant. This keeps the table within its + // container (no runaway horizontal overflow) while still letting the user + // set the proportions — the intuitive spreadsheet "move the boundary" feel. + const startColumnResize = (e: MouseEvent, key: string) => { + e.preventDefault(); + e.stopPropagation(); + const th = (e.currentTarget as HTMLElement).closest("th") as HTMLElement | null; + const row = th?.parentElement; + const startX = e.clientX; + + // Pin EVERY column to its current rendered width so the layout is fully + // determined; without this the unsized columns reflow when one changes. + const snapshot: Record = { ...columnWidths() }; + const ths: HTMLElement[] = row + ? Array.from(row.querySelectorAll("th[data-colkey]")) as HTMLElement[] + : []; + ths.forEach((el) => { + const k = el.getAttribute("data-colkey"); + if (k) snapshot[k] = el.getBoundingClientRect().width; + }); + setColumnWidths(snapshot); + setResizingColumn(key); + + const startWidth = snapshot[key] ?? (th ? th.getBoundingClientRect().width : 120); + // The right neighbor absorbs the change (the last column has no handle, so + // a neighbor always exists). Falls back to a plain grow if none is found. + const idx = ths.findIndex((el) => el.getAttribute("data-colkey") === key); + const nextKey = idx >= 0 && idx + 1 < ths.length ? ths[idx + 1].getAttribute("data-colkey") : null; + const startNextWidth = nextKey ? (snapshot[nextKey] ?? 0) : 0; + + const onMove = (ev: MouseEvent) => { + const rawDelta = ev.clientX - startX; + if (nextKey) { + // Clamp so neither the dragged column nor its neighbor drops below + // the minimum; the pair's combined width never changes. + const delta = Math.max( + MIN_COLUMN_WIDTH - startWidth, + Math.min(startNextWidth - MIN_COLUMN_WIDTH, rawDelta), + ); + const newWidth = Math.round(startWidth + delta); + const newNext = startWidth + startNextWidth - newWidth; + setColumnWidths(prev => ({ ...prev, [key]: newWidth, [nextKey]: newNext })); + } else { + const newWidth = Math.max(MIN_COLUMN_WIDTH, Math.round(startWidth + rawDelta)); + setColumnWidths(prev => ({ ...prev, [key]: newWidth })); + } + }; + const onUp = () => { + document.removeEventListener("mousemove", onMove); + document.removeEventListener("mouseup", onUp); + setResizingColumn(null); + }; + document.addEventListener("mousemove", onMove); + document.addEventListener("mouseup", onUp); + }; + + // Inline width style for a header cell, or undefined when unset. + const columnWidthStyle = (key: string) => { + const w = columnWidths()[key]; + return w ? { width: w + "px" } : undefined; + }; + + + const getInitialVisibleColumns = () => { + if (opts().columnVisibilityStorageKey) { + try { + const stored = localStorage.getItem(opts().columnVisibilityStorageKey); + if (stored) { + const parsed = JSON.parse(stored); + if (Array.isArray(parsed)) { + return parsed; + } + } + } catch { + // Ignore localStorage errors + } + } + return props.columns + .map((col, i) => ({ col, i })) + .filter(({ col }) => !col.hiddenByDefault) + .map(({ i }) => i.toString()); + }; + const [visibleColumns, setVisibleColumns] = createSignal(getInitialVisibleColumns()); + + createEffect(on( + () => props.columns.length, + () => { + const allIndices = props.columns.map((_, i) => i.toString()); + const validVisible = visibleColumns().filter(v => allIndices.includes(v)); + const newColumns = allIndices.filter(i => !visibleColumns().includes(i) && !props.columns[parseInt(i)]?.hiddenByDefault); + if (newColumns.length > 0 || validVisible.length !== visibleColumns().length) { + setVisibleColumns([...validVisible, ...newColumns]); + } + } + )); + + createEffect(() => { + if (opts().columnVisibilityStorageKey && opts().toggleColumns) { + try { + localStorage.setItem(opts().columnVisibilityStorageKey, JSON.stringify(visibleColumns())); + } catch { + // Ignore localStorage errors + } + } + }); + + // -- User-created calculated columns ------------------------------------- + const loadUserCalcColumns = (): UserCalculatedColumn[] => { + const key = opts().calculatedColumnsStorageKey; + if (key) { + try { + const stored = localStorage.getItem(key); + if (stored) { + const parsed = JSON.parse(stored); + if (Array.isArray(parsed)) return parsed; + } + } catch { + // Ignore localStorage errors + } + } + return []; + }; + const [userCalcColumns, setUserCalcColumns] = createSignal(loadUserCalcColumns()); + + createEffect(() => { + const key = opts().calculatedColumnsStorageKey; + if (key && opts().calculatedColumns) { + try { + localStorage.setItem(key, JSON.stringify(userCalcColumns())); + } catch { + // Ignore localStorage errors + } + } + }); + + const CALC_REF_PREFIX = "_calc_"; + + // Lookups rebuilt whenever the set of calc columns changes. + const calcById = createMemo(() => { + const m = new Map(); + for (const c of userCalcColumns()) m.set(c.id, c); + return m; + }); + // Maps a referenceable display name (lowercased) to an operand key, so custom + // formulas can use [Display Name]. Data columns resolve to their + // sortIdentifier; calc columns to "_calc_". + const calcNameToRef = createMemo(() => { + const m = new Map(); + for (const col of props.columns) if (col.sortIdentifier) m.set(col.displayName.trim().toLowerCase(), col.sortIdentifier); + for (const c of userCalcColumns()) m.set(c.displayName.trim().toLowerCase(), CALC_REF_PREFIX + c.id); + return m; + }); + // Compile custom formulas once per change (not per row); invalid -> null. + const compiledFormulas = createMemo(() => { + const m = new Map(); + for (const c of userCalcColumns()) { + if (c.fn === "custom") { + try { m.set(c.id, compileFormula(c.formula ?? "")); } + catch { m.set(c.id, null); } + } + } + return m; + }); + + // Build the per-row resolvers a formula needs. `visiting` holds the chain of + // calc column ids being evaluated so cycles resolve to NaN instead of + // recursing forever. [refs] read the current row, {refs} the whole filtered + // column (both may point at other calc columns). + const makeCalcResolvers = (item: any, visiting: Set) => { + const resolveRef = (ref: string): number => { + if (ref.startsWith(CALC_REF_PREFIX)) { + const id = ref.slice(CALC_REF_PREFIX.length); + if (visiting.has(id)) return NaN; + const target = calcById().get(id); + if (!target) return NaN; + const next = new Set(visiting); next.add(id); + return computeUserColumn(target, item, next); + } + return toCalcNumber(item?.[ref]); + }; + const resolveColumn = (ref: string): number[] => { + const rows = aggregateRows(); + if (ref.startsWith(CALC_REF_PREFIX)) { + const id = ref.slice(CALC_REF_PREFIX.length); + if (visiting.has(id)) return []; + const target = calcById().get(id); + if (!target) return []; + const next = new Set(visiting); next.add(id); + return rows.map(r => computeUserColumn(target, r, next)); + } + return rows.map(r => toCalcNumber(r?.[ref])); + }; + const rowIndex = () => { const i = aggregateRows().indexOf(item); return i < 0 ? NaN : i + 1; }; + return { resolveRef, resolveColumn, rowIndex }; + }; + + // Evaluate a calc spec (a saved column or an unsaved draft) for one row. + const evalCalcSpec = (spec: { fn: UserCalculatedColumn["fn"]; operands: string[]; formula?: string }, compiled: FormulaNode | null, item: any, visiting: Set): number => { + const { resolveRef, resolveColumn, rowIndex } = makeCalcResolvers(item, visiting); + if (spec.fn === "custom") { + if (!compiled) return NaN; + const refs = calcNameToRef(); + const lookup = (name: string) => refs.get(name.trim().toLowerCase()); + return toScalar(compiled({ + cell: (name) => { const ref = lookup(name); return ref === undefined ? NaN : resolveRef(ref); }, + column: (name) => { const ref = lookup(name); return ref === undefined ? [] : resolveColumn(ref); }, + row: rowIndex, + })); + } + return applyCalcFunction(spec.fn, spec.operands.map(resolveRef)); + }; + + const computeUserColumn = (uc: UserCalculatedColumn, item: any, visiting: Set): number => + evalCalcSpec(uc, uc.fn === "custom" ? (compiledFormulas().get(uc.id) ?? null) : null, item, visiting); + + // Each user column becomes a regular AutoTableColumn carrying a `calculated` + // spec, so the existing render/sort/export paths handle it. The compute + // closure resolves calc-in-calc references and custom formulas; the synthetic + // sortIdentifier ("_calc_") lets processDataLocally find and sort it. + const userCalcToColumn = (uc: UserCalculatedColumn): AutoTableColumn => ({ + displayName: uc.displayName, + displayPosition: uc.displayPosition ?? COL_POS_RIGHT, + sortable: isLocalFiltering(), + sortIdentifier: CALC_REF_PREFIX + uc.id, + csv: true, + calculated: { + fn: uc.fn, + operands: [], + dataType: uc.dataType, + precision: uc.precision, + compute: (item: any) => computeUserColumn(uc, item, new Set([uc.id])), + }, + }); + const calcColumns = createMemo(() => userCalcColumns().map(userCalcToColumn)); + // props.columns plus user calc columns — used for local sorting and export + // so calculated columns participate in both. + const effectiveColumns = createMemo(() => [...props.columns, ...calcColumns()]); + + // Columns the user can reference as operands: data columns with a field key, + // plus other calculated columns (referenced as "_calc_"). + const calcOperandOptions = createMemo(() => { + const dataOpts = props.columns + .filter(c => !!c.sortIdentifier) + .map(c => ({ value: c.sortIdentifier!, label: c.displayName } as FormSelectOption)); + const calcOpts = userCalcColumns() + .map(c => ({ value: CALC_REF_PREFIX + c.id, label: c.displayName } as FormSelectOption)); + return [...dataOpts, ...calcOpts]; + }); + + const removeCalcColumn = (id: string) => setUserCalcColumns(prev => prev.filter(c => c.id !== id)); + const saveCalcColumn = (col: UserCalculatedColumn) => { + setUserCalcColumns(prev => { + const idx = prev.findIndex(c => c.id === col.id); + if (idx >= 0) { + const next = [...prev]; + next[idx] = col; + return next; + } + return [...prev, col]; + }); + }; + + // -- User-created footer summary rows ------------------------------------ + const loadUserSummaryRows = (): UserSummaryRow[] => { + const key = opts().summaryRowsStorageKey; + if (key) { + try { + const stored = localStorage.getItem(key); + if (stored) { const parsed = JSON.parse(stored); if (Array.isArray(parsed)) return parsed; } + } catch { /* ignore */ } + } + return []; + }; + const [userSummaryRows, setUserSummaryRows] = createSignal(loadUserSummaryRows()); + createEffect(() => { + const key = opts().summaryRowsStorageKey; + if (key && opts().summaryRows) { + try { localStorage.setItem(key, JSON.stringify(userSummaryRows())); } catch { /* ignore */ } + } + }); + const saveSummaryRow = (row: UserSummaryRow) => { + setUserSummaryRows((prev: UserSummaryRow[]) => { + const idx = prev.findIndex(r => r.id === row.id); + if (idx >= 0) { const next = [...prev]; next[idx] = row; return next; } + return [...prev, row]; + }); + }; + const removeSummaryRow = (id: string) => setUserSummaryRows((prev: UserSummaryRow[]) => prev.filter(r => r.id !== id)); + + // Evaluate a summary formula once (no current row) over the filtered rows, then + // format it. Reuses the column engine with item=null: [cell]/ROW() resolve to + // NaN, {Col} aggregates resolve against aggregateRows(). + const computeSummaryValue = (s: UserSummaryRow): string => { + const fn = s.fn ?? "custom"; + let n: number; + if (fn === "custom") { + let compiled: FormulaNode | null = null; + try { compiled = compileFormula(s.formula ?? ""); } catch { return "—"; } + n = evalCalcSpec({ fn: "custom", operands: [], formula: s.formula }, compiled, null, new Set()); + } else { + // Basic: aggregate the chosen column down the (filtered) rows. + const key = s.operands && s.operands[0]; + if (!key) return "—"; + const { resolveColumn } = makeCalcResolvers(null, new Set()); + n = applyCalcFunction(fn as CalculatedFunction, resolveColumn(key)); + } + return formatCalcResult(n, s.dataType, s.precision, undefined, undefined, "—"); + }; + // Computed summary lines for PDF export (label + formatted value). + const pdfSummaries = () => opts().summaryRows + ? userSummaryRows().map((s: UserSummaryRow) => ({ label: s.label, value: computeSummaryValue(s) })) + : []; + + + // Columns the calc editor highlights on hover (operand keys: a data column's + // sortIdentifier or "_calc_"). A header is lit when its sortIdentifier is + // in this set. + const [highlightedCols, setHighlightedCols] = createSignal([] as string[]); + const isColHighlighted = (col: AutoTableColumn) => !!col.sortIdentifier && highlightedCols().includes(col.sortIdentifier); + + // -- Column ordering (drag-to-reorder) ----------------------------------- + // Columns are ordered by stable string keys so data and calculated columns + // can interleave: data columns are "d", calc columns use their + // sortIdentifier ("_calc_"). Calc columns join the orderable list only + // when a cellRenderer is present (dragging needs per-cell control); with a + // rowRenderer they stay appended after the data columns and aren't draggable. + const DATA_COLUMN_KEY = (i: number) => "d" + i; + const calcMerged = () => !!props.cellRenderer; + const appendedCalcColumns = () => calcMerged() ? [] : calcColumns(); + + const allColumnKeys = createMemo(() => { + const dataKeys = props.columns.map((_, i) => DATA_COLUMN_KEY(i)); + if (!calcMerged()) return dataKeys; + return [...dataKeys, ...userCalcColumns().map(uc => CALC_REF_PREFIX + uc.id)]; + }); + + const columnByKey = createMemo(() => { + const m = new Map(); + props.columns.forEach((col, i) => m.set(DATA_COLUMN_KEY(i), { column: col, originalIndex: i })); + if (calcMerged()) calcColumns().forEach((col) => m.set(col.sortIdentifier!, { column: col, originalIndex: -1 })); + return m; + }); + + const getInitialColumnOrder = (): string[] => { + if (opts().columnOrderStorageKey) { + try { + const stored = localStorage.getItem(opts().columnOrderStorageKey!); + if (stored) { + const parsed = JSON.parse(stored); + if (Array.isArray(parsed) && parsed.every((x: any) => typeof x === "string")) { + return parsed; + } + } + } catch { + // Ignore localStorage errors (and the pre-key numeric format) + } + } + return allColumnKeys(); + }; + const [columnOrder, setColumnOrder] = createSignal(getInitialColumnOrder()); + + // Reconcile the saved order with the current column set: keep known keys in + // their order, append newly added columns, drop removed ones. Handles data + // column changes and calc columns being added/removed/reordered. + createEffect(() => { + const keys = allColumnKeys(); + const keySet = new Set(keys); + const current = columnOrder(); + const valid = current.filter(k => keySet.has(k)); + const missing = keys.filter(k => !current.includes(k)); + if (missing.length > 0 || valid.length !== current.length) { + setColumnOrder([...valid, ...missing]); + } + }); + + createEffect(() => { + if (opts().columnOrderStorageKey && opts().draggableColumns) { + try { + localStorage.setItem(opts().columnOrderStorageKey, JSON.stringify(columnOrder())); + } catch { + // Ignore localStorage errors + } + } + }); + + let loadingTimerRef: ReturnType | null = null; + let debounceTimerRef: ReturnType | null = null; + let fetchIdRef = 0; + + const [filter, setFilter] = createSignal({ + Pagination: { + Disabled: false, + CurrentPage: 1, + NextPage: 1, + PreviousPage: 1, + TotalPages: 1, + TotalItems: 0, + MaxItemsPerPage: opts().hidePagination ? -1 : 25, + ItemsThisPage: 0, + ViewRangeLower: 0, + ViewRangeUpper: 0, + }, + OrderBy: { + Identifier: "", + Descending: false, + }, + Search: [] as AutoTableSearchEntry[], + ...props.initialFilter, + } as AutoTableFilter); + + const [searchInputs, setSearchInputs] = createSignal(props.initialFilter?.Search || []); + const [searchGeneration, setSearchGeneration] = createSignal(0); + const bumpSearchGeneration = () => setSearchGeneration((g) => g + 1); + + const sourceData = createMemo(() => isRemote() ? remoteData() : (props.data || [])); + + // Rows that cross-row formula refs ({Column}) aggregate over: the filtered + // set (search applied, sort/pagination ignored). Independent of localProcessed + // so a calc column that aggregates can still be sorted without a cycle. For + // server-side filtering only the loaded page is available. + const aggregateRows = createMemo(() => isLocalFiltering() + ? applySearchFilters(sourceData(), filter().Search) + : remoteData()); + + const localProcessed = createMemo(() => { + if (!isLocalFiltering()) return null; + return processDataLocally(sourceData(), filter(), effectiveColumns()); + }); + + const displayData = createMemo(() => isLocalFiltering() ? (localProcessed()?.data || []) : remoteData()); + const allFilteredData = createMemo(() => isLocalFiltering() ? (localProcessed()?.allFilteredData || []) : remoteData()); + const displayPagination = createMemo(() => isLocalFiltering() + ? (localProcessed()?.pagination || filter().Pagination) + : filter().Pagination); + + createEffect(() => { + displayPagination().CurrentPage; + filter().OrderBy.Identifier; + filter().OrderBy.Descending; + filter().Search; + setExpandedRows(new Set()); + }); + + // When a highlightMatch predicate is supplied and the matching row lives + // on a different page than the one currently shown, jump to that page. + // + // The effect tracks only the predicate identity (caller is expected to + // memoize it - a new function ref means "new highlight target") and + // props.data (so we re-evaluate once the data finishes loading). All + // other AutoTable state - filter, allFilteredData, pagination - is read + // via untrack so user-driven page changes don't re-fire the jump and + // pin them to the highlighted page. + createEffect(() => { + const match = props.highlightMatch; + if (!match) return; + // Touch props.data so the effect re-runs when the dataset loads. + const data = props.data; + if (!data) return; + untrack(() => { + const all = allFilteredData(); + if (!all.length) return; + const idx = all.findIndex((it: any) => match(it)); + if (idx < 0) return; + const f = filter(); + const pageSize = f.Pagination.MaxItemsPerPage; + if (!pageSize || pageSize < 0) return; + const targetPage = Math.floor(idx / pageSize) + 1; + if (targetPage !== f.Pagination.CurrentPage) { + setFilter((prev: AutoTableFilter) => ({ + ...prev, + Pagination: { ...prev.Pagination, CurrentPage: targetPage }, + })); + } + }); + }); + + const fetchData = async (filterToUse: AutoTableFilter, isInitial = false) => { + if (!props.url) return; + + const currentFetchId = ++fetchIdRef; + setError(null); + + if (loadingTimerRef) { + clearTimeout(loadingTimerRef); + loadingTimerRef = null; + } + + if (!isInitial) { + loadingTimerRef = setTimeout(() => { + setShowLoading(true); + }, 500); + } + + const startTime = isInitial ? Date.now() : 0; + const minLoadingDuration = 300; + + try { + const fetchUrl = !props.remoteFiltering ? props.url : props.url + "?" + buildQueryString(filterToUse); + const response = props.requireAuth + ? await authFetch(fetchUrl) + : await fetch(fetchUrl); + + if (!response.ok) { + throw new Error("HTTP " + response.status + ": " + response.statusText); + } + + const result = await response.json(); + + if (currentFetchId !== fetchIdRef) return; + + if (isInitial) { + const elapsed = Date.now() - startTime; + if (elapsed < minLoadingDuration) { + await new Promise(resolve => setTimeout(resolve, minLoadingDuration - elapsed)); + } + } + + if (!props.remoteFiltering) { + const data = Array.isArray(result) ? result : (result.data || []); + setRemoteData(data); + } else { + setRemoteData(result.data || []); + setFilter((prev) => ({ + ...prev, + Pagination: result.filter.Pagination, + OrderBy: result.filter.OrderBy, + })); + } + } catch (err) { + if (currentFetchId !== fetchIdRef) return; + setError(err instanceof Error ? err.message : "Unknown error"); + } finally { + if (currentFetchId === fetchIdRef) { + if (loadingTimerRef) { + clearTimeout(loadingTimerRef); + loadingTimerRef = null; + } + setInitialLoading(false); + setShowLoading(false); + } + } + }; + + createEffect(() => { + void props.refreshSignal; + if (isRemote()) { + fetchData(untrack(() => filter()), true); + } + }); + + createEffect(() => { + if (!props.onFilterChange) return; + props.onFilterChange(filter()); + }); + + const triggerFetch = (newFilter: AutoTableFilter) => { + if (debounceTimerRef) { + clearTimeout(debounceTimerRef); + debounceTimerRef = null; + } + fetchData(newFilter); + }; + + const handleSort = (identifier: string) => { + if (isLocalFiltering()) { + setFilter((prev) => ({ + ...prev, + OrderBy: { + Identifier: identifier, + Descending: prev.OrderBy.Identifier === identifier ? !prev.OrderBy.Descending : false, + }, + Pagination: { ...prev.Pagination, CurrentPage: 1 }, + })); + } else { + setFilter((prev) => { + const newFilter = { + ...prev, + OrderBy: { + Identifier: identifier, + Descending: prev.OrderBy.Identifier === identifier ? !prev.OrderBy.Descending : false, + }, + Pagination: { ...prev.Pagination, CurrentPage: 1 }, + }; + triggerFetch(newFilter); + return newFilter; + }); + } + }; + + const getSortIdentifier = (col: AutoTableColumn, colIndex: number): string | null => { + if (col.sortIdentifier) return col.sortIdentifier; + if (isLocalFiltering()) return "_col_" + colIndex; + return null; + }; + + const handlePageChange = (page: number) => { + if (isLocalFiltering()) { + setFilter((prev) => ({ + ...prev, + Pagination: { ...prev.Pagination, CurrentPage: page }, + })); + } else { + setFilter((prev) => { + const newFilter = { + ...prev, + Pagination: { ...prev.Pagination, CurrentPage: page }, + }; + triggerFetch(newFilter); + return newFilter; + }); + } + }; + + const handleItemsPerPageChange = (itemsPerPage: number) => { + if (isLocalFiltering()) { + setFilter((prev) => ({ + ...prev, + Pagination: { ...prev.Pagination, MaxItemsPerPage: itemsPerPage, CurrentPage: 1 }, + })); + } else { + setFilter((prev) => { + const newFilter = { + ...prev, + Pagination: { ...prev.Pagination, MaxItemsPerPage: itemsPerPage, CurrentPage: 1 }, + }; + triggerFetch(newFilter); + return newFilter; + }); + } + }; + + const handleSearch = (identifier: string, value: string, exact = false) => { + const updateSearchArray = (searchArray: AutoTableSearchEntry[]): AutoTableSearchEntry[] => { + const existingIndex = searchArray.findIndex(s => s.Identifier === identifier); + let updated: AutoTableSearchEntry[]; + if (existingIndex >= 0) { + updated = [...searchArray]; + updated[existingIndex] = { Identifier: identifier, Values: [value], Exact: exact }; + } else { + updated = [...searchArray, { Identifier: identifier, Values: [value], Exact: exact }]; + } + return updated.filter(s => s.Values.some(v => v)); + }; + + if (isLocalFiltering()) { + setFilter((prev) => ({ + ...prev, + Search: updateSearchArray(prev.Search), + Pagination: { ...prev.Pagination, CurrentPage: 1 }, + })); + } else { + const newSearchInputs = updateSearchArray(searchInputs()); + setSearchInputs(newSearchInputs); + + if (debounceTimerRef) clearTimeout(debounceTimerRef); + + debounceTimerRef = setTimeout(() => { + debounceTimerRef = null; + setFilter((prev) => { + const newFilter = { + ...prev, + Pagination: { ...prev.Pagination, CurrentPage: 1 }, + Search: updateSearchArray(prev.Search), + }; + fetchData(newFilter); + return newFilter; + }); + }, 150); + } + bumpSearchGeneration(); + }; + + const readSearchValue = (identifier: string): string => { + if (isLocalFiltering()) { + return filter().Search.find((s: AutoTableSearchEntry) => s.Identifier === identifier)?.Values[0] || ""; + } + return searchInputs().find((s: AutoTableSearchEntry) => s.Identifier === identifier)?.Values[0] || ""; + }; + + const getSearchValue = (identifier: string): string => { + searchGeneration(); + return readSearchValue(identifier); + }; + + const setSearchValue = (identifier: string, value: string) => { + handleSearch(identifier, value); + }; + + const setSearchValueExact = (identifier: string, value: string) => { + handleSearch(identifier, value, true); + }; + + const getSearchValues = (identifier: string): string[] => { + if (isLocalFiltering()) { + return filter().Search.find((s: AutoTableSearchEntry) => s.Identifier === identifier)?.Values || []; + } + return searchInputs().find((s: AutoTableSearchEntry) => s.Identifier === identifier)?.Values || []; + }; + + const setSearchValues = (identifier: string, values: string[]) => { + const updateSearchArray = (searchArray: AutoTableSearchEntry[]): AutoTableSearchEntry[] => { + const existingIndex = searchArray.findIndex(s => s.Identifier === identifier); + let updated: AutoTableSearchEntry[]; + if (existingIndex >= 0) { + updated = [...searchArray]; + updated[existingIndex] = { Identifier: identifier, Values: values }; + } else { + updated = [...searchArray, { Identifier: identifier, Values: values }]; + } + return updated.filter(s => s.Values.length > 0); + }; + + if (isLocalFiltering()) { + setFilter((prev) => ({ + ...prev, + Search: updateSearchArray(prev.Search), + Pagination: { ...prev.Pagination, CurrentPage: 1 }, + })); + } else { + const newSearchInputs = updateSearchArray(searchInputs()); + setSearchInputs(newSearchInputs); + + if (debounceTimerRef) clearTimeout(debounceTimerRef); + + debounceTimerRef = setTimeout(() => { + debounceTimerRef = null; + setFilter((prev) => { + const newFilter = { + ...prev, + Pagination: { ...prev.Pagination, CurrentPage: 1 }, + Search: updateSearchArray(prev.Search), + }; + fetchData(newFilter); + return newFilter; + }); + }, 150); + } + bumpSearchGeneration(); + }; + + const getMultiSearchValue = (fields: string[]): string => { + return readSearchValue(createMultiSearchIdentifier(fields)); + }; + + const setMultiSearchValue = (fields: string[], value: string) => { + handleSearch(createMultiSearchIdentifier(fields), value); + }; + + const getHeaderColorClass = () => HEADER_COLOR_CLS[opts().color]; + const getHeaderSortHover = () => HEADER_SORT_HOVER_CLS[opts().color]; + const getHeaderTextClass = () => HEADER_TEXT_CLS[opts().color]; + const getHeaderDragClass = () => HEADER_DRAG_CLS[opts().color]; + const getHeaderSortIconClass = () => HEADER_SORT_ICON_CLS[opts().color]; + const getHeaderPaddingClass = () => HEADER_PADDING_CLS[opts().size]; + const getBodyPaddingClass = () => BODY_PADDING_CLS[opts().size]; + const getPaginationPaddingClass = () => PAGINATION_PADDING_CLS[opts().size]; + const getRowHoverClass = () => opts().hover ? ROW_HOVER_CLS[opts().color] : ""; + + const handleDragStart = (e: DragEvent, orderIndex: number) => { + if (!opts().draggableColumns) return; + setDraggedColumn(orderIndex); + e.dataTransfer!.effectAllowed = "move"; + e.dataTransfer!.setData("text/plain", orderIndex.toString()); + }; + + const handleDragOver = (e: DragEvent, orderIndex: number) => { + if (!opts().draggableColumns || draggedColumn() === null) return; + e.preventDefault(); + e.dataTransfer!.dropEffect = "move"; + if (dragOverColumn() !== orderIndex) { + setDragOverColumn(orderIndex); + } + }; + + const handleDragLeave = () => { + setDragOverColumn(null); + }; + + const handleDrop = (e: DragEvent, targetOrderIndex: number) => { + if (!opts().draggableColumns || draggedColumn() === null) return; + e.preventDefault(); + + if (draggedColumn() !== targetOrderIndex) { + setColumnOrder(prev => { + const newOrder = [...prev]; + const [removed] = newOrder.splice(draggedColumn() as number, 1); + newOrder.splice(targetOrderIndex, 0, removed); + return newOrder; + }); + } + + setDraggedColumn(null); + setDragOverColumn(null); + }; + + const handleDragEnd = () => { + setDraggedColumn(null); + setDragOverColumn(null); + }; + + // -- Summary row reordering (drag-to-reorder, via the grip handle) -------- + const handleSummaryDragStart = (e: DragEvent, index: number) => { + setDraggedSummary(index); + e.dataTransfer!.effectAllowed = "move"; + e.dataTransfer!.setData("text/plain", index.toString()); + }; + const handleSummaryDragOver = (e: DragEvent, index: number) => { + if (draggedSummary() === null) return; + e.preventDefault(); + e.dataTransfer!.dropEffect = "move"; + if (dragOverSummary() !== index) setDragOverSummary(index); + }; + const handleSummaryDrop = (e: DragEvent, target: number) => { + if (draggedSummary() === null) return; + e.preventDefault(); + const from = draggedSummary() as number; + if (from !== target) { + setUserSummaryRows((prev: UserSummaryRow[]) => { + const next = [...prev]; + const [moved] = next.splice(from, 1); + next.splice(target, 0, moved); + return next; + }); + } + setDraggedSummary(null); + setDragOverSummary(null); + }; + const handleSummaryDragEnd = () => { setDraggedSummary(null); setDragOverSummary(null); }; + + const resetColumnOrder = () => { + setColumnOrder(allColumnKeys()); + }; + const resetColumnWidths = () => setColumnWidths({}); + const resetCalcColumns = () => setUserCalcColumns([]); + const resetSummaryRows = () => setUserSummaryRows([]); + const resetAll = () => { + resetCalcColumns(); + resetSummaryRows(); + resetColumnWidths(); + resetColumnOrder(); + }; + // Show the reset menu only when there's something resettable enabled. + const canReset = () => opts().resetButton && (opts().draggableColumns || opts().resizableColumns || opts().calculatedColumns || opts().summaryRows); + + const isLoading = () => initialLoading() || showLoading(); + + // The ordered, visible columns header & body iterate over. Built from the + // stable-key order (or natural order when not draggable); calc columns are + // included here only when merged (cellRenderer present). orderIndex is the + // position within the full order, so drag handlers splice columnOrder by it. + const displayColumnsWithIndices = createMemo(() => { + const byKey = columnByKey(); + const keys = opts().draggableColumns ? columnOrder() : allColumnKeys(); + const mapped = keys + .map((k, orderIdx) => { + const entry = byKey.get(k); + return entry ? { column: entry.column, originalIndex: entry.originalIndex, orderIndex: orderIdx, key: k } : null; + }) + .filter(Boolean) as { column: AutoTableColumn; originalIndex: number; orderIndex: number; key: string }[]; + + if (!opts().toggleColumns) return mapped; + + // Calc columns aren't part of the toggle set — always keep them. + return mapped.filter(({ column, originalIndex }) => + column.calculated || column.toggleable === false || visibleColumns().includes(originalIndex.toString())); + }); + + // Columns shown: those in the ordered list plus calc columns appended when + // not merged into the order (rowRenderer case). + const renderedColumnCount = () => displayColumnsWithIndices().length + appendedCalcColumns().length; + const totalColumnCount = () => renderedColumnCount() + (opts().accordion ? 1 : 0); + + // When every visible column has a pinned width, give the table an explicit + // px width equal to their sum. table-fixed then honors each width exactly, so + // resizing one column only moves that boundary (Excel-style) instead of + // reflowing the others; the table grows/scrolls as widths change. Returns + // null (→ min-w-full) until all columns are pinned, e.g. before first resize. + // The accordion toggle column is a fixed 40px (w-10). + const ACCORDION_TOGGLE_WIDTH = 40; + const pinnedTableWidth = createMemo(() => { + if (!opts().resizableColumns) return null; + const widths = columnWidths(); + if (Object.keys(widths).length === 0) return null; + let sum = opts().accordion ? ACCORDION_TOGGLE_WIDTH : 0; + for (const c of displayColumnsWithIndices()) { + const w = widths[c.key]; + if (w == null) return null; + sum += w; + } + for (const c of appendedCalcColumns()) { + const w = c.sortIdentifier ? widths[c.sortIdentifier] : undefined; + if (w == null) return null; + sum += w; + } + return sum; + }); + + const hasCalculatedColumns = createMemo(() => props.columns.some(c => !!c.calculated) || userCalcColumns().length > 0); + + // Calculated columns are rendered by AutoTable itself (the cellRenderer never + // sees them). Numeric results read best right-aligned, so default to that. + const renderCalculatedCell = (col: AutoTableColumn, item: any) => { + const pos = col.displayPosition ?? COL_POS_RIGHT; + return {formatCalculatedValue(item, col.calculated!)}; + }; + + const columnToggleOptions = createMemo(() => { + return props.columns + .map((col, i) => ({ value: i.toString(), label: col.displayName, toggleable: col.toggleable })) + .filter(opt => opt.toggleable !== false); + }); + + const handleExportCSV = async (): Promise => { + if (isExporting()) return; + setIsExporting(true); + + try { + const exportColumns = [...displayColumnsWithIndices().map(({ column }) => column), ...appendedCalcColumns()]; + + if (isLocalFiltering()) { + const filterWithoutPagination: AutoTableFilter = { + ...filter(), + Pagination: { ...filter().Pagination, MaxItemsPerPage: -1 }, + }; + const processed = processDataLocally(sourceData(), filterWithoutPagination, props.columns); + downloadCSV(processed.data, exportColumns, effectiveExportFilename()); + } else if (props.url && props.remoteFiltering) { + const exportFilter: AutoTableFilter = { + ...filter(), + Search: searchInputs(), + Pagination: { ...filter().Pagination, MaxItemsPerPage: -1, CurrentPage: 1 }, + }; + const fetchUrl = props.url + "?" + buildQueryString(exportFilter); + const response = props.requireAuth ? await authFetch(fetchUrl) : await fetch(fetchUrl); + + if (!response.ok) { + throw new Error("HTTP " + response.status + ": " + response.statusText); + } + + const result = await response.json(); + downloadCSV(result.data || [], exportColumns, effectiveExportFilename()); + } + } catch (err) { + console.error("Export failed:", err); + } finally { + setIsExporting(false); + } + }; + + const handleExportPDF = async () => { + if (isExporting()) return; + setIsExporting(true); + + try { + const exportColumns = [...displayColumnsWithIndices().map(({ column }) => column), ...appendedCalcColumns()]; + + if (isLocalFiltering()) { + const filterWithoutPagination = { + ...filter(), + Pagination: { ...filter().Pagination, MaxItemsPerPage: -1 }, + }; + const processed = processDataLocally(sourceData(), filterWithoutPagination, props.columns); + await downloadPDF(processed.data, exportColumns, effectiveExportFilename(), effectivePdfHeader(), null, pdfSummaries()); + } else if (props.url && props.remoteFiltering) { + const exportFilter = { + ...filter(), + Search: searchInputs(), + Pagination: { ...filter().Pagination, MaxItemsPerPage: -1, CurrentPage: 1 }, + }; + const fetchUrl = props.url + "?" + buildQueryString(exportFilter); + const resp = props.requireAuth ? await authFetch(fetchUrl) : await fetch(fetchUrl); + + if (!resp.ok) { + throw new Error("HTTP " + resp.status + ": " + resp.statusText); + } + + const result = await resp.json(); + await downloadPDF(result.data || [], exportColumns, effectiveExportFilename(), effectivePdfHeader(), result, pdfSummaries()); + } + } catch (err) { + console.error("Export failed:", err); + } finally { + setIsExporting(false); + } + }; + + const handlePrintPDF = async () => { + if (isExporting()) return; + setIsExporting(true); + + try { + const exportColumns = [...displayColumnsWithIndices().map(({ column }) => column), ...appendedCalcColumns()]; + let pdfBytes = null; + + if (isLocalFiltering()) { + const filterWithoutPagination = { + ...filter(), + Pagination: { ...filter().Pagination, MaxItemsPerPage: -1 }, + }; + const processed = processDataLocally(sourceData(), filterWithoutPagination, props.columns); + pdfBytes = await buildTablePDF(processed.data, exportColumns, effectivePdfHeader(), null, pdfSummaries()); + } else if (props.url && props.remoteFiltering) { + const exportFilter = { + ...filter(), + Search: searchInputs(), + Pagination: { ...filter().Pagination, MaxItemsPerPage: -1, CurrentPage: 1 }, + }; + const fetchUrl = props.url + "?" + buildQueryString(exportFilter); + const resp = props.requireAuth ? await authFetch(fetchUrl) : await fetch(fetchUrl); + + if (!resp.ok) { + throw new Error("HTTP " + resp.status + ": " + resp.statusText); + } + + const result = await resp.json(); + pdfBytes = await buildTablePDF(result.data || [], exportColumns, effectivePdfHeader(), result, pdfSummaries()); + } + + if (pdfBytes) { + const blob = new Blob([pdfBytes as BlobPart], { type: "application/pdf" }); + const url = URL.createObjectURL(blob); + const iframe = document.createElement("iframe"); + iframe.style.cssText = "position:fixed;right:0;bottom:0;width:1px;height:1px;border:none;opacity:0;pointer-events:none"; + iframe.src = url; + iframe.onload = () => { + iframe.contentWindow!.focus(); + iframe.contentWindow!.print(); + }; + document.body.appendChild(iframe); + } + } catch (err) { + console.error("PDF print failed:", err); + } finally { + setIsExporting(false); + } + }; + + const showExportMenu = () => opts().exportCSV || !!props.exportMenuItems; + + const showToolbar = () => opts().toggleColumns || showExportMenu() || opts().calculatedColumns || opts().summaryRows || !!props.toolbarActions; + // Actions are only "on the filter row" when a filter row exists. Without + // searchFields there's nothing to push them right, so fall through to + // TOOLBAR_ACTIONS (lg:ml-auto) instead of the inline variant and keep the + // toolbar (e.g. a lone Export button) right-aligned. + const actionsOnFilterRow = () => !!props.searchFields && (opts().inlineToolbar || showToolbar()); + const toolbarActionsCls = () => actionsOnFilterRow() ? TOOLBAR_ACTIONS_INLINE : TOOLBAR_ACTIONS; + const desktopRowCls = () => TOOLBAR_DESKTOP_ROW; + const panelActionsCls = () => actionsOnFilterRow() ? PANEL_ACTIONS_INLINE : PANEL_ACTIONS_DEFAULT; + + const resolveExportMenuItems = () => { + const items = props.exportMenuItems; + if (items == null) return null; + return typeof items === "function" ? items({ allFilteredData }) : items; + }; + + const toolbarButtons = () => showToolbar() ? ( +
+ {props.toolbarActions && props.toolbarActions({ allFilteredData })} + + + + + Add Calculation + + + setHighlightedCols(keys ?? [])} + /> + + + + + + + Columns + } + options={columnToggleOptions()} + value={visibleColumns()} + onchange={setVisibleColumns} + align="right" + minWidth={180} + showSelectAll={true} + searchable={true} + small={true} + /> + + + + + + Export Options + + +
+
Export settings
+
+ File name + setExportFilenameInput(e.currentTarget.value)} /> +
+
+ PDF title + setPdfTitle(e.currentTarget.value)} /> +
+
+ PDF subtitle + setPdfSubtitle(e.currentTarget.value)} /> +
+
+ Orientation + setPdfOrientation(e.currentTarget.value)}> + + + +
+ +
+
+
+
+ + + + + + Export + + + + + Spreadsheet + + + handleExportCSV()} + disabled={isExporting()} + > + {isExporting() ? "Exporting..." : "Export CSV"} + + + + {resolveExportMenuItems()} + + + PDF + handleExportPDF()} + disabled={isExporting()} + > + {isExporting() ? "Exporting..." : "Export PDF"} + + handlePrintPDF()} + disabled={isExporting()} + > + {isExporting() ? "Loading..." : "Print PDF"} + + + + + +
+ ) : null; + + // Main render + + const searchFieldsCtx: SearchFieldsContext = { + getSearchValue, setSearchValue, setSearchValueExact, + getSearchValues, setSearchValues, getMultiSearchValue, + setMultiSearchValue, resetColumnOrder, + }; + + const [filtersOpen, setFiltersOpen] = createSignal(false); + const activeFilterCount = createMemo(() => { + const searches = isLocalFiltering() ? filter().Search : searchInputs(); + return searches.filter((s: AutoTableSearchEntry) => s.Values.some(v => v)).length; + }); + const toggleFilters = () => setFiltersOpen(v => !v); + const inlineFiltersBodyCls = () => { + let cls = actionsOnFilterRow() ? FILTERS_BODY_INLINE : FILTERS_BODY_BASE + " " + FILTERS_BODY_DESKTOP_FLEX; + cls += filtersOpen() ? " max-lg:flex" : " max-lg:hidden"; + return cls; + }; + const asideFiltersBodyCls = () => + FILTERS_BODY_ASIDE + (filtersOpen() ? " max-lg:flex" : " max-lg:hidden") + " lg:flex"; + + const filtersToggleBtn = ; + + const toolbarPanelActions = () => showToolbar() ? ( +
+ {toolbarButtons()} +
+ ) : null; + + return
+ +
+
+ + {filtersToggleBtn} + +
+
+ +
+ {untrack(() => props.searchFields!(searchFieldsCtx))} + {toolbarPanelActions()} +
+
+ + + +
+
+
+
+ + + +
+ {props.aboveTable} +
+
+ + + + + + + + {({ column: col, originalIndex: originalIdx, orderIndex: orderIdx, key: colKey }, displayIdx) => { + const isDragging = () => draggedColumn() === orderIdx; + const isDragOver = () => dragOverColumn() === orderIdx && draggedColumn() !== orderIdx; + const pos = col.displayPosition ?? COL_POS_LEFT; + const posCls = POS_CLS[pos]; + const headerInnerPosCls = HEADER_INNER_POS[pos]; + + return ; + }} + + + {(uc: UserCalculatedColumn, calcIdx) => { + const sortId = "_calc_" + uc.id; + const sortable = () => isLocalFiltering(); + const pos = uc.displayPosition ?? COL_POS_RIGHT; + const isLastColumn = () => calcIdx() === userCalcColumns().length - 1; + return ; + }} + + + + + + + {(_, rowIdx) => + + + + column), ...appendedCalcColumns()]}> + {() => } + + } + + + + + + + + + + + + + 0 && displayData().length === 0}> + + + + + 0 && displayData().length > 0}> + + {(item, rowIdx) => { + const position = () => (displayPagination().CurrentPage - 1) * displayPagination().MaxItemsPerPage + rowIdx() + 1; + const isLastRow = () => rowIdx() === displayData().length - 1; + const rowKey = () => item.id ?? rowIdx(); + const isExpanded = () => opts().accordion && expandedRows().has(rowKey()); + const isHighlighted = () => !!(props.highlightMatch && props.highlightMatch(item)); + return [ + { + let c = ""; + if (isHighlighted()) { + // Highlight overrides alternate + hover with !important. + c += "bg-amber-100! [&>td:first-child]:shadow-[inset_3px_0_0_var(--color-amber-500)] "; + } else { + if (opts().alternate && rowIdx() % 2 === 1) c += "bg-neutral-100 "; + c += getRowHoverClass() + " "; + } + if (isExpanded() || (opts().borderX && !isLastRow())) c += "border-b border-neutral-300 "; + if (opts().accordion) c += "cursor-pointer select-none "; + return c.trim(); + })()} + onclick={opts().accordion ? () => toggleAccordion(rowKey()) : undefined} + > + + + + + + {({ column: col, originalIndex: originalIdx }) => { + if (col.calculated) return renderCalculatedCell(col, item); + return props.cellRenderer!(item, col, originalIdx, position(), rowIdx()); + }} + + + + {(col: AutoTableColumn) => renderCalculatedCell(col, item)} + + , + + + + + + ]; + }} + + + + 0}> + + + {(s: UserSummaryRow, sIdx: () => number) => { + const value = () => computeSummaryValue(s); + // Always right-aligned: the value sits in the last column and the label + // in the cell immediately to its left (or they share the one cell in a + // single-column table). All other cells stay empty. + const cols = () => [...displayColumnsWithIndices().map((e: { column: AutoTableColumn }) => e.column), ...appendedCalcColumns()]; + const valueIdx = () => cols().length - 1; + const labelIdx = () => { const v = valueIdx(); return v > 0 ? v - 1 : v; }; + return handleSummaryDragOver(e, sIdx())} + onDragLeave={() => setDragOverSummary(null)} + onDrop={(e: DragEvent) => handleSummaryDrop(e, sIdx())}> + + + {(_col: AutoTableColumn, idx: () => number) => { + const isLbl = () => idx() === labelIdx(); + const isVal = () => idx() === valueIdx(); + const isFirst = () => idx() === 0; + return ; + }} + + ; + }} + + + +
+ + +   handleDragStart(e, orderIdx)} + onDragOver={(e) => handleDragOver(e, orderIdx)} + onDragLeave={handleDragLeave} + onDrop={(e) => handleDrop(e, orderIdx)} + onDragEnd={handleDragEnd} + style={columnWidthStyle(colKey)} + class={getHeaderPaddingClass() + " " + getHeaderColorClass() + + (opts().resizableColumns ? " relative" : "") + + (posCls ? " " + posCls : "") + + (opts().headerBorderY && displayIdx() > 0 ? " border-l border-l-neutral-300" : "") + + (col.sortable ? " cursor-pointer" : "") + + (col.sortable && !resizingColumn() ? " " + getHeaderSortHover() : "") + + (opts().draggableColumns ? " select-none cursor-grab active:cursor-grabbing" : "") + + (isDragOver() ? "outline-2 outline-sky-500 -outline-offset-2" : "") + + (isColHighlighted(col) ? "outline-2 -outline-offset-2 outline-amber-400" : "") + + (col.headerClasses ? " " + col.headerClasses : "")} + onclick={() => { + if (col.sortable) { + const sortId = getSortIdentifier(col, originalIdx); + if (sortId) handleSort(sortId); + } + }} + > + +
startColumnResize(e, colKey)} + onclick={(e: MouseEvent) => e.stopPropagation()}>
+
+
+
+ + + + + + + e.stopPropagation()}> + + + + + + calcById().get((col.sortIdentifier || "").slice(CALC_REF_PREFIX.length)) ?? null} + operandOptions={calcOperandOptions} + onSave={saveCalcColumn} + onRemove={removeCalcColumn} + onHighlight={(keys: string[] | null) => setHighlightedCols(keys ?? [])} + /> + + + + +
+ {col.displayName} +
+ +
+ + }> + + + +
+
+
+
+
{ if (sortable()) handleSort(sortId); }}> + +
startColumnResize(e, sortId)} + onclick={(e: MouseEvent) => e.stopPropagation()}>
+
+
+
+ e.stopPropagation()}> + + + + + + uc} + operandOptions={calcOperandOptions} + onSave={saveCalcColumn} + onRemove={removeCalcColumn} + onHighlight={(keys: string[] | null) => setHighlightedCols(keys ?? [])} + /> + + + +
+ {uc.displayName} +
+ +
+ + }> + + + +
+
+
+
+
+
+
+ Error: {error()} +
+ No columns selected. +
+ {(typeof props.emptyMessage === "function" ? props.emptyMessage() : props.emptyMessage) || "No entries found."} +
+ + + +
+ {props.accordionRenderer!(item, item[accordionKey()])} +
+ 1}> + + + +
+ + + + {s.label} + + + + s} + operandOptions={calcOperandOptions} + onSave={saveSummaryRow} + onRemove={removeSummaryRow} + onHighlight={(keys: string[] | null) => setHighlightedCols(keys ?? [])} /> + + + + + {value()} + +
+
+
+
+ + +
+
+ +
+ + + + + Reset + + + + + resetColumnOrder()}>Reset column order + + + resetColumnWidths()}>Reset column widths + + + resetCalcColumns()}>Reset calculations + + + resetSummaryRows()}>Reset summaries + + + resetAll()}>Reset all + + +
+
+
+ + + + + + {displayPagination().ViewRangeLower}-{displayPagination().ViewRangeUpper} of {displayPagination().TotalItems} + + +
+
+ +
+ +
+ Items per page: +
+ handleItemsPerPageChange(parseInt(e.currentTarget.value))} + > + + + + + + + + + + + handlePageChange(1)} + disabled={displayPagination().CurrentPage <= 1} + > + + + handlePageChange(displayPagination().CurrentPage - 1)} + disabled={displayPagination().CurrentPage <= 1} + > + + + +
+ Page {displayPagination().CurrentPage} of {displayPagination().TotalPages} +
+ + handlePageChange(displayPagination().CurrentPage + 1)} + disabled={displayPagination().CurrentPage >= displayPagination().TotalPages} + > + + + handlePageChange(displayPagination().TotalPages)} + disabled={displayPagination().CurrentPage >= displayPagination().TotalPages} + > + + +
+
+
+
+
+ {props.belowTable} +
+
+
; +} + + +function PaginationButton(props: JSX.ButtonHTMLAttributes) { + return ; +} + +interface TdProps { + class?: string; + children: any; +} + +export function TdLeft(props: TdProps) { + return {props.children}; +} + +export function TdRight(props: TdProps) { + return {props.children}; +} + +export function TdCenter(props: TdProps) { + return {props.children}; +} + +export function TdUniformLeft(props: TdProps) { + return {props.children}; +} + +export function TdUniformRight(props: TdProps) { + return {props.children}; +} + +export function TdUniformCenter(props: TdProps) { + return {props.children}; +} + +export default AutoTable; diff --git a/web/kit/Badges.tsx b/web/kit/Badges.tsx new file mode 100644 index 00000000..6f8601cb --- /dev/null +++ b/web/kit/Badges.tsx @@ -0,0 +1,53 @@ +import { JSXElement } from "solid-js"; + +export const BADGE_GREEN = "green"; +export const BADGE_RED = "red"; +export const BADGE_BLUE = "blue"; +export const BADGE_AMBER = "amber"; +export const BADGE_NEUTRAL = "neutral"; +export const BADGE_MUTED = "muted"; + +const BASE = "inline-flex items-center gap-1 text-xs font-semibold py-0.5 px-2 rounded-default whitespace-nowrap"; + +const COLORS: Record = { + "green": "text-white bg-green-700", + "red": "text-white bg-red-700", + "blue": "text-white bg-sky-800", + "amber": "text-white bg-amber-700", + "neutral": "text-white bg-neutral-500", + "muted": "text-neutral-400 bg-transparent", +}; + +interface BadgeProps { + color?: string; + pill?: boolean; + // When provided, the badge renders as a ` + ); + } + + return {props.children}; +} diff --git a/web/kit/Buttons.tsx b/web/kit/Buttons.tsx new file mode 100644 index 00000000..f01574a9 --- /dev/null +++ b/web/kit/Buttons.tsx @@ -0,0 +1,191 @@ +import { JSX, JSXElement } from "solid-js"; +import { Icon } from "./Icons.tsx"; + +export const BUTTON_COLOR_NEUTRAL = "neutral"; +export const BUTTON_COLOR_WHITE = "white"; +export const BUTTON_COLOR_LIGHT_NEUTRAL = "light-neutral"; +export const BUTTON_COLOR_BLUE = "blue"; +export const BUTTON_COLOR_DARK_BLUE = "dark-blue"; +export const BUTTON_COLOR_GREEN = "green"; +export const BUTTON_COLOR_DARK_GREEN = "dark-green"; +export const BUTTON_COLOR_RED = "red"; +export const BUTTON_COLOR_DARK_RED = "dark-red"; +export const BUTTON_COLOR_YELLOW = "yellow"; +export const BUTTON_COLOR_ORANGE = "orange"; +export const BUTTON_COLOR_PRIMARY = "primary"; + +const BASE = "inline-flex items-center justify-center gap-2 cursor-pointer text-sm font-normal rounded-default transition disabled:opacity-50 disabled:cursor-not-allowed focus-visible:outline-2 focus-visible:outline-current focus-visible:outline-offset-2"; + +const COLORS: Record = { + "neutral": "shadow-xs bg-neutral-700 text-white hover:bg-neutral-800", + "white": "shadow-xs bg-white text-black border border-neutral-300 hover:bg-neutral-50", + "light-neutral": "shadow-xs bg-neutral-50 text-black border border-neutral-300 hover:bg-neutral-100", + "blue": "shadow-xs bg-sky-700 text-white hover:bg-sky-800", + "dark-blue": "shadow-xs bg-sky-900 text-white hover:bg-sky-950", + "green": "shadow-xs bg-green-700 text-white hover:bg-green-800", + "dark-green": "shadow-xs bg-green-900 text-white hover:bg-green-950", + "red": "shadow-xs bg-red-700 text-white hover:bg-red-800", + "dark-red": "shadow-xs bg-red-900 text-white hover:bg-red-950", + "yellow": "shadow-xs bg-yellow-700 text-white hover:bg-yellow-800", + "orange": "shadow-xs bg-orange-600 text-white hover:bg-orange-700", + "primary": "shadow-xs bg-primary text-white hover:bg-primary-hover", + "secondary": "shadow-none bg-neutral-100 text-neutral-700 border border-neutral-300 hover:bg-neutral-200", + "ghost": "shadow-none bg-transparent text-neutral-600 border-none hover:bg-neutral-100", +}; + +const OUTLINE_COLORS: Record = { + "neutral": "text-neutral-700", + "white": "text-neutral-300", + "light-neutral": "text-neutral-300", + "blue": "text-sky-700", + "dark-blue": "text-sky-900", + "green": "text-green-700", + "dark-green": "text-green-900", + "red": "text-red-700", + "dark-red": "text-red-900", + "yellow": "text-yellow-700", + "orange": "text-orange-600", + "primary": "text-primary", +}; + +const OUTLINE_BASE = "bg-transparent shadow-[inset_0_0_0_1px_currentColor] hover:shadow-[inset_0_0_0_2px_currentColor]"; + +interface ButtonUIProps extends JSX.ButtonHTMLAttributes { + text?: string; + icon?: unknown; + outline?: boolean; + color?: string; + small?: boolean; +} + +export function ButtonUI(props: ButtonUIProps) { + const hasText = () => props.text !== undefined ? !!props.text : !props.icon; + + const cls = () => { + let c = BASE; + if (props.outline) { + c += " " + OUTLINE_BASE + " " + (OUTLINE_COLORS[props.color!] || OUTLINE_COLORS["neutral"]); + } else { + c += " " + (COLORS[props.color!] || COLORS["neutral"]); + } + if (props.small) { + c += props.icon ? " py-1 px-3" : " py-1 px-4"; + } else if (props.icon && !hasText()) { + c += " py-2 px-3"; + } else if (props.icon) { + c += " py-2 px-5"; + } else { + c += " py-2 px-8"; + } + return c; + }; + + return ( + + ); +} + +interface ButtonLinkProps { + onclick?: (e: MouseEvent) => void; + children?: JSXElement; +} + +export function ButtonLink(props: ButtonLinkProps) { + return ( + + ); +} + +export function ButtonLinkRed(props: ButtonLinkProps) { + return ( + + ); +} + +// SegmentedButtons renders a horizontal group of mutually-exclusive button +// options - one is "selected" at any time. Used for "toggle" patterns like +// the events sidebar's date/class switcher. Pill styling: a neutral track +// holds a raised white chip that marks the selected option; the rest sit +// muted. Buttons stretch to fill the track (flex-1). +type Reactive2 = T | (() => T); + +export interface SegmentedButtonOption { + value: string; + label: string; + icon?: string; +} + +interface SegmentedButtonsProps { + options: Reactive2; + value: Reactive2; + onchange: (v: string) => void; + small?: boolean; + class?: string; +} + +export function SegmentedButtons(props: SegmentedButtonsProps) { + const resolveR = (v: Reactive2): T => (typeof v === "function" ? (v as () => T)() : v); + + // Concentric corner radii: outer = inner + gap, where the gap is the + // track's p-0.5 (2px) padding -> 6px = 4px + 2px. Reusing one radius for + // both makes the chip corners read as too sharp inside the track. + const innerRadius = "rounded-default"; // chips: 4px + const outerRadius = "rounded-md"; // track: 4px + 2px = 6px + + const sizeCls = () => props.small ? "py-0.5 px-2 text-xs" : "py-1 px-3 text-sm"; + const baseCls = "inline-flex items-center justify-center gap-1.5 flex-1 cursor-pointer font-medium transition-colors whitespace-nowrap disabled:opacity-50 disabled:cursor-not-allowed"; + const activeCls = "bg-white text-text-heading shadow-sm"; + const inactiveCls = "text-neutral-500 hover:text-neutral-700"; + + const buttonCls = (v: string) => { + const selected = resolveR(props.value) === v; + return baseCls + " " + innerRadius + " " + sizeCls() + " " + (selected ? activeCls : inactiveCls); + }; + + return ( +
+ {resolveR(props.options).map((opt) => ( + + ))} +
+ ); +} + +interface BackLinkProps { + href: string; + text?: string; + onDark?: boolean; +} + +// Bare inline-flex anchor — callers control surrounding spacing so +// the link can sit cleanly inside a flex row without throwing off +// vertical alignment (e.g. inside a page header next to a title). +export function BackLink(props: BackLinkProps) { + const cls = () => "inline-flex items-center gap-1 text-sm no-underline " + + (props.onDark + ? "text-text-on-dark-muted hover:text-text-on-dark" + : "text-neutral-600 hover:text-neutral-900"); + return ( + + + {props.text} + + ); +} diff --git a/web/kit/Calendar.tsx b/web/kit/Calendar.tsx new file mode 100644 index 00000000..72811c38 --- /dev/null +++ b/web/kit/Calendar.tsx @@ -0,0 +1,211 @@ +import { createSignal, createMemo, For, createEffect, JSXElement } from "solid-js"; +import { Icon } from "./Icons.tsx"; + +const DAYS = ["Su", "Mo", "Tu", "We", "Th", "Fr", "Sa"]; +const MONTHS = [ + "January", "February", "March", "April", "May", "June", + "July", "August", "September", "October", "November", "December" +]; + +// -- Shared Tailwind class constants (also used by DatePicker) -- +export const CAL_PICKER_ROOT = "p-2 min-w-[240px]"; +export const CAL_MONTH_ROOT = "p-0 min-w-0 w-full bg-white border border-neutral-200 rounded-default shadow-sm overflow-hidden"; + +export const CAL_HEADER_PICKER = "flex items-center justify-between mb-2 gap-1"; +export const CAL_HEADER_MONTH = "flex items-center justify-between gap-1 py-3 px-4 border-b border-neutral-200 bg-neutral-50"; + +export const CAL_NAV_BTN = "bg-transparent border-0 p-1 cursor-pointer text-text-muted rounded-sm flex items-center justify-center hover:bg-neutral-100 hover:text-text-body"; + +export const CAL_MY_PICKER = "text-sm font-semibold text-text-heading mx-3 whitespace-nowrap"; +export const CAL_MY_MONTH = "text-lg font-heading mx-4 flex-1 text-center font-semibold text-text-heading whitespace-nowrap"; + +export const CAL_WEEKDAYS_PICKER = "grid grid-cols-7 gap-[2px] mb-1"; +export const CAL_WEEKDAYS_MONTH = "grid grid-cols-7 border-b border-neutral-200"; +export const CAL_WEEKDAY_PICKER = "text-center text-xs font-semibold text-text-muted p-1"; +export const CAL_WEEKDAY_MONTH = "text-center text-xs font-semibold text-text-muted p-2 uppercase tracking-wider"; + +export const CAL_DAYS_PICKER = "grid grid-cols-7 gap-[2px]"; +export const CAL_DAYS_MONTH = "grid grid-cols-7"; + +export const CAL_DAY_PICKER_BASE = "aspect-square flex items-center justify-center text-sm bg-transparent border-0 rounded-sm cursor-pointer text-text-body p-0"; +export const CAL_DAY_MONTH_BASE = "min-h-[6.5rem] flex flex-col items-stretch justify-start p-1.5 border-r border-b border-neutral-200 text-left gap-1 text-xs bg-transparent cursor-pointer"; + +export const CAL_SELECT = "flex-1 py-1 px-2 text-sm font-semibold border border-neutral-200 rounded-sm bg-white text-text-heading cursor-pointer focus:outline-hidden focus:border-primary"; + +function getDaysInMonth(year: number, month: number): number { + return new Date(year, month + 1, 0).getDate(); +} + +function getFirstDayOfMonth(year: number, month: number): number { + return new Date(year, month, 1).getDay(); +} + +function toDateKey(date: Date | null | undefined): string { + if (!date) return ""; + const y = date.getFullYear(); + const m = String(date.getMonth() + 1).padStart(2, "0"); + const d = String(date.getDate()).padStart(2, "0"); + return `${y}-${m}-${d}`; +} + +export type CalendarVariant = "picker" | "month"; + +interface CalendarProps { + selected?: string | Date; + viewMonth?: string | Date; + onSelect?: (key: string) => void; + variant?: CalendarVariant; + renderDay?: (key: string, date: Date) => JSXElement; + _reset?: unknown; +} + +export function Calendar(props: CalendarProps) { + const today = new Date(); + const [viewMonth, setViewMonth] = createSignal(new Date(today.getFullYear(), today.getMonth(), 1)); + const [key, setKey] = createSignal(0); + + createEffect(() => { + if (props.selected) { + const d = new Date(props.selected); + if (!isNaN(d.getTime())) { + setViewMonth(new Date(d.getFullYear(), d.getMonth(), 1)); + } + } + }); + + createEffect(() => { + props._reset; + setKey(k => k + 1); + setViewMonth(new Date(today.getFullYear(), today.getMonth(), 1)); + }); + + // Sync viewMonth from parent only when the parent's value actually changes + // to a different month. Tracking a stamp prevents the parent from clobbering + // the user's local month navigation on unrelated re-renders. + let lastPropStamp: number | null = null; + createEffect(() => { + const vm = props.viewMonth; + if (!vm) return; + const d = vm instanceof Date ? vm : new Date(vm); + if (!(d instanceof Date) || isNaN(d.getTime())) return; + const stamp = d.getFullYear() * 12 + d.getMonth(); + if (stamp === lastPropStamp) return; + lastPropStamp = stamp; + setViewMonth(new Date(d.getFullYear(), d.getMonth(), 1)); + }); + + const currentMonth = createMemo(() => viewMonth().getMonth()); + const currentYear = createMemo(() => viewMonth().getFullYear()); + + const days = createMemo(() => { + const year = currentYear(); + const month = currentMonth(); + const daysInMonth = getDaysInMonth(year, month); + const firstDay = getFirstDayOfMonth(year, month); + + const daysArray: (Date | null)[] = []; + for (let i = 0; i < firstDay; i++) { + daysArray.push(null); + } + for (let i = 1; i <= daysInMonth; i++) { + daysArray.push(new Date(year, month, i)); + } + return daysArray; + }); + + const prevMonth = () => setViewMonth(new Date(currentYear(), currentMonth() - 1, 1)); + const nextMonth = () => setViewMonth(new Date(currentYear(), currentMonth() + 1, 1)); + + const isSelected = (date: Date | null): boolean => { + if (!date || !props.selected) return false; + const sel = new Date(props.selected); + if (isNaN(sel.getTime())) return false; + return date.getFullYear() === sel.getFullYear() && + date.getMonth() === sel.getMonth() && + date.getDate() === sel.getDate(); + }; + + const isToday = (date: Date | null): boolean => { + if (!date) return false; + return date.getFullYear() === today.getFullYear() && + date.getMonth() === today.getMonth() && + date.getDate() === today.getDate(); + }; + + const selectDate = (date: Date | null) => { + if (!date) return; + props.onSelect?.(toDateKey(date)); + }; + + const variant = (): CalendarVariant => props.variant || "picker"; + const isMonth = () => variant() === "month"; + + const rootCls = () => isMonth() ? CAL_MONTH_ROOT : CAL_PICKER_ROOT; + const headerCls = () => isMonth() ? CAL_HEADER_MONTH : CAL_HEADER_PICKER; + const myCls = () => isMonth() ? CAL_MY_MONTH : CAL_MY_PICKER; + const weekdaysCls = () => isMonth() ? CAL_WEEKDAYS_MONTH : CAL_WEEKDAYS_PICKER; + const weekdayCls = () => isMonth() ? CAL_WEEKDAY_MONTH : CAL_WEEKDAY_PICKER; + const daysCls = () => isMonth() ? CAL_DAYS_MONTH : CAL_DAYS_PICKER; + + // Day button class. nth-child(7n) (last column) skips right border in + // month variant — we compute it from the array index since the Tailwind + // compiler doesn't support [&:nth-child(7n)] arbitrary variants. + const dayClass = (date: Date | null, idx: number): string => { + const empty = !date; + const selected = isSelected(date); + const today = isToday(date); + + if (isMonth()) { + let c = CAL_DAY_MONTH_BASE; + if (idx % 7 === 6) c += " border-r-0"; + if (empty) c += " bg-neutral-50 cursor-default"; + else c += " hover:bg-neutral-50"; + if (selected) c += " bg-primary/10 text-text-body"; + return c; + } + let c = CAL_DAY_PICKER_BASE; + if (empty) c += " cursor-default"; + else c += " hover:bg-neutral-100"; + if (today) c += " font-bold text-primary"; + if (selected) c += " !bg-primary !text-white"; + return c; + }; + + const dayNumberClass = (date: Date | null): string => { + if (isMonth()) { + const base = "text-sm font-semibold text-text-muted self-end px-1 py-0.5"; + if (date && isToday(date)) { + return "bg-primary text-white rounded-full w-6 h-6 inline-flex items-center justify-center p-0 self-end text-sm font-semibold"; + } + return base; + } + return "leading-none"; + }; + + + + return ( +
+
+ + {MONTHS[currentMonth()] + " " + currentYear()} + +
+
+ {(day) =>
{day}
}
+
+
+ {(date, idx) => ( + + )} +
+
+ ); +} diff --git a/web/kit/Cards.tsx b/web/kit/Cards.tsx new file mode 100644 index 00000000..7f3f8270 --- /dev/null +++ b/web/kit/Cards.tsx @@ -0,0 +1,98 @@ +import { JSXElement } from "solid-js"; + +interface CardProps { + class?: string; + children?: JSXElement; +} + +// `ui-card` / `no-flex` class names are kept so page-specific CSS (e.g. +// support.css) that targets them can keep overriding. All baseline +// styling is Tailwind. +const CARD_BASE = "ui-card bg-white shadow-sm rounded-default w-full"; +const CARD_WITH_PADDING = CARD_BASE + " p-5 flex-1"; +const CARD_NO_FLEX = CARD_BASE + " no-flex p-5"; +const CARD_NO_PADDING_NO_FLEX = CARD_BASE + " no-padding no-flex"; + +const BORDER_CARD = "border border-neutral-300 rounded-default p-5 w-full"; + +// Cut-corner card uses two pseudo-elements with clip-paths to create the +// notched corners. Tailwind supports arbitrary clip-path values. +const CUT_CORNER_CARD = + "relative isolate p-5 w-full " + + "before:content-[''] before:absolute before:inset-0 before:bg-neutral-300 before:-z-20 " + + "before:[clip-path:polygon(16px_0,100%_0,100%_calc(100%_-_16px),calc(100%_-_16px)_100%,0_100%,0_16px)] " + + "after:content-[''] after:absolute after:inset-[1px] after:bg-white after:-z-10 " + + "after:[clip-path:polygon(15px_0,100%_0,100%_calc(100%_-_15px),calc(100%_-_15px)_100%,0_100%,0_15px)]"; + +export function Card(props: CardProps) { + return ( +
+ {props.children} +
+ ); +} + +export function CardNoPadding(props: CardProps) { + return ( +
+ {props.children} +
+ ); +} + +export function CardNoFlexGrow(props: CardProps) { + return ( +
+ {props.children} +
+ ); +} + +export function BorderCard(props: CardProps) { + return ( +
+ {props.children} +
+ ); +} + +export function BorderCutCornerCard(props: CardProps) { + return ( +
+ {props.children} +
+ ); +} + +const CARD_HEADER = "text-xl tracking-tight text-black mb-5"; +const CARD_HEADER_HR = "text-neutral-200 mt-1 mb-3"; + +export function CardHeader(props: CardProps) { + return ( +
+ {props.children} +
+
+ ); +} + +export function CardHeaderTextCenter(props: CardProps) { + return ( +
+ {props.children} +
+
+ ); +} + +export function CardSubheader(props: CardProps) { + return ( +
+ {props.children} +
+ ); +} + +export function CardSpacer() { + return
; +} diff --git a/web/kit/CellGrid.tsx b/web/kit/CellGrid.tsx new file mode 100644 index 00000000..54e94edb --- /dev/null +++ b/web/kit/CellGrid.tsx @@ -0,0 +1,434 @@ +import { createSignal, createMemo, untrack, Show, For, JSXElement } from "solid-js"; +import { Icon } from "./Icons.tsx"; + +export const GRID_HEADER_CLS = "border-b border-r border-neutral-300 bg-neutral-50 px-1.5 py-1.5 text-left text-xs font-bold uppercase text-black whitespace-nowrap last:border-r-0"; + +interface SortableHeaderProps { + label: string; + sortKey: string; + width?: string; + minWidth?: string; + current: string | null; + desc: boolean; + onSort?: (key: string) => void; +} + +function columnSizeClass(width?: string, minWidth?: string): string { + return width || minWidth || ""; +} + +export function SortableHeader(props: SortableHeaderProps) { + const isActive = () => { + const cur = typeof props.current === "function" ? (props.current as () => string | null)() : props.current; + return cur === props.sortKey; + }; + const descending = () => { + const d = typeof props.desc === "function" ? (props.desc as () => boolean)() : props.desc; + return !!d; + }; + const cls = () => GRID_HEADER_CLS + " cursor-pointer select-none hover:bg-neutral-200" + + (columnSizeClass(props.width, props.minWidth) ? " " + columnSizeClass(props.width, props.minWidth) : ""); + return ( + props.onSort?.(props.sortKey)}> +
+ {props.label} + + + +
+ + ); +} + +export function compareRowsGeneric(a: any, b: any, key: string, sortType?: string): number { + const av = a[key]; + const bv = b[key]; + const aEmpty = av === "" || av == null; + const bEmpty = bv === "" || bv == null; + if (aEmpty && bEmpty) return 0; + if (aEmpty) return 1; + if (bEmpty) return -1; + if (sortType === "numeric") { + const as = String(av); + const bs = String(bv); + const am = /^(\d+)/.exec(as); + const bm = /^(\d+)/.exec(bs); + const an = am ? parseInt(am[1], 10) : NaN; + const bn = bm ? parseInt(bm[1], 10) : NaN; + if (!isNaN(an) && !isNaN(bn)) { + if (an !== bn) return an - bn; + return as.localeCompare(bs); + } + if (!isNaN(an)) return -1; + if (!isNaN(bn)) return 1; + return as.localeCompare(bs); + } + if (sortType === "money") { + return parseFloat(av) - parseFloat(bv); + } + return String(av).localeCompare(String(bv)); +} + +export interface CellGridColumn { + key: string; + label: string; + sortKey?: string; + sortType?: string; + sortValue?: (row: any) => unknown; + width?: string; + minWidth?: string; + headerClass?: string; + editable?: boolean; + readOnly?: boolean; + render?: (row: any) => JSXElement; + cellClass?: string | ((row: any) => string); + onclick?: (row: any) => void; + inputMode?: "decimal" | "email" | "none" | "numeric" | "search" | "tel" | "text" | "url" | undefined; + placeholder?: string; + parse?: (value: string) => unknown; +} + +export interface CellGridApi { + dirty: () => boolean; + selected: () => { id: unknown; field: string } | null; + focusCell: (id: unknown, field: string) => void; + displayedRows: () => any[]; + snapshotRowPositions: () => Map; + animateRows: (before: Map) => void; +} + +interface CellGridProps { + columns: CellGridColumn[]; + rows: any[]; + initialRows: any[]; + idField?: string; + onCellChange: (rowId: unknown, field: string, value: unknown) => void; + sortKey: string | null; + setSortKey: (key: string) => void; + sortDesc: boolean; + setSortDesc: (desc: boolean) => void; + conflictFields?: string[]; + dense?: boolean; + ref?: (api: CellGridApi) => void; +} + +export function CellGrid(props: CellGridProps) { + const getSortKey = () => typeof props.sortKey === "function" ? (props.sortKey as () => string | null)() : props.sortKey; + const getSortDesc = () => typeof props.sortDesc === "function" ? (props.sortDesc as () => boolean)() : props.sortDesc; + + const idField = () => props.idField || "id"; + const editableFields = createMemo(() => props.columns.filter((c) => c.editable).map((c) => c.key)); + const columnsByKey = createMemo(() => { + const m = new Map(); + for (const col of props.columns) m.set(col.key, col); + return m; + }); + + const [selected, setSelected] = createSignal<{ id: unknown; field: string } | null>(null); + const [sortStamp, setSortStamp] = createSignal(0); + const [blurStamp, setBlurStamp] = createSignal(0); + + const rowRefs = new Map(); + const inputRefs = new Map(); + + const setRowRef = (id: unknown) => (el: HTMLElement) => { + if (el) rowRefs.set(id, el); + }; + const setInputRef = (id: unknown, field: string) => (el: HTMLInputElement) => { + const key = id + "::" + field; + if (el) inputRefs.set(key, el); + else inputRefs.delete(key); + }; + + const sortColMap = createMemo(() => { + const m = new Map(); + for (const col of props.columns) { + if (col.sortKey) m.set(col.sortKey, col); + } + return m; + }); + + const sortedOrder = createMemo(() => { + sortStamp(); + props.initialRows; + props.rows.length; + const sk = getSortKey(); + const desc = getSortDesc(); + const col = sortColMap().get(sk || ""); + const st = col?.sortType || "string"; + const getVal = typeof col?.sortValue === "function" ? col.sortValue : (r: any) => r[sk || ""]; + return untrack(() => { + const idf = idField(); + const snap = props.rows.map((r) => ({ id: r[idf], sortVal: getVal(r) })); + snap.sort((a, b) => compareRowsGeneric(a, b, "sortVal", st)); + if (desc) snap.reverse(); + return snap.map((s) => s.id); + }); + }); + + const displayedRows = createMemo(() => { + const order = sortedOrder(); + const idf = idField(); + const byId = new Map(); + for (let i = 0; i < props.rows.length; i++) { + byId.set(props.rows[i][idf], props.rows[i]); + } + const out: any[] = []; + for (const id of order) { + const r = byId.get(id); + if (r) out.push(r); + } + return out; + }); + + const dirty = createMemo(() => { + const current = props.rows; + const initial = props.initialRows; + if (!initial || current.length !== initial.length) return true; + const fields = editableFields(); + for (let i = 0; i < current.length; i++) { + for (const f of fields) { + if (current[i][f] !== initial[i][f]) return true; + } + } + return false; + }); + + const conflictSets = createMemo(() => { + blurStamp(); + props.initialRows; + return untrack(() => { + const result: Record> = {}; + if (!props.conflictFields) return result; + for (const field of props.conflictFields) { + const counts = new Map(); + for (let i = 0; i < props.rows.length; i++) { + const v = props.rows[i][field]; + if (!v) continue; + counts.set(v, (counts.get(v) || 0) + 1); + } + const conflicts = new Set(); + counts.forEach((c, v) => { if (c > 1) conflicts.add(v); }); + result[field] = conflicts; + } + return result; + }); + }); + + const isConflict = (field: string, value: unknown): boolean => { + if (!value) return false; + const sets = conflictSets(); + return !!sets[field] && sets[field].has(value); + }; + + const animateReorder = (prevPositions: Map) => { + rowRefs.forEach((el, id) => { + const prev = prevPositions.get(id); + if (!prev || !el.isConnected) return; + const next = el.getBoundingClientRect(); + const dy = prev.top - next.top; + if (dy === 0) return; + el.animate( + [{ transform: `translateY(${dy}px)` }, { transform: "translateY(0)" }], + { duration: 300, easing: "cubic-bezier(0.22, 0.61, 0.36, 1)" } + ); + }); + }; + + const handleSort = (key: string) => { + const positions = new Map(); + rowRefs.forEach((el, id) => { + if (el.isConnected) positions.set(id, el.getBoundingClientRect()); + }); + if (getSortKey() === key) { + props.setSortDesc(!getSortDesc()); + } else { + props.setSortKey(key); + props.setSortDesc(false); + } + setSortStamp((s) => s + 1); + animateReorder(positions); + }; + + const focusCell = (id: unknown, field: string) => { + const el = inputRefs.get(id + "::" + field); + if (el) { + el.focus(); + try { el.select(); } catch {} + } + }; + + const moveSelection = (dCol: number, dRow: number) => { + const cur = selected(); + const rows = displayedRows(); + const fields = editableFields(); + if (rows.length === 0 || fields.length === 0) return; + const idf = idField(); + let rowIdx = cur ? rows.findIndex((r) => r[idf] === cur.id) : 0; + let colIdx = cur ? fields.indexOf(cur.field) : 0; + if (rowIdx < 0) rowIdx = 0; + if (colIdx < 0) colIdx = 0; + const newRowIdx = Math.max(0, Math.min(rows.length - 1, rowIdx + dRow)); + const newColIdx = Math.max(0, Math.min(fields.length - 1, colIdx + dCol)); + const newId = rows[newRowIdx][idf]; + const newField = fields[newColIdx]; + setSelected({ id: newId, field: newField }); + focusCell(newId, newField); + }; + + const shouldNavigateHorizontal = (input: HTMLInputElement | null): boolean => { + if (!input) return false; + if (!input.value) return true; + return typeof input.selectionStart === "number" && input.selectionStart !== input.selectionEnd; + }; + + const handleKeyDown = (e: KeyboardEvent) => { + const key = e.key; + const input = e.target as HTMLInputElement; + + if (key === "Enter") { + e.preventDefault(); + moveSelection(0, e.shiftKey ? -1 : 1); + return; + } + if (key === "Tab") { + e.preventDefault(); + moveSelection(e.shiftKey ? -1 : 1, 0); + return; + } + if (key === "Escape") { + if (input && typeof input.setSelectionRange === "function") { + const pos = input.selectionEnd || 0; + try { input.setSelectionRange(pos, pos); } catch {} + } + e.preventDefault(); + return; + } + if (key === "ArrowUp" || key === "ArrowDown") { + e.preventDefault(); + moveSelection(0, key === "ArrowDown" ? 1 : -1); + return; + } + if (key === "ArrowLeft" || key === "ArrowRight") { + if (shouldNavigateHorizontal(input)) { + e.preventDefault(); + moveSelection(key === "ArrowRight" ? 1 : -1, 0); + } + return; + } + }; + + const handleCellFocus = (id: unknown, field: string) => { + setSelected({ id, field }); + }; + + const handleCellMouseDown = (id: unknown, field: string) => { + setSelected({ id, field }); + }; + + const snapshotRowPositions = (): Map => { + const m = new Map(); + rowRefs.forEach((el, id) => { + if (el.isConnected) m.set(id, el.getBoundingClientRect()); + }); + return m; + }; + + props.ref?.({ + dirty, + selected, + focusCell, + displayedRows, + snapshotRowPositions, + animateRows: animateReorder, + }); + + const dense = () => !!props.dense; + const rowHCls = () => dense() ? "h-6" : "h-8"; + const readonlyTdCls = () => "border-b border-r border-neutral-300 bg-black/5 px-2 text-neutral-700 align-middle " + rowHCls(); + const editableTdCls = () => "border-b border-r border-neutral-300 p-0 relative align-middle"; + const inputCls = "absolute inset-0 w-full border-none outline-none bg-transparent px-2 placeholder:text-neutral-400 focus:bg-red-50 focus:shadow-[inset_0_0_0_2px_var(--color-red-500)]"; + + const colSizeCls = (col: CellGridColumn) => columnSizeClass(col.width, col.minWidth); + + const renderHeader = (col: CellGridColumn) => { + const widthCls = colSizeCls(col) ? " " + colSizeCls(col) : ""; + if (col.sortKey) { + return ( + + ); + } + const cls = col.headerClass + ? GRID_HEADER_CLS + " " + col.headerClass + widthCls + : GRID_HEADER_CLS + widthCls; + return {col.label}; + }; + + const renderCell = (row: any, col: CellGridColumn) => { + const idf = idField(); + const rowId = row[idf]; + const sizeCls = colSizeCls(col) ? " " + colSizeCls(col) : ""; + + if (col.render && !col.editable) { + const cellCls = () => { + if (typeof col.cellClass === "function") return col.cellClass(row) + sizeCls; + return (col.cellClass || readonlyTdCls()) + sizeCls; + }; + return col.onclick!(row) : undefined}>{col.render!(row)}; + } + + if (col.readOnly) { + const roCls = () => { + if (typeof col.cellClass === "function") return col.cellClass(row) + sizeCls; + return (col.cellClass || readonlyTdCls()) + sizeCls; + }; + return col.onclick!(row) : undefined}>{col.render ? col.render(row) : row[col.key]}; + } + + const hasConflict = () => isConflict(col.key, row[col.key]); + const tdClass = () => { + let base = editableTdCls() + sizeCls; + if (props.conflictFields && props.conflictFields.includes(col.key)) { + base += " relative"; + if (hasConflict()) base += " bg-amber-100"; + } + return base; + }; + + return ( + + { + const target = e.currentTarget as HTMLInputElement; + const val = col.parse ? col.parse(target.value) : target.value; + props.onCellChange(rowId, col.key, val); + }} onFocus={() => handleCellFocus(rowId, col.key)} onBlur={() => setBlurStamp((s) => s + 1)} onMouseDown={() => handleCellMouseDown(rowId, col.key)}/> + + + + + + + ); + }; + + const tableCls = () => "min-w-full w-max border-collapse " + (dense() ? "text-xs" : "text-sm"); + + return ( +
+ + + + {(col) => renderHeader(col)} + + + + {(row) => ( + + {(col) => renderCell(row, col)} + + )} + +
+
+ ); +} diff --git a/web/kit/Chart.tsx b/web/kit/Chart.tsx new file mode 100644 index 00000000..be7c61f9 --- /dev/null +++ b/web/kit/Chart.tsx @@ -0,0 +1,91 @@ +import { createEffect, onCleanup, onMount } from "solid-js"; +import { Chart, registerables } from "chart.js"; + +// chart.js v4 is tree-shakeable and ships nothing registered by default; register +// all controllers/elements/scales once so any chart type works (the old UMD shim +// did this implicitly). +Chart.register(...registerables); + +type ChartType = "line" | "bar" | "radar" | "doughnut" | "polarArea" | "bubble" | "pie" | "scatter"; + +interface ReactiveChartProps { + type: ChartType; + data: unknown; + options?: object; + class?: string; +} + +interface ChartInstance { + destroy(): void; + update(): void; + data: unknown; + options: object; +} + +type ChartCtor = new (ctx: CanvasRenderingContext2D, cfg: object) => ChartInstance; + +export default function ReactiveChart(props: ReactiveChartProps) { + let canvasRef: HTMLCanvasElement | undefined; + let chartInstance: ChartInstance | null = null; + + onMount(() => { + // Defer initialization until the canvas is connected to the document. + // @solidjs/router creates route components before inserting them into + // the DOM, and Chart.js needs getComputedStyle which requires a + // connected element with ownerDocument.defaultView. + const init = () => { + if (!canvasRef) return; + if (!canvasRef.isConnected) { + requestAnimationFrame(init); + return; + } + + const ctx = canvasRef.getContext("2d"); + if (!ctx) return; + + chartInstance = new (Chart as unknown as ChartCtor)(ctx, { + type: props.type, + data: props.data, + options: { + responsive: true, + maintainAspectRatio: false, + ...(props.options ?? {}), + }, + }); + }; + init(); + }); + + onCleanup(() => { + if (chartInstance) { + chartInstance.destroy(); + chartInstance = null; + } + }); + + createEffect(() => { + const data = props.data; + if (chartInstance) { + chartInstance.data = data; + chartInstance.update(); + } + }); + + createEffect(() => { + const options = props.options; + if (chartInstance) { + chartInstance.options = { + responsive: true, + maintainAspectRatio: false, + ...(options ?? {}), + }; + chartInstance.update(); + } + }); + + return ( +
+ canvasRef = el}> +
+ ); +} diff --git a/web/kit/CrmTabs.tsx b/web/kit/CrmTabs.tsx new file mode 100644 index 00000000..fd70deca --- /dev/null +++ b/web/kit/CrmTabs.tsx @@ -0,0 +1,166 @@ +import { createSignal, onCleanup, onMount, Show, For, JSXElement } from "solid-js"; + +// CrmTabGroup / CrmSubTabGroup — drop-in, behaviourally identical siblings of +// TabGroup (same props, storageKey syncing, controlled/uncontrolled index) with +// a different look: +// - CrmTabGroup → boxed tabs with a sky-blue top accent (ported from the old cdrl_2.0 tabs) +// - CrmSubTabGroup → interlocking right-pointing arrows (a process-flow strip) + +interface CrmTabItem { + title: string; + badge?: number; + content: JSXElement; +} + +interface CrmTabGroupProps { + items: CrmTabItem[]; + storageKey?: string; + activeIndex?: number; + onTabChange?: (index: number) => void; + defaultIndex?: number; +} + +function resolveCrmBadge(badge: number | undefined): number | undefined { + return typeof badge === "function" ? (badge as () => number)() : badge; +} + +// Shared active-index state: mirrors TabGroup exactly (localStorage persistence, +// cross-component sync via synthetic storage events, optional controlled index). +function createCrmTabState(props: CrmTabGroupProps) { + const getInitialIndex = () => { + if (props.storageKey) { + const stored = localStorage.getItem(props.storageKey); + if (stored !== null) { + const parsed = parseInt(stored, 10); + if (!isNaN(parsed) && parsed >= 0 && parsed < props.items.length) { + return parsed; + } + } + } + return props.defaultIndex ?? 0; + }; + + const [_activeIndex, _setActiveIndex] = createSignal(getInitialIndex()); + + const activeIndex = (): number => { + const controlled = props.activeIndex; + if (controlled != null) { + return typeof controlled === "function" ? (controlled as () => number)() : controlled; + } + return _activeIndex(); + }; + + const setActiveIndex = (i: number) => { + _setActiveIndex(i); + if (props.storageKey) { + const v = String(i); + localStorage.setItem(props.storageKey, v); + window.dispatchEvent(new StorageEvent("storage", { key: props.storageKey, newValue: v })); + } + props.onTabChange && props.onTabChange(i); + }; + + onMount(() => { + if (!props.storageKey) return; + const handler = (e: StorageEvent) => { + if (e.key !== props.storageKey || e.newValue == null) return; + const n = parseInt(e.newValue, 10); + if (!isNaN(n) && n >= 0 && n < props.items.length && n !== _activeIndex()) { + _setActiveIndex(n); + props.onTabChange && props.onTabChange(n); + } + }; + window.addEventListener("storage", handler); + onCleanup(() => window.removeEventListener("storage", handler)); + }); + + return { activeIndex, setActiveIndex }; +} + +function crmTabContent(items: CrmTabItem[], activeIndex: () => number) { + return {(item: CrmTabItem, index: () => number) => ( +
+ {item.content} +
+ )}
; +} + +// --- CrmTabGroup: boxed top-accent tabs ------------------------------------- + +// Ported from the old cdrl_2.0 TabGroup. Inactive tabs are flat with only a +// bottom border (neutral-300) that forms the baseline; a trailing flex-1 filler +// extends that baseline past the last tab to the right edge. The active tab +// drops its bottom border and gains 1px left/right borders plus a 2px sky-700 +// top edge, so it reads as a raised box connected to the content below. No body +// panel — content sits flat beneath the row, as in the old design. +// +// Per-tab borders (no negative-margin overlap) mean the row's overflow-x-auto +// can't clip anything: the baseline simply has a gap under the active tab. +const CRM_TAB_ROW = "flex w-full overflow-x-auto text-sm"; +const CRM_TAB_BASE = "flex items-center gap-1.5 cursor-pointer p-4 font-medium border-neutral-300 transition-colors"; +const CRM_TAB_INACTIVE = "border-b text-neutral-500 hover:text-neutral-800"; +const CRM_TAB_ACTIVE = "border-x border-t-2 border-t-sky-700 text-primary"; +const CRM_TAB_BADGE = "inline-flex items-center justify-center min-w-5 h-5 px-1 text-xs font-semibold bg-primary text-white rounded-full"; + +export function CrmTabGroup(props: CrmTabGroupProps) { + const { activeIndex, setActiveIndex } = createCrmTabState(props); + + return
+
+ {(item: CrmTabItem, index: () => number) => ( + + )} +
+
+
+ {crmTabContent(props.items, activeIndex)} +
+
; +} + +// --- CrmSubTabGroup: segmented control -------------------------------------- + +// A single rounded, bordered group split into segments with dividers between +// them. The active segment is filled gray with white text; inactive segments +// are white and recede. The group is left-aligned (flush with the file-folder +// tabs) and a full-width baseline separates the bar from the content below. +const CRM_SUBTAB_WRAP = "flex pb-3 border-b border-neutral-300 overflow-x-auto"; +const CRM_SUBTAB_GROUP = "inline-flex items-stretch rounded-md border border-neutral-300 overflow-hidden text-sm select-none"; +const CRM_SUBTAB_BASE = "flex items-center gap-1.5 py-1 px-3 cursor-pointer font-medium whitespace-nowrap transition-colors"; +const CRM_SUBTAB_DIVIDER = "border-l border-neutral-300"; +const CRM_SUBTAB_ACTIVE = "bg-neutral-500 text-white"; +const CRM_SUBTAB_INACTIVE = "bg-white text-neutral-600 hover:bg-neutral-100 hover:text-neutral-900"; +const CRM_SUBTAB_BADGE = "inline-flex items-center justify-center min-w-5 h-5 px-1 text-xs font-semibold bg-black/10 text-current rounded-full"; + +export function CrmSubTabGroup(props: CrmTabGroupProps) { + const { activeIndex, setActiveIndex } = createCrmTabState(props); + + return
+
+
+ {(item: CrmTabItem, index: () => number) => ( + + )} +
+
+
+ {crmTabContent(props.items, activeIndex)} +
+
; +} diff --git a/web/kit/DatePicker.tsx b/web/kit/DatePicker.tsx new file mode 100644 index 00000000..d4682731 --- /dev/null +++ b/web/kit/DatePicker.tsx @@ -0,0 +1,542 @@ +import { createSignal, createEffect, Show, onMount, onCleanup, For } from "solid-js"; +import { Portal } from "solid-js/web"; +import { Icon } from "./Icons.tsx"; +import { FormInput } from "./Forms.tsx"; +import { readAccessor, accessor, type MaybeAccessor } from "../utils/accessors.ts"; +import { + CAL_PICKER_ROOT, + CAL_HEADER_PICKER, + CAL_NAV_BTN, + CAL_MY_PICKER, + CAL_WEEKDAYS_PICKER, + CAL_WEEKDAY_PICKER, + CAL_DAYS_PICKER, + CAL_DAY_PICKER_BASE, + CAL_SELECT, +} from "./Calendar.tsx"; + +const DAYS = ["Su", "Mo", "Tu", "We", "Th", "Fr", "Sa"]; +const MONTHS = ["January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"]; + +function getDaysInMonth(year: number, month: number): number { + return new Date(year, month + 1, 0).getDate(); +} + +function getFirstDayOfMonth(year: number, month: number): number { + return new Date(year, month, 1).getDay(); +} + +function dayClass(date: Date | null, selected: boolean, today: boolean): string { + let c = CAL_DAY_PICKER_BASE; + if (!date) c += " cursor-default"; + else c += " hover:bg-neutral-100"; + if (today) c += " font-bold text-primary"; + if (selected) c += " !bg-primary !text-white"; + return c; +} + +/** Parse typed or pasted text into YYYY-MM-DD, or "" if invalid. */ +function parseDateInput(text: string): string { + const trimmed = text.trim(); + if (!trimmed) return ""; + + if (/^\d{4}-\d{2}-\d{2}$/.test(trimmed)) { + const [y, m, d] = trimmed.split("-").map((n) => parseInt(n, 10)); + const iso = new Date(y, m - 1, d); + if (!isNaN(iso.getTime()) && iso.getFullYear() === y && iso.getMonth() === m - 1 && iso.getDate() === d) { + return trimmed; + } + } + + const parsed = new Date(trimmed); + if (!isNaN(parsed.getTime())) { + const y = parsed.getFullYear(); + const m = String(parsed.getMonth() + 1).padStart(2, "0"); + const d = String(parsed.getDate()).padStart(2, "0"); + return `${y}-${m}-${d}`; + } + + return ""; +} + +function formatDisplayDate(iso: string): string { + if (!iso) return ""; + const parts = iso.split("-"); + if (parts.length < 3) return iso; + const d = new Date(parseInt(parts[0], 10), parseInt(parts[1], 10) - 1, parseInt(parts[2], 10)); + if (isNaN(d.getTime())) return ""; + return d.toLocaleDateString(); +} + +interface CalendarDropdownProps { + selected?: MaybeAccessor; + onSelect?: (key: string) => void; +} + +function CalendarDropdown(props: CalendarDropdownProps) { + const today = new Date(); + const selected = () => readAccessor(props.selected, ""); + const [viewDate, setViewDate] = createSignal(selected() ? new Date(selected()) : today); + const [key, setKey] = createSignal(0); + + createEffect(() => { + const v = selected(); + if (v) { + const d = new Date(v); + if (!isNaN(d.getTime())) { + setViewDate(d); + } + } + }); + + const currentMonth = () => viewDate().getMonth(); + const currentYear = () => viewDate().getFullYear(); + + const getDays = () => { + const year = currentYear(); + const month = currentMonth(); + const daysInMonth = getDaysInMonth(year, month); + const firstDay = getFirstDayOfMonth(year, month); + + const daysArray: (Date | null)[] = []; + for (let i = 0; i < firstDay; i++) { + daysArray.push(null); + } + for (let i = 1; i <= daysInMonth; i++) { + daysArray.push(new Date(year, month, i)); + } + return daysArray; + }; + + const isSelected = (date: Date | null): boolean => { + if (!date) return false; + const v = selected(); + if (!v) return false; + const sel = new Date(v); + if (isNaN(sel.getTime())) return false; + return date.getFullYear() === sel.getFullYear() && + date.getMonth() === sel.getMonth() && + date.getDate() === sel.getDate(); + }; + + const isToday = (date: Date | null): boolean => { + if (!date) return false; + return date.getFullYear() === today.getFullYear() && + date.getMonth() === today.getMonth() && + date.getDate() === today.getDate(); + }; + + const selectDate = (date: Date | null) => { + if (!date) return; + const y = date.getFullYear(); + const m = String(date.getMonth() + 1).padStart(2, '0'); + const d = String(date.getDate()).padStart(2, '0'); + props.onSelect?.(`${y}-${m}-${d}`); + }; + + const goPrev = (e: MouseEvent) => { + e.stopPropagation(); + setViewDate(new Date(currentYear(), currentMonth() - 1, 1)); + setKey(k => k + 1); + }; + + const goNext = (e: MouseEvent) => { + e.stopPropagation(); + setViewDate(new Date(currentYear(), currentMonth() + 1, 1)); + setKey(k => k + 1); + }; + + return ( +
+
+ + {MONTHS[currentMonth()]} {currentYear()} + +
+
+ {(day) =>
{day}
}
+
+
+ {(date) => ( + + )} +
+
+ ); +} + +const DATE_PICKER_WRAP = "relative w-full min-w-0"; +const DATE_PICKER_FIELD = "relative w-full min-w-0 cursor-pointer [&_.ui-form]:m-0 [&_input]:cursor-text"; +const DATE_PICKER_DROPDOWN = "bg-white border border-neutral-200 rounded-default shadow-[0_4px_12px_rgba(0,0,0,0.15)] p-1 min-w-[16rem]"; +const DATE_PICKER_ICON_BTN = "absolute inset-y-0 right-0 z-[1] flex items-center justify-center bg-transparent border-0 px-2 cursor-pointer text-text-muted leading-none hover:text-text-body pointer-events-auto"; + +const DATE_PICKER_CLEAR_BTN = "absolute inset-y-0 right-8 z-[1] flex items-center justify-center bg-transparent border-0 px-1.5 cursor-pointer text-text-muted leading-none hover:text-text-body pointer-events-auto"; + +interface DatePickerProps { + value?: MaybeAccessor; + onchange?: (value: string) => void; + placeholder?: string; + small?: boolean; + clearable?: boolean; +} + +export function DatePicker(props: DatePickerProps) { + const [open, setOpen] = createSignal(false); + const [localValue, setLocalValue] = createSignal(""); + const [editing, setEditing] = createSignal(false); + const [draft, setDraft] = createSignal(""); + const [dropdownPos, setDropdownPos] = createSignal({ top: 0, left: 0, width: 0 }); + let containerRef: HTMLDivElement | undefined; + let fieldRef: HTMLDivElement | undefined; + let dropdownRef: HTMLDivElement | undefined; + + const externalValue = () => readAccessor(props.value, ""); + + const updateDropdownPos = () => { + if (!fieldRef) return; + const rect = fieldRef.getBoundingClientRect(); + setDropdownPos({ top: rect.bottom + 4, left: rect.left, width: rect.width }); + }; + + const dropdownStyle = () => { + const pos = dropdownPos(); + const maxW = Math.min(400, window.innerWidth - pos.left - 8); + return `position:fixed;top:${pos.top}px;left:${pos.left}px;min-width:${Math.max(pos.width, 16 * 16)}px;width:max-content;max-width:${maxW}px;z-index:200;`; + }; + + createEffect(() => { + if (open()) { + updateDropdownPos(); + let rafId: number; + const trackPosition = () => { + updateDropdownPos(); + rafId = requestAnimationFrame(trackPosition); + }; + rafId = requestAnimationFrame(trackPosition); + onCleanup(() => cancelAnimationFrame(rafId)); + } + }); + + createEffect(() => { + if (!editing()) { + setLocalValue(externalValue()); + } + }); + + const hasValue = () => !!localValue(); + + const displayValue = () => formatDisplayDate(localValue()); + + const inputValue = () => editing() ? draft() : displayValue(); + + const commitValue = (raw: string) => { + const parsed = parseDateInput(raw); + setLocalValue(parsed); + setDraft(parsed ? formatDisplayDate(parsed) : ""); + if (typeof props.onchange === "function") props.onchange(parsed); + }; + + const handleSelect = (dateStr: string) => { + setEditing(false); + setLocalValue(dateStr); + setDraft(formatDisplayDate(dateStr)); + if (typeof props.onchange === "function") props.onchange(dateStr); + setOpen(false); + }; + + const handleClear = (e: MouseEvent) => { + e.stopPropagation(); + setEditing(false); + setLocalValue(""); + setDraft(""); + if (typeof props.onchange === "function") props.onchange(""); + setOpen(false); + }; + + const handleClickOutside = (e: MouseEvent) => { + const target = e.target as Node; + const inContainer = containerRef?.contains(target); + const inDropdown = dropdownRef?.contains(target); + if (!inContainer && !inDropdown) { + if (editing()) { + commitValue(draft()); + setEditing(false); + } + setOpen(false); + } + }; + + onMount(() => { + document.addEventListener("click", handleClickOutside); + }); + + onCleanup(() => { + document.removeEventListener("click", handleClickOutside); + }); + + const openCalendar = (_e: MouseEvent) => { + updateDropdownPos(); + setOpen(true); + }; + + const toggleCalendar = (e: MouseEvent) => { + e.stopPropagation(); + if (!open()) updateDropdownPos(); + setOpen((v) => !v); + }; + + const handleInputFocus = (_e: FocusEvent) => { + setEditing(true); + setDraft(displayValue()); + }; + + const handleInput = (e: InputEvent & { currentTarget: HTMLInputElement }) => { + setDraft(e.currentTarget.value); + }; + + const handleInputBlur = (_e: FocusEvent) => { + commitValue(draft()); + setEditing(false); + }; + + const inputCls = () => "w-full" + (props.clearable && hasValue() ? " pr-14" : " pr-9"); + + return ( +
containerRef = el}> +
fieldRef = el} onclick={openCalendar}> + + + + + +
+ + +
dropdownRef = el} + data-floating-content="true" + class={DATE_PICKER_DROPDOWN} + style={dropdownStyle()} + onclick={(e: MouseEvent) => e.stopPropagation()} + > + +
+
+
+
+ ); +} + +const MONTHS_SHORT = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]; + +function CalendarDropdownDOB(props: CalendarDropdownProps) { + const today = new Date(); + const currentYear = today.getFullYear(); + const years = Array.from({ length: 120 }, (_, i) => currentYear - i); + + const [viewDate, setViewDate] = createSignal(props.selected ? new Date(props.selected as string) : today); + const [key, setKey] = createSignal(0); + + createEffect(() => { + if (props.selected) { + const d = new Date(props.selected as string); + if (!isNaN(d.getTime())) { + setViewDate(d); + } + } + }); + + const currentMonth = () => viewDate().getMonth(); + const currentYearView = () => viewDate().getFullYear(); + + const getDays = () => { + const year = currentYearView(); + const month = currentMonth(); + const daysInMonth = getDaysInMonth(year, month); + const firstDay = getFirstDayOfMonth(year, month); + + const daysArray: (Date | null)[] = []; + for (let i = 0; i < firstDay; i++) { + daysArray.push(null); + } + for (let i = 1; i <= daysInMonth; i++) { + daysArray.push(new Date(year, month, i)); + } + return daysArray; + }; + + const handleMonthChange = (e: Event) => { + const month = parseInt((e.target as HTMLSelectElement).value); + if (!isNaN(month)) { + setViewDate(new Date(currentYearView(), month, 1)); + } + }; + + const handleYearChange = (e: Event) => { + const year = parseInt((e.target as HTMLSelectElement).value); + if (!isNaN(year)) { + setViewDate(new Date(year, currentMonth(), 1)); + } + }; + + const isSelected = (date: Date | null): boolean => { + if (!date || !props.selected) return false; + const sel = new Date(props.selected as string); + if (isNaN(sel.getTime())) return false; + return date.getFullYear() === sel.getFullYear() && + date.getMonth() === sel.getMonth() && + date.getDate() === sel.getDate(); + }; + + const isToday = (date: Date | null): boolean => { + if (!date) return false; + return date.getFullYear() === today.getFullYear() && + date.getMonth() === today.getMonth() && + date.getDate() === today.getDate(); + }; + + const selectDate = (date: Date | null) => { + if (!date) return; + const y = date.getFullYear(); + const m = String(date.getMonth() + 1).padStart(2, '0'); + const d = String(date.getDate()).padStart(2, '0'); + props.onSelect?.(`${y}-${m}-${d}`); + }; + + const goPrev = (e: MouseEvent) => { + e.stopPropagation(); + setViewDate(new Date(currentYearView(), currentMonth() - 1, 1)); + setKey(k => k + 1); + }; + + const goNext = (e: MouseEvent) => { + e.stopPropagation(); + setViewDate(new Date(currentYearView(), currentMonth() + 1, 1)); + setKey(k => k + 1); + }; + + return ( +
+
+ + + + +
+
+ {(day) =>
{day}
}
+
+
+ {(date) => ( + + )} +
+
+ ); +} + +export function DateOfBirthPicker(props: DatePickerProps) { + const [open, setOpen] = createSignal(false); + const [editing, setEditing] = createSignal(false); + const [draft, setDraft] = createSignal(""); + let containerRef: HTMLDivElement | undefined; + + const isoValue = () => readAccessor(props.value, ""); + + const displayValue = () => formatDisplayDate(isoValue()); + + const inputValue = () => editing() ? draft() : displayValue(); + + const commitValue = (raw: string) => { + const parsed = parseDateInput(raw); + setDraft(parsed ? formatDisplayDate(parsed) : ""); + props.onchange?.(parsed); + }; + + const handleSelect = (dateStr: string) => { + setEditing(false); + setDraft(formatDisplayDate(dateStr)); + props.onchange?.(dateStr); + setOpen(false); + }; + + const handleClickOutside = (e: MouseEvent) => { + if (containerRef && !containerRef.contains(e.target as Node)) { + if (editing()) { + commitValue(draft()); + setEditing(false); + } + setOpen(false); + } + }; + + onMount(() => { + document.addEventListener("click", handleClickOutside); + }); + + onCleanup(() => { + document.removeEventListener("click", handleClickOutside); + }); + + const openCalendar = (_e: MouseEvent) => { + setOpen(true); + }; + + const toggleCalendar = (e: MouseEvent) => { + e.stopPropagation(); + setOpen((v) => !v); + }; + + return ( +
containerRef = el}> +
+ { setEditing(true); setDraft(displayValue()); }} + oninput={(e: InputEvent & { currentTarget: HTMLInputElement }) => setDraft(e.currentTarget.value)} + onblur={(_e: FocusEvent) => { commitValue(draft()); setEditing(false); }} + class="w-full pr-9" + /> + +
+ +
e.stopPropagation()}> + +
+
+
+ ); +} diff --git a/web/kit/EnvBadge.tsx b/web/kit/EnvBadge.tsx new file mode 100644 index 00000000..e3c0f524 --- /dev/null +++ b/web/kit/EnvBadge.tsx @@ -0,0 +1,37 @@ +import { ENV_TYPE, isNonProdEnv } from "../env.ts"; + +// The deployment environment is baked into the bundle at build time (env.ts +// reads esbuild's __ENV_TYPE__ define), so it is a plain module constant here — +// no runtime globalThis read. Re-exported for callers that historically imported +// isNonProdEnv from this module. +export { isNonProdEnv }; + +// Browser-only: tag so any env-specific styling can hook in. Guarded +// because the SSR DOM shim has no document.documentElement. +(function markEnvOnRoot() { + if (isNonProdEnv() && typeof document !== "undefined" && document.documentElement) { + document.documentElement.dataset.env = ENV_TYPE; + } +})(); + +const BADGE_BASE = "pointer-events-none select-none absolute top-0 -right-2 z-10 " + + "rounded px-1 py-px text-[0.5rem] font-bold uppercase leading-none tracking-wider shadow-sm"; + +/** + * Small environment badge pinned to the corner of the app logo. Renders + * nothing in production. Drop it inside a `position: relative` wrapper around + * the logo image so it anchors to the logo's top-right corner. + */ +export function EnvBadge() { + if (!isNonProdEnv()) return null; + + const label = ENV_TYPE === "development" ? "DEV" + : ENV_TYPE === "staging" ? "STAGING" + : ENV_TYPE.toUpperCase(); + + const tone = ENV_TYPE === "development" ? "bg-orange-500 text-white" + : ENV_TYPE === "staging" ? "bg-yellow-400 text-gray-900" + : "bg-neutral-700 text-white"; + + return {label}; +} diff --git a/web/kit/Floating.tsx b/web/kit/Floating.tsx new file mode 100644 index 00000000..1f642d40 --- /dev/null +++ b/web/kit/Floating.tsx @@ -0,0 +1,397 @@ +import { createContext, useContext, createSignal, createEffect, createRenderEffect, onCleanup, Show, getOwner, runWithOwner, JSXElement } from "solid-js"; +import { Portal } from "solid-js/web"; + +export type Placement = "top" | "top-start" | "top-end" | "bottom" | "bottom-start" | "bottom-end" | "left" | "left-start" | "left-end" | "right" | "right-start" | "right-end"; + +export interface PositionOptions { + placement?: Placement; + offset?: number; + flip?: boolean; + shift?: boolean; + shiftPadding?: number; +} + +interface Position { + top: number; + left: number; + placement: string; +} + +class FloatingManager { + activeCloseCallback: (() => void) | null = null; + + register(closeCallback: () => void) { + if (this.activeCloseCallback && this.activeCloseCallback !== closeCallback) { + this.activeCloseCallback(); + } + this.activeCloseCallback = closeCallback; + } + + unregister(closeCallback: () => void) { + if (this.activeCloseCallback === closeCallback) { + this.activeCloseCallback = null; + } + } + + closeActive() { + if (this.activeCloseCallback) { + this.activeCloseCallback(); + this.activeCloseCallback = null; + } + } +} + +const floatingManager = new FloatingManager(); + +// Open floating-content elements in open order, so outside-click handling can tell +// a descendant (opened later, e.g. a menu inside a popover) from an ancestor: a +// floating stays open for clicks inside itself or a later-opened floating, and +// closes for clicks anywhere else (including its parent popover). +const openFloatings: HTMLElement[] = []; + +export interface FloatingContextValue { + isOpen: () => boolean; + setIsOpen: (open: boolean) => void; + readonly triggerRef: HTMLElement | undefined; + setTriggerRef: (el: HTMLElement) => void; + readonly floatingRef: HTMLElement | undefined; + setFloatingRef: (el: HTMLElement) => void; + position: () => Position | null; + options: Required; + cancelHoverClose: () => void; + scheduleHoverClose: (delay: number) => void; +} + +const FloatingContext = createContext(null); + +export function useFloatingContext(): FloatingContextValue { + const context = useContext(FloatingContext); + if (!context) { + throw new Error("Floating components must be used within a FloatingRoot"); + } + return context; +} + +function calculatePosition(triggerRect: DOMRect, floatingRect: DOMRect, options: Required): Position { + const { placement, offset, flip, shift, shiftPadding } = options; + const parts = placement.split("-"); + const basePlacement = parts[0]; + const alignment = parts[1] || "center"; + + let top = 0; + let left = 0; + let finalPlacement: string = placement; + + switch (basePlacement) { + case "top": top = triggerRect.top - floatingRect.height - offset; break; + case "bottom": top = triggerRect.bottom + offset; break; + case "left": left = triggerRect.left - floatingRect.width - offset; break; + case "right": left = triggerRect.right + offset; break; + } + + if (basePlacement === "top" || basePlacement === "bottom") { + switch (alignment) { + case "start": left = triggerRect.left; break; + case "end": left = triggerRect.right - floatingRect.width; break; + default: left = triggerRect.left + (triggerRect.width - floatingRect.width) / 2; + } + } else { + switch (alignment) { + case "start": top = triggerRect.top; break; + case "end": top = triggerRect.bottom - floatingRect.height; break; + default: top = triggerRect.top + (triggerRect.height - floatingRect.height) / 2; + } + } + + if (flip) { + const vh = window.innerHeight; + const vw = window.innerWidth; + if (basePlacement === "bottom" && top + floatingRect.height > vh - shiftPadding) { + const flippedTop = triggerRect.top - floatingRect.height - offset; + if (flippedTop >= shiftPadding) { top = flippedTop; finalPlacement = placement.replace("bottom", "top"); } + } else if (basePlacement === "top" && top < shiftPadding) { + const flippedTop = triggerRect.bottom + offset; + if (flippedTop + floatingRect.height <= vh - shiftPadding) { top = flippedTop; finalPlacement = placement.replace("top", "bottom"); } + } else if (basePlacement === "right" && left + floatingRect.width > vw - shiftPadding) { + const flippedLeft = triggerRect.left - floatingRect.width - offset; + if (flippedLeft >= shiftPadding) { left = flippedLeft; finalPlacement = placement.replace("right", "left"); } + } else if (basePlacement === "left" && left < shiftPadding) { + const flippedLeft = triggerRect.right + offset; + if (flippedLeft + floatingRect.width <= vw - shiftPadding) { left = flippedLeft; finalPlacement = placement.replace("left", "right"); } + } + } + + if (shift) { + const vh = window.innerHeight; + const vw = window.innerWidth; + if (left < shiftPadding) left = shiftPadding; + else if (left + floatingRect.width > vw - shiftPadding) left = vw - floatingRect.width - shiftPadding; + if (top < shiftPadding) top = shiftPadding; + else if (top + floatingRect.height > vh - shiftPadding) top = vh - floatingRect.height - shiftPadding; + } + + return { top, left, placement: finalPlacement }; +} + +interface FloatingRootProps { + // Solid's `h` auto-invokes zero-arg function props on read, so each + // of these is simply the unwrapped value inside the component body. + open?: boolean; + onOpenChange?: (open: boolean) => void; + placement?: Placement; + offset?: number; + flip?: boolean; + shift?: boolean; + shiftPadding?: number; + // Opt out of the global single-open manager. Use for a floating nested + // inside another (e.g. a tooltip inside a popover) so opening it doesn't + // close its ancestor, and so the ancestor opening doesn't close it. + standalone?: boolean; + children?: JSXElement; +} + +export function FloatingRoot(props: FloatingRootProps) { + const [internalOpen, setInternalOpen] = createSignal(false); + const isControlled = () => props.open !== undefined; + const isOpen = () => isControlled() ? !!props.open : internalOpen(); + + let triggerRef: HTMLElement | undefined; + let floatingRef: HTMLElement | undefined; + const [position, setPosition] = createSignal(null); + + let hoverCloseTimeout: ReturnType | null = null; + + const options: Required = { + placement: props.placement ?? "bottom-start", + offset: props.offset ?? 4, + flip: props.flip ?? true, + shift: props.shift ?? true, + shiftPadding: props.shiftPadding ?? 8, + }; + + const closeThis = () => { + if (isControlled()) props.onOpenChange?.(false); + else setInternalOpen(false); + }; + + const setIsOpen = (open: boolean) => { + if (!props.standalone) { + if (open) floatingManager.register(closeThis); + else floatingManager.unregister(closeThis); + } + if (isControlled()) props.onOpenChange?.(open); + else setInternalOpen(open); + if (open) { + requestAnimationFrame(() => updatePosition()); + } else { + setPosition(null); + } + }; + + onCleanup(() => { + floatingManager.unregister(closeThis); + if (hoverCloseTimeout) clearTimeout(hoverCloseTimeout); + }); + + const cancelHoverClose = () => { + if (hoverCloseTimeout) { clearTimeout(hoverCloseTimeout); hoverCloseTimeout = null; } + }; + + const scheduleHoverClose = (delay: number) => { + cancelHoverClose(); + hoverCloseTimeout = setTimeout(() => setIsOpen(false), delay); + }; + + const updatePosition = () => { + if (!triggerRef || !floatingRef) return; + const triggerRect = triggerRef.getBoundingClientRect(); + const floatingRect = floatingRef.getBoundingClientRect(); + setPosition(calculatePosition(triggerRect, floatingRect, options)); + }; + + createRenderEffect(() => { + if (!isOpen()) { setPosition(null); return; } + const rafId = requestAnimationFrame(updatePosition); + const handleUpdate = () => updatePosition(); + window.addEventListener("scroll", handleUpdate, true); + window.addEventListener("resize", handleUpdate); + onCleanup(() => { + cancelAnimationFrame(rafId); + window.removeEventListener("scroll", handleUpdate, true); + window.removeEventListener("resize", handleUpdate); + }); + }); + + const value: FloatingContextValue = { + isOpen, + setIsOpen, + get triggerRef() { return triggerRef; }, + setTriggerRef: (el: HTMLElement) => { triggerRef = el; }, + get floatingRef() { return floatingRef; }, + setFloatingRef: (el: HTMLElement) => { floatingRef = el; }, + position, + options, + cancelHoverClose, + scheduleHoverClose, + }; + + return {props.children}; +} + +interface FloatingTriggerProps { + openOnHover?: boolean; + hoverDelay?: number; + hoverCloseDelay?: number; + class?: string; + title?: string; + children?: JSXElement; +} + +export function FloatingTrigger(props: FloatingTriggerProps) { + const ctx = useFloatingContext(); + let hoverOpenTimeout: ReturnType | null = null; + + const clearOpenTimeout = () => { + if (hoverOpenTimeout) { clearTimeout(hoverOpenTimeout); hoverOpenTimeout = null; } + }; + onCleanup(clearOpenTimeout); + + const handleMouseEnter = () => { + if (!props.openOnHover) return; + ctx.cancelHoverClose(); + clearOpenTimeout(); + hoverOpenTimeout = setTimeout(() => ctx.setIsOpen(true), props.hoverDelay ?? 0); + }; + + const handleMouseLeave = () => { + if (!props.openOnHover) return; + clearOpenTimeout(); + ctx.scheduleHoverClose(props.hoverCloseDelay ?? 150); + }; + + const handleClick = () => { + if (props.openOnHover) return; + ctx.setIsOpen(!ctx.isOpen()); + }; + + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + ctx.setIsOpen(!ctx.isOpen()); + } else if (e.key === "Escape" && ctx.isOpen()) { + ctx.setIsOpen(false); + } + }; + + return ( + + ); +} + +interface FloatingContentProps { + class?: string; + style?: Record; + onMouseEnter?: () => void; + onMouseLeave?: () => void; + children?: JSXElement; +} + +export function FloatingContent(props: FloatingContentProps) { + const ctx = useFloatingContext(); + + // Capture FloatingContent's own owner (which sits inside whatever provider + // wraps us — e.g. Menu's MenuContext). We resolve props.children under this + // owner below instead of letting the resolve them in its deferred + // scope: children handed straight to a Portal get instantiated in the + // Portal's owner, dropping the surrounding provider from their owner chain, + // so a MenuItem inside a Menu's Portal throws "must be used within a Menu" + // (this bites when a solid-js/html page like AppLayout.ts feeds children in). + // Using runWithOwner (not the children() helper) keeps the exact same one-shot + // insert behavior the div had before — no extra reactive memo over the content, + // which that "always render" design is sensitive to. + const owner = getOwner(); + + // Always render the div — toggle visibility via CSS. Avoids the + // Show-based mount/unmount thrash where reactive scope disposal + // was immediately destroying the inner component on open. + const handleClickOutside = (e: MouseEvent) => { + if (!ctx.isOpen()) return; + const el = e.target instanceof Element ? e.target : null; + const self = ctx.floatingRef; + // Inside our own content or trigger → keep open. + if (el && self && self.contains(el)) return; + if (el && ctx.triggerRef && ctx.triggerRef.contains(el)) return; + // Keep open when the click lands in a descendant floating layer: either a + // FloatingContent opened AFTER us (higher in the open stack), or an + // unregistered floating (e.g. a combobox/select dropdown opened from inside + // us — these never join the stack and are always leaf descendants). Only a + // click in an ancestor/sibling FloatingContent, or fully outside, closes us. + const clicked = el && (el.closest("[data-floating-content]") as HTMLElement | null); + if (clicked && self) { + const ci = openFloatings.indexOf(clicked); + if (ci < 0 || ci > openFloatings.indexOf(self)) return; + } + ctx.setIsOpen(false); + }; + const handleEscape = (e: KeyboardEvent) => { + if (!ctx.isOpen() || e.key !== "Escape") return; + // Only the topmost open floating closes on Escape, so dismissing a nested + // menu (e.g. an insert menu inside an editor popover) doesn't also close + // its parent. The stack is in open order, so the last entry is innermost. + const self = ctx.floatingRef; + if (self && openFloatings.length > 0 && openFloatings[openFloatings.length - 1] !== self) return; + ctx.setIsOpen(false); + }; + document.addEventListener("mousedown", handleClickOutside); + document.addEventListener("keydown", handleEscape); + // Track open order so descendant vs ancestor can be distinguished above. + createEffect(() => { + const self = ctx.floatingRef; + if (!self) return; + const i = openFloatings.indexOf(self); + if (ctx.isOpen()) { if (i < 0) openFloatings.push(self); } + else if (i >= 0) openFloatings.splice(i, 1); + }); + onCleanup(() => { + document.removeEventListener("mousedown", handleClickOutside); + document.removeEventListener("keydown", handleEscape); + const self = ctx.floatingRef; + const i = self ? openFloatings.indexOf(self) : -1; + if (i >= 0) openFloatings.splice(i, 1); + }); + + // Render through a Portal (to document.body) so the popover escapes any + // ancestor that establishes a containing block for `position: fixed` — most + // importantly the Modal's animated `transform`, which would otherwise make + // our viewport-relative top/left resolve relative to the modal instead. + return ( + +
ctx.setFloatingRef(el)} role="menu" data-floating-content="true" class={props.class || ""} style={{ + position: "fixed", + display: ctx.isOpen() ? "block" : "none", + // Kept laid-out-but-invisible until position() is computed (one + // rAF after open) so it never flashes at the top-left 0,0 origin. + visibility: ctx.isOpen() && ctx.position() ? "visible" : "hidden", + top: (ctx.position()?.top ?? 0) + "px", + left: (ctx.position()?.left ?? 0) + "px", + // Above the Modal container (z-[100]) so popovers opened from + // inside a modal aren't hidden behind it now that we portal. + "z-index": 110, + ...(props.style || {}), + }} onMouseEnter={() => props.onMouseEnter?.()} onMouseLeave={() => props.onMouseLeave?.()}>{runWithOwner(owner, () => props.children)}
+
+ ); +} + +export function useFloatingHover(openOnHover: boolean, hoverCloseDelay: number = 150) { + const ctx = useFloatingContext(); + const handleMouseEnter = () => { + if (!openOnHover) return; + ctx.cancelHoverClose(); + }; + const handleMouseLeave = () => { + if (!openOnHover) return; + ctx.scheduleHoverClose(hoverCloseDelay); + }; + return { onMouseEnter: handleMouseEnter, onMouseLeave: handleMouseLeave }; +} diff --git a/web/kit/Formatters.ts b/web/kit/Formatters.ts new file mode 100644 index 00000000..139a430c --- /dev/null +++ b/web/kit/Formatters.ts @@ -0,0 +1,120 @@ +// Format a number as a US phone number: (XXX) XXX-XXXX. +export function formatPhoneNumber(number: string | number): string { + const digits = String(number).replace(/\D/g, "").padStart(10, "0").slice(0, 10); + const areaCode = digits.slice(0, 3); + const centralOfficeCode = digits.slice(3, 6); + const lineNumber = digits.slice(6, 10); + return "(" + areaCode + ") " + centralOfficeCode + "-" + lineNumber; +} + +// Format a number as a US zip code (5 or 9 digits). +export function formatZipCode(number: string | number): string { + const num = typeof number === "string" ? parseInt(number, 10) : number; + + if (num <= 99999) { + return String(num).padStart(5, "0"); + } + + const digits = String(num).padStart(9, "0"); + const zipCode = digits.slice(0, 5); + const plus4 = digits.slice(5, 9); + return zipCode + "-" + plus4; +} + +// Format a number as a US Tax ID (EIN): XX-XXXXXXX. +export function formatTaxId(number: string | number): string { + const digits = String(number).replace(/\D/g, "").padStart(9, "0").slice(0, 9); + const prefix = digits.slice(0, 2); + const identifier = digits.slice(2, 9); + return prefix + "-" + identifier; +} + +export function formatNumber(number: number): string { + return new Intl.NumberFormat("en-US").format(number); +} + +export function formatDecimal(number: number, decimalPlaces: number = 2): string { + return new Intl.NumberFormat("en-US", { + minimumFractionDigits: decimalPlaces, + maximumFractionDigits: decimalPlaces, + }).format(number); +} + +// State Code Utilities + +const stateCodeMap: Record = { + "Alabama": "AL", "Alaska": "AK", "Arizona": "AZ", "Arkansas": "AR", "California": "CA", + "Colorado": "CO", "Connecticut": "CT", "Delaware": "DE", "District of Columbia": "DC", "Florida": "FL", + "Georgia": "GA", "Hawaii": "HI", "Idaho": "ID", "Illinois": "IL", "Indiana": "IN", + "Iowa": "IA", "Kansas": "KS", "Kentucky": "KY", "Louisiana": "LA", "Maine": "ME", + "Maryland": "MD", "Massachusetts": "MA", "Michigan": "MI", "Minnesota": "MN", "Mississippi": "MS", + "Missouri": "MO", "Montana": "MT", "Nebraska": "NE", "Nevada": "NV", "New Hampshire": "NH", + "New Jersey": "NJ", "New Mexico": "NM", "New York": "NY", "North Carolina": "NC", "North Dakota": "ND", + "Ohio": "OH", "Oklahoma": "OK", "Oregon": "OR", "Pennsylvania": "PA", "Puerto Rico": "PR", + "Rhode Island": "RI", "South Carolina": "SC", "South Dakota": "SD", "Tennessee": "TN", "Texas": "TX", + "Utah": "UT", "Vermont": "VT", "Virgin Islands": "VI", "Virginia": "VA", "Washington": "WA", + "West Virginia": "WV", "Wisconsin": "WI", "Wyoming": "WY", +}; + +export function stateToStateCode(state: string): string { + if (stateCodeMap[state]) { + return stateCodeMap[state]; + } + + const stateLower = state.toLowerCase(); + for (const [stateName, code] of Object.entries(stateCodeMap)) { + if (stateName.toLowerCase() === stateLower) { + return code; + } + } + + return ""; +} + +export function stateCodeToState(code: string): string { + for (const [state, stateCode] of Object.entries(stateCodeMap)) { + if (stateCode === code.toUpperCase()) { + return state; + } + } + return ""; +} + +export function isValidStateCode(code: string): boolean { + return Object.values(stateCodeMap).includes(code.toUpperCase()); +} + +export function formatDate(date: string | Date): string { + const d = typeof date === "string" ? new Date(date) : date; + return new Intl.DateTimeFormat("en-US", { + month: "2-digit", + day: "2-digit", + year: "numeric", + }).format(d); +} + +export function formatDateLong(date: string | Date): string { + const d = typeof date === "string" ? new Date(date) : date; + return new Intl.DateTimeFormat("en-US", { + month: "long", + day: "numeric", + year: "numeric", + }).format(d); +} + +export function formatDateTime(date: string | Date): string { + const d = typeof date === "string" ? new Date(date) : date; + return new Intl.DateTimeFormat("en-US", { + month: "2-digit", + day: "2-digit", + year: "numeric", + hour: "numeric", + minute: "2-digit", + hour12: true, + }).format(d); +} + +export function formatPercent(value: number, decimalPlaces: number = 2, isDecimal: boolean = false): string { + const percent = isDecimal ? value * 100 : value; + return percent.toFixed(decimalPlaces) + "%"; +} diff --git a/web/kit/Forms.tsx b/web/kit/Forms.tsx new file mode 100644 index 00000000..91d2d2bc --- /dev/null +++ b/web/kit/Forms.tsx @@ -0,0 +1,1898 @@ +import { createSignal, createEffect, createMemo, onCleanup, For, Show, splitProps, JSX, JSXElement } from "solid-js"; +import { Portal } from "solid-js/web"; +import {IconInline} from "./Icons.tsx"; +import { readAccessor } from "../utils/accessors.ts"; + +// --- Tailwind class building helpers --- +const INPUT_BASE = "bg-white block w-full border rounded-default shadow-xs text-sm focus:outline-2 focus:outline-offset-1 disabled:bg-neutral-100 disabled:cursor-not-allowed"; +const INPUT_BASE_DARK = "bg-dark text-text-on-dark placeholder:text-text-on-dark-faint block w-full border rounded-default shadow-xs text-sm focus:outline-2 focus:outline-offset-1 disabled:opacity-50 disabled:cursor-not-allowed"; + +const CONTROL_H = "h-[38px]"; +const CONTROL_H_SM = "h-[30px]"; +const _controlH = (small?: boolean): string => (small ? CONTROL_H_SM : CONTROL_H); + +function _fieldBorder(error?: unknown, success?: unknown, onDark?: boolean): string { + const borderNormal = onDark ? "border-border-on-dark focus:outline-sky-500" : "border-neutral-300 focus:outline-sky-500"; + return error ? "border-red-500 focus:outline-red-500" + : success ? "border-green-500 focus:outline-green-500" + : borderNormal; +} + +function _inputCls(small?: boolean, error?: unknown, success?: unknown, extra?: string, onDark?: boolean): string { + const base = onDark ? INPUT_BASE_DARK : INPUT_BASE; + return base + " " + _controlH(small) + (small ? " p-1" : " p-2") + + " " + _fieldBorder(error, success, onDark) + + (extra ? " " + extra : ""); +} + +// Textarea shares the input look but must grow with its content, so it opts out +// of the fixed control height. +function _textareaCls(small?: boolean, error?: unknown, extra?: string, onDark?: boolean): string { + const base = onDark ? INPUT_BASE_DARK : INPUT_BASE; + return base + (small ? " p-1" : " p-2") + + " " + _fieldBorder(error, false, onDark) + + (extra ? " " + extra : ""); +} + +function _prefixCls(small?: boolean, error?: unknown, onDark?: boolean): string { + const base = onDark + ? "bg-dark-raised text-text-on-dark-muted border shadow-xs border-r-0 flex items-center shrink-0 rounded-l-default" + : "bg-neutral-100 border shadow-xs border-r-0 flex items-center shrink-0 rounded-l-default"; + const borderNormal = onDark ? "border-border-on-dark" : "border-neutral-300"; + return base + + (error ? " border-red-500" : " " + borderNormal) + + (small ? " p-1 text-sm" : " p-2"); +} + +const FORM_ERROR = "block text-red-600 text-xs mt-1"; +const FORM_SUCCESS = "block text-green-600 text-xs mt-1"; +const INPUT_GROUP = "flex flex-row items-stretch w-full text-sm"; + +const TRIGGER_BASE = "bg-white border border-neutral-300 rounded-default shadow-xs text-sm w-full text-left flex items-center justify-between gap-2 cursor-pointer disabled:bg-neutral-100 disabled:cursor-not-allowed"; +const TRIGGER_BASE_DARK = "bg-dark-raised border border-border-on-dark text-text-on-dark rounded-default shadow-xs text-sm w-full text-left flex items-center justify-between gap-2 cursor-pointer hover:border-border-on-dark-hover disabled:opacity-50 disabled:cursor-not-allowed"; +const DROPDOWN = "bg-white border border-neutral-300 rounded-default shadow-lg max-h-60 overflow-auto"; +const DROPDOWN_DARK = "bg-dark-raised border border-border-on-dark rounded-default shadow-lg max-h-60 overflow-auto"; +const DROPDOWN_SEARCH_WRAP = "sticky top-0 bg-white border-b border-neutral-200 p-2"; +const DROPDOWN_SEARCH_WRAP_DARK = "sticky top-0 bg-dark-raised border-b border-border-on-dark p-2"; +const DROPDOWN_SEARCH_INPUT = "w-full bg-white border border-neutral-300 rounded-default shadow-xs text-sm p-1 focus:outline-2 focus:outline-sky-500 focus:outline-offset-1"; +const DROPDOWN_SEARCH_INPUT_DARK = "w-full bg-dark border border-border-on-dark text-text-on-dark placeholder:text-text-on-dark-faint rounded-default shadow-xs text-sm p-1 focus:outline-2 focus:outline-sky-500 focus:outline-offset-1"; +const DROPDOWN_OPTION = "w-full text-left p-2 text-sm cursor-pointer flex items-center gap-2 bg-transparent border-none hover:bg-neutral-100 disabled:text-neutral-400 disabled:cursor-not-allowed whitespace-nowrap"; +const DROPDOWN_OPTION_DARK = "w-full text-left p-2 text-sm cursor-pointer flex items-center gap-2 bg-transparent border-none text-text-on-dark hover:bg-white/5 disabled:text-text-on-dark-faint disabled:cursor-not-allowed whitespace-nowrap"; +const DROPDOWN_OPTION_HIGHLIGHT = "bg-neutral-100"; +const DROPDOWN_OPTION_HIGHLIGHT_DARK = "bg-white/10"; +const DROPDOWN_NO_RESULTS = "p-2 text-sm text-neutral-500 text-center"; +const DROPDOWN_NO_RESULTS_DARK = "p-2 text-sm text-text-on-dark-muted text-center"; +const SELECT_ALL_WRAP = "border-b border-neutral-200"; +const SELECT_ALL_BTN = "w-full text-left p-2 text-sm cursor-pointer text-neutral-600 font-medium bg-transparent border-none hover:bg-neutral-100"; + +// -- Shared types -------------------------------------------------- +type CommonInputAttrs = + | "autocomplete" + | "disabled" + | "id" + | "inputmode" + | "maxlength" + | "name" + | "onblur" + | "onchange" + | "onclick" + | "onfocus" + | "oninput" + | "onkeydown" + | "placeholder" + | "readonly" + | "required" + | "value" + // Legacy attributes (camel case) + | "inputMode" + | "maxLength" + | "onBlur" + | "onchange" + | "onclick" + | "onFocus" + | "onInput" + | "onKeyDown"; + +// Subsets valid for typeof props.ref === "function" && props.ref(el)} + class={cls()} + type={props.type} + value={inputValue()} + placeholder={props.placeholder} + autocomplete={props.autocomplete || (props.passwordManagerIgnore ? "off" : undefined)} + inputmode={props.inputmode} + data-1p-ignore={props.passwordManagerIgnore} + data-lpignore={props.passwordManagerIgnore ? "other" : undefined} + data-form-type={props.passwordManagerIgnore} + data-bwignore={props.passwordManagerIgnore} + data-protonpass-ignore={props.passwordManagerIgnore} + disabled={props.disabled} + maxlength={props.maxlength} + min={props.min} + max={props.max} + step={props.step} + name={props.name} + id={props.id} + oninput={props.oninput} + onchange={props.onchange} + onBlur={props.onblur} + onFocus={props.onfocus} + onKeyDown={props.onkeydown} + + /> + + {props.error} + + + {props.success} + + ; +} + +export function FormInputGroup(props: FormInputGroupProps) { + const pfxCls = () => _prefixCls(props.small, props.error, props.onDark); + const inputCls = () => _inputCls(props.small, props.error, props.success, "rounded-l-none" + (props.class ? " " + props.class : ""), props.onDark); + const inputValue = () => readAccessor(props.value, ""); + return
+
+ {props.prefix} + + typeof props.ref === "function" && props.ref(el)} + class={inputCls()} + type={props.type} + value={inputValue()} + placeholder={props.placeholder} + autocomplete={props.autocomplete || (props.passwordManagerIgnore ? "off" : undefined)} + inputmode={props.inputmode} + data-1p-ignore={props.passwordManagerIgnore} + data-lpignore={props.passwordManagerIgnore ? "other" : undefined} + data-form-type={props.passwordManagerIgnore} + data-bwignore={props.passwordManagerIgnore} + data-protonpass-ignore={props.passwordManagerIgnore} + disabled={props.disabled} + maxlength={props.maxlength} + name={props.name} + id={props.id} + oninput={props.oninput} + onchange={props.onchange} + onBlur={props.onblur} + onFocus={props.onfocus} + onKeyDown={props.onkeydown} + /> + +
+ + {props.error} + + + {props.success} + +
; +} + +export function FormNumberInput(props: FormNumberInputProps) { + const [local, inputProps] = splitProps(props, ["small", "ref", "class", "oninput", "int", "unsigned"]); + var regex: RegExp; + + if (local.int) { + regex = local.unsigned ? /[^0-9]/g : /[^-0-9]/g; + } else { + regex = local.unsigned ? /[^0-9.]/g : /[^-0-9.]/g; + } + + const handleInput = (e) => { + if (!e || !e.currentTarget) return; + const input = e.currentTarget; + input.value = input.value.replace(regex, ""); + + // Remove hyphens that aren't at the start of the number + if (!local.unsigned) { + const isNegative = input.value.startsWith("-"); + input.value = input.value.replace(/-/g, ""); + if (isNegative) input.value = "-" + input.value; + } + const parts = input.value.split("."); + if (parts.length > 2) { + input.value = parts[0] + "." + parts.slice(1).join(""); + } + if (typeof local.oninput === "function") { + local.oninput(e); + } + }; + + return typeof local.ref === "function" && local.ref(el)} + type="text" + inputMode="decimal" + oninput={handleInput} + class={local.class} + small={local.small} + {...inputProps} + />; +} + +export function FormCarNumberInput(props: FormInputProps) { + const [local, inputProps] = splitProps(props, ["small", "ref", "class", "oninput"]); + + const handleInput = (e) => { + if (!e || !e.currentTarget) return; + const input = e.currentTarget; + const raw = input.value.toUpperCase().replace(/[^0-9A-Z]/g, ""); + const match = raw.match(/^([0-9]{0,2})([A-Z]{0,2})/); + input.value = match ? match[1] + match[2] : ""; + if (typeof local.oninput === "function") { + local.oninput(e); + } + }; + + return typeof local.ref === "function" && local.ref(el)} + type="text" + maxLength="4" + oninput={handleInput} + class={local.class} + small={local.small} + placeholder="e.g. 28" + {...inputProps} + />; +} + +export function FormCurrencyInput(props: {hideSymbol?: boolean} & FormInputProps) { + const [local, inputProps] = splitProps(props, ["small", "ref", "class", "oninput", "hideSymbol"]); + + const handleInput = (e) => { + if (!e || !e.currentTarget) return; + const input = e.currentTarget; + let value = input.value.replace(/[^0-9.]/g, ""); + const parts = value.split("."); + if (parts.length > 2) { + value = parts[0] + "." + parts.slice(1).join(""); + } + if (parts.length === 2 && parts[1].length > 2) { + value = parts[0] + "." + parts[1].slice(0, 2); + } + input.value = value; + if (typeof local.oninput === "function") { + local.oninput(e); + } + }; + + if (local.hideSymbol) { + return typeof local.ref === "function" && local.ref(el)} + type="text" + inputMode="decimal" + oninput={handleInput} + class={local.class} + small={local.small} + {...inputProps} + />; + } else { + return typeof local.ref === "function" && local.ref(el)} + prefix="$" + type="text" + inputMode="decimal" + oninput={handleInput} + class={local.class} + small={local.small} + {...inputProps} + />; + } +} + +export function FormPercentInput(props: FormInputProps) { + const [local, inputProps] = splitProps(props, ["small", "ref", "class", "oninput"]); + + const handleInput = (e) => { + if (!e || !e.currentTarget) return; + const input = e.currentTarget; + input.value = input.value.replace(/[^0-9.]/g, ""); + const parts = input.value.split("."); + if (parts.length > 2) { + input.value = parts[0] + "." + parts.slice(1).join(""); + } + if (typeof local.oninput === "function") { + local.oninput(e); + } + }; + + return typeof local.ref === "function" && local.ref(el)} + prefix="%" + type="text" + inputMode="decimal" + oninput={handleInput} + class={local.class} + small={local.small} + {...inputProps} + />; +} + +export function FormPhoneInput(props: FormInputProps) { + const [local, inputProps] = splitProps(props, ["small", "ref", "class", "oninput"]); + + const handleInput = (e) => { + if (!e || !e.currentTarget) return; + const input = e.currentTarget; + + let digits = input.value.replace(/\D/g, ""); + digits = digits.slice(0, 10); + let formatted = ""; + if (digits.length > 0) { + formatted = "(" + digits.slice(0, 3); + } + if (digits.length > 3) { + formatted += ") " + digits.slice(3, 6); + } + if (digits.length > 6) { + formatted += "-" + digits.slice(6, 10); + } + input.value = formatted; + if (typeof local.oninput === "function") { + local.oninput(e); + } + }; + + return typeof local.ref === "function" && local.ref(el)} + type="text" + inputMode="numeric" + oninput={handleInput} + class={local.class} + small={local.small} + placeholder="(555) 555-5555" + {...inputProps} + />; +} + +// @TODO replace this with showIcon prop +export function FormPhoneInputWithIcon(props: {icon?: string} & FormInputGroupProps) { + const [local, inputProps] = splitProps(props, ["icon", "small", "ref", "class", "oninput"]); + + const handleInput = (e) => { + if (!e || !e.currentTarget) return; + const input = e.currentTarget; + let digits = input.value.replace(/\D/g, ""); + digits = digits.slice(0, 10); + let formatted = ""; + if (digits.length > 0) { + formatted = "(" + digits.slice(0, 3); + } + if (digits.length > 3) { + formatted += ") " + digits.slice(3, 6); + } + if (digits.length > 6) { + formatted += "-" + digits.slice(6, 10); + } + input.value = formatted; + if (typeof local.oninput === "function") { + local.oninput(e); + } + }; + + const iconEl = ; + + return typeof local.ref === "function" && local.ref(el)} + type="text" + inputMode="numeric" + oninput={handleInput} + class={local.class} + small={local.small} + placeholder="(555) 555-5555" + {...inputProps} + />; +} + +export function FormEmailInput(props: {showIcon?: boolean} & FormInputProps) { + const [local, inputProps] = splitProps(props, ["showIcon", "small", "ref", "class", "error", "success", "onDark"]); + const showIcon = () => local.showIcon !== false; + const pfxCls = () => _prefixCls(local.small, local.error, local.onDark); + const inputCls = () => _inputCls(local.small, local.error, local.success, "rounded-l-none" + (local.class ? " " + local.class : ""), local.onDark); + + return typeof local.ref === "function" && local.ref(el)} + type="email" + inputMode="email" + class={local.class} + small={local.small} + error={local.error} + success={local.success} + onDark={local.onDark} + {...inputProps} + />}> +
+
+ + + + + typeof local.ref === "function" && local.ref(el)} + type="email" + inputMode="email" + class={inputCls()} + {...inputProps} + /> + +
+ + {local.error} + + + {local.success} + +
+
; +} + +export function FormURLInput(props: {showIcon: boolean} & FormInputProps) { + const [local, inputProps] = splitProps(props, ["showIcon", "small", "ref", "class", "onDark"]); + const showIcon = () => local.showIcon !== false; + const pfxCls = () => _prefixCls(local.small, false, local.onDark); + const inputCls = () => _inputCls(local.small, false, false, "rounded-l-none" + (local.class ? " " + local.class : ""), local.onDark); + + return typeof local.ref === "function" && local.ref(el)} + type="text" + inputMode="url" + class={local.class} + small={local.small} + onDark={local.onDark} + {...inputProps} + />}> +
+
+ + + + + typeof local.ref === "function" && local.ref(el)} + type="text" + inputMode="url" + class={inputCls()} + {...inputProps} + /> + +
+
+
; +} + +export function FormZipCodeInput(props: FormInputProps) { + const [local, inputProps] = splitProps(props, ["small", "ref", "class", "oninput"]); + + const handleInput = (e) => { + if (!e || !e.currentTarget) return; + const input = e.currentTarget; + + input.value = input.value + .replace(/[^0-9]/g, "") + .substring(0, 9) + .replace(/^(\d{5})(\d{1,4})?$/, function (_, a, b) { + return b ? a + "-" + b : a; + }); + + if (typeof props.oninput === "function") { + props.oninput(e); + } + }; + + return typeof local.ref === "function" && local.ref(el)} + type="text" + inputMode="numeric" + oninput={handleInput} + class={local.class} + small={local.small} + placeholder="12345" + {...inputProps} + />; +} + +export const handleTaxIdInput = (e) => { + if (!e || !e.currentTarget) return; + const input = e.currentTarget; + + let digits = input.value.replace(/\D/g, ""); + digits = digits.slice(0, 9); + let formatted = digits.slice(0,2); + if (digits.length > 2) { + formatted += "-" + digits.slice(2, 9); + } + input.value = formatted; +} + +export const handleRateInput = (e) => { + if (!e || !e.currentTarget) return; + const input = e.currentTarget; + + let value = input.value.replace(/[^0-9.]/g, ""); + const parts = value.split("."); + if (parts.length > 3) { + value = parts[0] + "." + parts.slice(1).join(""); + } + if (value.includes(".")) { + const [intPart, decPart] = value.split("."); + value = intPart + "." + decPart.slice(0, 3); + } + value = value.replace(/^0+(?=\d)/, ""); + input.value = value; +} + +export function FormSignaturePad(props: { value?: () => string; onchange?: (svg: string) => void; class?: string }) { + let canvasEl; + let ctx; + const CANVAS_WIDTH = 600; + const CANVAS_HEIGHT = 120; + const [isDrawing, setIsDrawing] = createSignal(false); + const [strokes, setStrokes] = createSignal([]); + const [currentStroke, setCurrentStroke] = createSignal([]); + const [isEmpty, setIsEmpty] = createSignal(true); + + const sizeCanvas = () => { + if (!canvasEl) return; + canvasEl.width = CANVAS_WIDTH; + canvasEl.height = CANVAS_HEIGHT; + ctx = canvasEl.getContext("2d"); + redraw(); + }; + + const getPoint = (e) => { + const rect = canvasEl.getBoundingClientRect(); + const scaleX = CANVAS_WIDTH / rect.width; + const scaleY = CANVAS_HEIGHT / rect.height; + if (e.touches) { + return { x: (e.touches[0].clientX - rect.left) * scaleX, y: (e.touches[0].clientY - rect.top) * scaleY }; + } + return { x: (e.clientX - rect.left) * scaleX, y: (e.clientY - rect.top) * scaleY }; + }; + + const redraw = () => { + if (!ctx) return; + ctx.clearRect(0, 0, canvasEl.width, canvasEl.height); + ctx.strokeStyle = "#1a1a2e"; + ctx.lineWidth = 2; + ctx.lineCap = "round"; + ctx.lineJoin = "round"; + const allStrokes = [...strokes(), ...(currentStroke().length > 0 ? [currentStroke()] : [])]; + for (const stroke of allStrokes) { + if (stroke.length < 2) continue; + ctx.beginPath(); + ctx.moveTo(stroke[0].x, stroke[0].y); + for (let i = 1; i < stroke.length; i++) { + ctx.lineTo(stroke[i].x, stroke[i].y); + } + ctx.stroke(); + } + }; + + const strokesToSvg = (allStrokes) => { + const w = CANVAS_WIDTH; + const h = CANVAS_HEIGHT; + const paths = allStrokes.map((stroke) => { + if (stroke.length < 2) return ""; + let d = `M${stroke[0].x.toFixed(1)},${stroke[0].y.toFixed(1)}`; + if (stroke.length === 2) { + d += ` L${stroke[1].x.toFixed(1)},${stroke[1].y.toFixed(1)}`; + } else { + for (let i = 1; i < stroke.length - 1; i++) { + const mx = ((stroke[i].x + stroke[i + 1].x) / 2).toFixed(1); + const my = ((stroke[i].y + stroke[i + 1].y) / 2).toFixed(1); + d += ` Q${stroke[i].x.toFixed(1)},${stroke[i].y.toFixed(1)},${mx},${my}`; + } + const last = stroke[stroke.length - 1]; + d += ` L${last.x.toFixed(1)},${last.y.toFixed(1)}`; + } + return ``; + }).filter(Boolean); + return `${paths.join("")}`; + }; + + const emitChange = (allStrokes) => { + const hasContent = allStrokes.length > 0 && allStrokes.some(s => s.length >= 2); + setIsEmpty(!hasContent); + props.onchange?.(hasContent ? strokesToSvg(allStrokes) : ""); + }; + + const startDrawing = (e) => { + e.preventDefault(); + if (!ctx) sizeCanvas(); + setIsDrawing(true); + setCurrentStroke([getPoint(e)]); + }; + + const draw = (e) => { + if (!isDrawing()) return; + e.preventDefault(); + const point = getPoint(e); + setCurrentStroke(prev => [...prev, point]); + redraw(); + }; + + const stopDrawing = (e) => { + if (!isDrawing()) return; + e?.preventDefault(); + setIsDrawing(false); + const stroke = currentStroke(); + if (stroke.length >= 2) { + const newStrokes = [...strokes(), stroke]; + setStrokes(newStrokes); + emitChange(newStrokes); + } + setCurrentStroke([]); + redraw(); + }; + + const clearSignature = () => { + setStrokes([]); + setCurrentStroke([]); + setIsEmpty(true); + redraw(); + props.onchange?.(""); + }; + + const initCanvas = (el) => { + canvasEl = el; + requestAnimationFrame(() => sizeCanvas()); + }; + + return
+
+ +
+ {isEmpty() ? "Sign above" : ""} + +
+
+
; +} + +// @TODO Fix this +export function FormSelect(props: FormSelectProps & { children?: JSXElement }) { + const [local, selectProps] = splitProps(props, ["small", "ref", "class", "children", "value", "onDark"]); + const cls = () => _inputCls(local.small, false, false, local.class, local.onDark); + let selectEl; + // Set value via deferred effect so it runs AFTER children (