From 6f248700d7b9dd80efa209ab3c91997d7a2a3d76 Mon Sep 17 00:00:00 2001 From: e2e Date: Sat, 11 Jul 2026 09:07:34 +0200 Subject: [PATCH] chore: germantax customer project --- .e2e-runtime/golden.db | 0 ...io-0de759d8-2c5a-44e6-a997-3401935185a4.db | Bin 0 -> 94208 bytes ...io-3f0e875c-b90a-4c94-958d-b24fdbcd9a52.db | Bin 0 -> 155648 bytes .gitignore | 35 + .yarnrc.yml | 8 + README.md | 40 + apps/app/.gitignore | 10 + apps/app/.stylelintrc.json | 33 + apps/app/README.md | 49 + apps/app/components.json | 17 + apps/app/eslint.config.js | 49 + apps/app/index.html | 16 + apps/app/messages/de.json | 184 ++ apps/app/messages/en.json | 197 ++ apps/app/package.json | 63 + apps/app/postcss.config.js | 5 + apps/app/project.inlang/settings.json | 7 + apps/app/src/auth.config.ts | 13 + apps/app/src/auth.tsx | 39 + apps/app/src/auth/AuthLayout.tsx | 163 ++ apps/app/src/auth/providers/_types.ts | 20 + apps/app/src/auth/providers/credentials.ts | 18 + apps/app/src/auth/session-provider.tsx | 87 + apps/app/src/components/AppLayout.tsx | 207 ++ apps/app/src/direction.ts | 40 + apps/app/src/i18n/config.ts | 101 + apps/app/src/i18n/ident.ts | 12 + apps/app/src/i18n/messages.ts | 54 + apps/app/src/lib/auth.ts | 41 + apps/app/src/lib/env.ts | 21 + apps/app/src/pages/__root.tsx | 78 + .../src/pages/companies/$companyId/index.tsx | 520 +++++ .../$companyId/resolutions/$resolutionId.tsx | 338 +++ .../companies/$companyId/resolutions/new.tsx | 141 ++ apps/app/src/pages/companies/index.tsx | 515 +++++ apps/app/src/pages/index.tsx | 267 +++ apps/app/src/pages/login.tsx | 80 + apps/app/src/pages/logout.tsx | 72 + apps/app/src/pages/portfolio.tsx | 50 + apps/app/src/pages/signup.tsx | 95 + apps/app/src/router.tsx | 16 + apps/app/src/theme.css | 4 + apps/app/tsconfig.json | 24 + apps/app/tsr.config.json | 6 + apps/app/vite.config.ts | 34 + bun.lock | 1963 +++++++++++++++++ db/annotations.ts | 26 + db/migrations/0001-init.sql | 63 + db/seed.sql | 15 + db/sqlite-seed.sql | 10 + db/sqlite/0001-init.sql | 63 + db/sqlite/0002-better-auth.sql | 107 + db/sqlite/0003-ba-organization.sql | 33 + db/sqlite/0004-fabric-audit.sql | 29 + db/sqlite/0005-user-actor.sql | 11 + db/sqlite/0006-admin.sql | 16 + db/sqlite/0007-fabric.sql | 13 + e2e/package.json | 20 + e2e/tests/cucumber.mjs | 7 + e2e/tests/features/auth.feature | 24 + e2e/tests/features/companies.feature | 20 + e2e/tests/features/resolutions.feature | 20 + e2e/tests/reports/cucumber-report.html | 50 + e2e/tests/steps/common.steps.ts | 234 ++ e2e/tests/support/hooks.ts | 248 +++ e2e/tests/support/types.ts | 38 + e2e/tests/support/world.ts | 109 + e2e/tsconfig.json | 14 + emails/locales/de.json | 14 + emails/locales/en.json | 14 + emails/partials/footer.html | 3 + emails/partials/layout.html | 15 + emails/templates/confirm-email.html | 27 + emails/templates/confirm-email.subject.txt | 1 + emails/templates/confirm-email.text.txt | 7 + emails/theme.json | 16 + fabric.config.json | 16 + ...io-06aec864-a35a-4aea-82a6-face0da0edff.db | Bin 0 -> 65536 bytes ...io-23c96830-59ac-4d19-acd8-160488cf410b.db | Bin 0 -> 65536 bytes ...io-4c4b169b-202d-424f-8f57-777a2fcd4719.db | Bin 0 -> 65536 bytes ...io-539c9779-39e8-4b9e-bf99-92769e6bb49c.db | Bin 0 -> 65536 bytes ...io-6784dba4-8bfb-4db8-98b5-6fe8158d0735.db | Bin 0 -> 65536 bytes ...io-85c3e4e8-28f0-4f94-af76-e23c924f8f2b.db | Bin 0 -> 65536 bytes ...io-a950b743-4da9-4b2d-bfd4-e224bc8dfe76.db | Bin 0 -> 65536 bytes ...io-c3d0ca99-0db7-4214-992a-a94c56757101.db | Bin 0 -> 65536 bytes ...io-ff357694-cebd-4e62-9bcd-f60a42e908a0.db | Bin 0 -> 65536 bytes germantax-root-0.0.1.tgz | Bin 0 -> 645340 bytes package.json | 37 + packages/components/package.json | 17 + packages/components/src/index.ts | 2 + packages/components/src/page-header.tsx | 27 + packages/components/src/status-badge.tsx | 33 + packages/functions-sdk/package.json | 19 + packages/functions/db/schema.d.ts | 70 + packages/functions/package.json | 26 + packages/functions/pikku.config.json | 23 + packages/functions/src/application-types.d.ts | 29 + packages/functions/src/config.ts | 11 + .../src/functions/auth/get-me.function.ts | 19 + .../companies/create-company.function.ts | 54 + .../companies/get-company.function.ts | 61 + .../companies/list-my-companies.function.ts | 57 + .../functions/members/add-member.function.ts | 91 + .../members/list-members.function.ts | 40 + .../members/remove-member.function.ts | 23 + .../archive-resolution.function.ts | 23 + .../resolutions/create-resolution.function.ts | 47 + .../get-resolution-pdf-url.function.ts | 28 + .../resolutions/get-resolution.function.ts | 42 + .../resolutions/list-resolutions.function.ts | 41 + .../publish-resolution.function.ts | 107 + .../templates/list-templates.function.ts | 36 + packages/functions/src/lib/auth-helpers.ts | 61 + packages/functions/src/lib/password.ts | 68 + packages/functions/src/services.ts | 57 + .../functions/src/services/audit.service.ts | 41 + .../src/templates/annual-budget-approval.ts | 60 + .../templates/annual-financials-approval.ts | 68 + .../src/templates/capital-increase.ts | 77 + packages/functions/src/templates/index.ts | 49 + .../functions/src/templates/md-appointment.ts | 66 + .../functions/src/templates/md-dismissal.ts | 66 + .../src/templates/profit-distribution.ts | 71 + .../src/templates/share-transfer-approval.ts | 83 + packages/functions/src/templates/shared.ts | 64 + packages/functions/src/types/db.types.ts | 1 + packages/functions/src/wire-services.ts | 9 + packages/functions/src/wirings/auth.wiring.ts | 51 + packages/functions/src/wirings/cors.wiring.ts | 28 + packages/functions/tests/.gitignore | 1 + packages/functions/tests/package.json | 24 + packages/functions/tests/tests/cucumber.mjs | 7 + .../functions/tests/tests/features/.gitkeep | 0 .../functions/tests/tests/support/hooks.ts | 7 + .../functions/tests/tests/support/services.ts | 44 + .../functions/tests/tests/support/world.ts | 5 + packages/functions/tests/tsconfig.json | 18 + packages/mantine-theme/package.json | 12 + packages/mantine-theme/src/index.ts | 40 + pikku.config.json | 36 + pikkufabric.config.json | 16 + tests/reports/cucumber-report.html | 50 + tsconfig.json | 13 + versions.pikku.json | 320 +++ workflow-wip.md | 339 +++ 145 files changed, 9830 insertions(+) create mode 100644 .e2e-runtime/golden.db create mode 100644 .e2e-runtime/scenario-0de759d8-2c5a-44e6-a997-3401935185a4.db create mode 100644 .e2e-runtime/scenario-3f0e875c-b90a-4c94-958d-b24fdbcd9a52.db create mode 100644 .gitignore create mode 100644 .yarnrc.yml create mode 100644 README.md create mode 100644 apps/app/.gitignore create mode 100644 apps/app/.stylelintrc.json create mode 100644 apps/app/README.md create mode 100644 apps/app/components.json create mode 100644 apps/app/eslint.config.js create mode 100644 apps/app/index.html create mode 100644 apps/app/messages/de.json create mode 100644 apps/app/messages/en.json create mode 100644 apps/app/package.json create mode 100644 apps/app/postcss.config.js create mode 100644 apps/app/project.inlang/settings.json create mode 100644 apps/app/src/auth.config.ts create mode 100644 apps/app/src/auth.tsx create mode 100644 apps/app/src/auth/AuthLayout.tsx create mode 100644 apps/app/src/auth/providers/_types.ts create mode 100644 apps/app/src/auth/providers/credentials.ts create mode 100644 apps/app/src/auth/session-provider.tsx create mode 100644 apps/app/src/components/AppLayout.tsx create mode 100644 apps/app/src/direction.ts create mode 100644 apps/app/src/i18n/config.ts create mode 100644 apps/app/src/i18n/ident.ts create mode 100644 apps/app/src/i18n/messages.ts create mode 100644 apps/app/src/lib/auth.ts create mode 100644 apps/app/src/lib/env.ts create mode 100644 apps/app/src/pages/__root.tsx create mode 100644 apps/app/src/pages/companies/$companyId/index.tsx create mode 100644 apps/app/src/pages/companies/$companyId/resolutions/$resolutionId.tsx create mode 100644 apps/app/src/pages/companies/$companyId/resolutions/new.tsx create mode 100644 apps/app/src/pages/companies/index.tsx create mode 100644 apps/app/src/pages/index.tsx create mode 100644 apps/app/src/pages/login.tsx create mode 100644 apps/app/src/pages/logout.tsx create mode 100644 apps/app/src/pages/portfolio.tsx create mode 100644 apps/app/src/pages/signup.tsx create mode 100644 apps/app/src/router.tsx create mode 100644 apps/app/src/theme.css create mode 100644 apps/app/tsconfig.json create mode 100644 apps/app/tsr.config.json create mode 100644 apps/app/vite.config.ts create mode 100644 bun.lock create mode 100644 db/annotations.ts create mode 100644 db/migrations/0001-init.sql create mode 100644 db/seed.sql create mode 100644 db/sqlite-seed.sql create mode 100644 db/sqlite/0001-init.sql create mode 100644 db/sqlite/0002-better-auth.sql create mode 100644 db/sqlite/0003-ba-organization.sql create mode 100644 db/sqlite/0004-fabric-audit.sql create mode 100644 db/sqlite/0005-user-actor.sql create mode 100644 db/sqlite/0006-admin.sql create mode 100644 db/sqlite/0007-fabric.sql create mode 100644 e2e/package.json create mode 100644 e2e/tests/cucumber.mjs create mode 100644 e2e/tests/features/auth.feature create mode 100644 e2e/tests/features/companies.feature create mode 100644 e2e/tests/features/resolutions.feature create mode 100644 e2e/tests/reports/cucumber-report.html create mode 100644 e2e/tests/steps/common.steps.ts create mode 100644 e2e/tests/support/hooks.ts create mode 100644 e2e/tests/support/types.ts create mode 100644 e2e/tests/support/world.ts create mode 100644 e2e/tsconfig.json create mode 100644 emails/locales/de.json create mode 100644 emails/locales/en.json create mode 100644 emails/partials/footer.html create mode 100644 emails/partials/layout.html create mode 100644 emails/templates/confirm-email.html create mode 100644 emails/templates/confirm-email.subject.txt create mode 100644 emails/templates/confirm-email.text.txt create mode 100644 emails/theme.json create mode 100644 fabric.config.json create mode 100644 file:/Users/yasser/git/pikku/germantax/.e2e-runtime/scenario-06aec864-a35a-4aea-82a6-face0da0edff.db create mode 100644 file:/Users/yasser/git/pikku/germantax/.e2e-runtime/scenario-23c96830-59ac-4d19-acd8-160488cf410b.db create mode 100644 file:/Users/yasser/git/pikku/germantax/.e2e-runtime/scenario-4c4b169b-202d-424f-8f57-777a2fcd4719.db create mode 100644 file:/Users/yasser/git/pikku/germantax/.e2e-runtime/scenario-539c9779-39e8-4b9e-bf99-92769e6bb49c.db create mode 100644 file:/Users/yasser/git/pikku/germantax/.e2e-runtime/scenario-6784dba4-8bfb-4db8-98b5-6fe8158d0735.db create mode 100644 file:/Users/yasser/git/pikku/germantax/.e2e-runtime/scenario-85c3e4e8-28f0-4f94-af76-e23c924f8f2b.db create mode 100644 file:/Users/yasser/git/pikku/germantax/.e2e-runtime/scenario-a950b743-4da9-4b2d-bfd4-e224bc8dfe76.db create mode 100644 file:/Users/yasser/git/pikku/germantax/.e2e-runtime/scenario-c3d0ca99-0db7-4214-992a-a94c56757101.db create mode 100644 file:/Users/yasser/git/pikku/germantax/.e2e-runtime/scenario-ff357694-cebd-4e62-9bcd-f60a42e908a0.db create mode 100644 germantax-root-0.0.1.tgz create mode 100644 package.json create mode 100644 packages/components/package.json create mode 100644 packages/components/src/index.ts create mode 100644 packages/components/src/page-header.tsx create mode 100644 packages/components/src/status-badge.tsx create mode 100644 packages/functions-sdk/package.json create mode 100644 packages/functions/db/schema.d.ts create mode 100644 packages/functions/package.json create mode 100644 packages/functions/pikku.config.json create mode 100644 packages/functions/src/application-types.d.ts create mode 100644 packages/functions/src/config.ts create mode 100644 packages/functions/src/functions/auth/get-me.function.ts create mode 100644 packages/functions/src/functions/companies/create-company.function.ts create mode 100644 packages/functions/src/functions/companies/get-company.function.ts create mode 100644 packages/functions/src/functions/companies/list-my-companies.function.ts create mode 100644 packages/functions/src/functions/members/add-member.function.ts create mode 100644 packages/functions/src/functions/members/list-members.function.ts create mode 100644 packages/functions/src/functions/members/remove-member.function.ts create mode 100644 packages/functions/src/functions/resolutions/archive-resolution.function.ts create mode 100644 packages/functions/src/functions/resolutions/create-resolution.function.ts create mode 100644 packages/functions/src/functions/resolutions/get-resolution-pdf-url.function.ts create mode 100644 packages/functions/src/functions/resolutions/get-resolution.function.ts create mode 100644 packages/functions/src/functions/resolutions/list-resolutions.function.ts create mode 100644 packages/functions/src/functions/resolutions/publish-resolution.function.ts create mode 100644 packages/functions/src/functions/templates/list-templates.function.ts create mode 100644 packages/functions/src/lib/auth-helpers.ts create mode 100644 packages/functions/src/lib/password.ts create mode 100644 packages/functions/src/services.ts create mode 100644 packages/functions/src/services/audit.service.ts create mode 100644 packages/functions/src/templates/annual-budget-approval.ts create mode 100644 packages/functions/src/templates/annual-financials-approval.ts create mode 100644 packages/functions/src/templates/capital-increase.ts create mode 100644 packages/functions/src/templates/index.ts create mode 100644 packages/functions/src/templates/md-appointment.ts create mode 100644 packages/functions/src/templates/md-dismissal.ts create mode 100644 packages/functions/src/templates/profit-distribution.ts create mode 100644 packages/functions/src/templates/share-transfer-approval.ts create mode 100644 packages/functions/src/templates/shared.ts create mode 100644 packages/functions/src/types/db.types.ts create mode 100644 packages/functions/src/wire-services.ts create mode 100644 packages/functions/src/wirings/auth.wiring.ts create mode 100644 packages/functions/src/wirings/cors.wiring.ts create mode 100644 packages/functions/tests/.gitignore create mode 100644 packages/functions/tests/package.json create mode 100644 packages/functions/tests/tests/cucumber.mjs create mode 100644 packages/functions/tests/tests/features/.gitkeep create mode 100644 packages/functions/tests/tests/support/hooks.ts create mode 100644 packages/functions/tests/tests/support/services.ts create mode 100644 packages/functions/tests/tests/support/world.ts create mode 100644 packages/functions/tests/tsconfig.json create mode 100644 packages/mantine-theme/package.json create mode 100644 packages/mantine-theme/src/index.ts create mode 100644 pikku.config.json create mode 100644 pikkufabric.config.json create mode 100644 tests/reports/cucumber-report.html create mode 100644 tsconfig.json create mode 100644 versions.pikku.json create mode 100644 workflow-wip.md diff --git a/.e2e-runtime/golden.db b/.e2e-runtime/golden.db new file mode 100644 index 0000000..e69de29 diff --git a/.e2e-runtime/scenario-0de759d8-2c5a-44e6-a997-3401935185a4.db b/.e2e-runtime/scenario-0de759d8-2c5a-44e6-a997-3401935185a4.db new file mode 100644 index 0000000000000000000000000000000000000000..377a2c7b58f4434d2ddb6a6104f408c2f17146f0 GIT binary patch literal 94208 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 literal 0 HcmV?d00001 diff --git a/.e2e-runtime/scenario-3f0e875c-b90a-4c94-958d-b24fdbcd9a52.db b/.e2e-runtime/scenario-3f0e875c-b90a-4c94-958d-b24fdbcd9a52.db new file mode 100644 index 0000000000000000000000000000000000000000..80b0bf654f50868ae2b3bb662e942be0bc98b05f GIT binary patch literal 155648 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 W0SG_<0uX=z1Rwwb2tZ%}1pW_NGm#qr literal 0 HcmV?d00001 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1ee1f91 --- /dev/null +++ b/.gitignore @@ -0,0 +1,35 @@ +node_modules +dist +.vite +.tanstack +.next +.output +*.tsbuildinfo + +# Pikku deploy bundles — regenerated by `pikku deploy`. +.deploy/ +.pikku-runtime/ + +# Playwright MCP screenshots +.playwright-mcp/ + +# Local pikku package tarballs (sourced from sibling repos for development). +vendor/*.tgz + +# Pikku CLI codegen output — regenerated by `pikku all`. +# Includes the local dev SQLite db and any locally generated PDF content. +packages/functions/.pikku/ + +# Pikku CLI scaffold codegen — regenerated on `pikku all`. +packages/functions/src/scaffold/*.gen.ts +packages/functions/src/pikku/* + +# Fabric generated/runtime artifacts — regenerated on every dev boot / scaffold / codegen. +.opencode +.pikku +.reports +__fabric_scaffold.vite.config.mjs +*.gen.* + +# Bun +.bun/ diff --git a/.yarnrc.yml b/.yarnrc.yml new file mode 100644 index 0000000..063ed60 --- /dev/null +++ b/.yarnrc.yml @@ -0,0 +1,8 @@ +approvedGitRepositories: + - "**" + +enableScripts: true + +nodeLinker: node-modules + +npmMinimalAgeGate: 0 diff --git a/README.md b/README.md new file mode 100644 index 0000000..05ed144 --- /dev/null +++ b/README.md @@ -0,0 +1,40 @@ +# Kanban Board Template + +Production-style Fabric demo template that exercises all core surfaces in one project: + +- `http` - board APIs + workflow start routes +- `channel` - `/ws/kanban` connect/message handlers +- `queue` - `kanban-health-check` worker +- `schedule` - periodic health-check trigger +- `workflow` - demo orchestration workflow +- `mcp` - exposed command endpoint (`mcpKanbanCommand`) +- `agent` - `kanbanOpsAgent` with tool wiring + +## Quick Start + +1. `yarn install` +2. `yarn workspace @germantax/functions pikku db migrate` +3. `yarn prebuild` +4. `yarn dev` +5. Open `/kanban` + +## Demo RPCs + +- `listKanbanCards` +- `getKanbanMetric` +- `getKanbanWorkflowStatus` +- `createKanbanCard` +- `moveKanbanCard` +- `enqueueKanbanHealthCheck` +- `startKanbanDemoWorkflow` +- `mcpKanbanCommand` + +## Template Conventions + +- Same workspace and deploy layout as Fabric `environment-template` +- Root `pikku.config.json` and `packages/functions/pikku.config.json` preserved +- SQL migrations live in `packages/functions/db/migrations/` +- Optional `packages/functions/db/seed.sql` runs only via `pikku db seed` / `pikku db reset` +- DB types regenerate at `packages/functions/db/schema.d.ts` on every `pikku db migrate` +- UI pages live in `apps/app/src/pages/` +- Shared widgets/components live in `packages/components/` diff --git a/apps/app/.gitignore b/apps/app/.gitignore new file mode 100644 index 0000000..11b69e0 --- /dev/null +++ b/apps/app/.gitignore @@ -0,0 +1,10 @@ +node_modules +dist +.vite + +# Generated by @pikku/cli — regenerated on `pikku all`. +src/gen/ + +# Generated by @tanstack/router-plugin — the seed at src/routeTree.gen.ts +# is committed so tsc passes before the plugin has run; the plugin +# overwrites it as soon as dev starts. diff --git a/apps/app/.stylelintrc.json b/apps/app/.stylelintrc.json new file mode 100644 index 0000000..f4ce2ae --- /dev/null +++ b/apps/app/.stylelintrc.json @@ -0,0 +1,33 @@ +{ + "extends": ["stylelint-config-standard"], + "plugins": ["stylelint-use-logical"], + "rules": { + "csstools/use-logical": [ + "always", + { + "except": [ + "top", + "bottom", + "width", + "height", + "min-width", + "max-width", + "min-height", + "max-height" + ] + } + ], + "declaration-no-important": true, + "at-rule-no-unknown": [ + true, + { "ignoreAtRules": ["tailwind", "apply", "variants", "responsive", "screen", "layer"] } + ] + }, + "ignoreFiles": [ + "dist/**", + "node_modules/**", + "src/routeTree.gen.ts", + "**/*.gen.ts", + "**/*.gen.d.ts" + ] +} diff --git a/apps/app/README.md b/apps/app/README.md new file mode 100644 index 0000000..d355e76 --- /dev/null +++ b/apps/app/README.md @@ -0,0 +1,49 @@ +# `_app_template_` (scaffold-resident) + +Skeleton for every user app. **Lives in the scaffold container, not the user's repo.** Users never see this directly — the scaffold instantiates from it when a new app is needed. + +## How it gets instantiated + +`pikku create-app ` (landed in #58) reads this directory from the scaffold's known path (`/opt/fabric/_app_template_/` inside the container) and: + +1. Copies every file to `apps//` in the user's repo +2. Substitutes placeholders — `app`, `@germantax/app`, `7104`, `App`, `0.0.1`, `2026-04-17T11:42:52Z` +3. Adds to yarn workspaces, runs `yarn install` +4. Regenerates Caddyfile to route to the new app's dev server +5. Stamps `package.json` → `fabric.templateVersion` with the current skeleton version (for 3-way merge on later `upgrade-app`) + +On first scaffold boot against an empty `apps/` dir, the scaffold auto-runs `create-app app` to seed the default. + +## Placeholders + +| Placeholder | Example | Notes | +|---|---|---| +| `app` | `admin` | Dir name + workspace slug | +| `@germantax/app` | `@germantax/admin` | `package.json` `name` | +| `7104` | `7105` | Next free port from 7104+ | +| `App` | `Admin` | ``, app header | +| `0.0.1` | `0.0.1` | Version at instantiation — ancestor for `upgrade-app` | +| `2026-04-17T11:42:52Z` | ISO timestamp | Audit | + +## What's here + +- Vite + Mantine + TypeScript + ESLint + Stylelint config +- Root providers wiring (`main.tsx`) — react-query, router, i18next, Pikku RPC +- Theme entry (`src/theme.css`) — imports `@germantax/components/themes/default.css`, allows local overrides +- Default routes: `/login`, `/logout`, `/hello` +- Auth config scaffold (Auth0 active; GitHub / Google / Microsoft / Passwordless as `.ts.inactive` templates) +- EN locale baseline + +## What's NOT here + +- **Widgets / components / themes** → `@germantax/components` in the user's template repo (shared across every app) +- **Backend functions** → `@germantax/functions` +- **App-specific pages / wirings** — land in the instantiated `apps/<name>/src/pages/` after creation + +## Upgrading an app to this skeleton version + +```sh +pikku upgrade-app app # 3-way merge (see #57) +``` + +Uses `package.json` → `fabric.templateVersion` as the ancestor. Untouched files update; edited files get conflict markers or interactive diff. diff --git a/apps/app/components.json b/apps/app/components.json new file mode 100644 index 0000000..b5cbfd1 --- /dev/null +++ b/apps/app/components.json @@ -0,0 +1,17 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "new-york", + "rsc": false, + "tsx": true, + "aliases": { + "components": "@germantax/components/components", + "ui": "@germantax/components/components/ui", + "widgets": "@germantax/components/widgets", + "utils": "@germantax/components/lib/utils", + "lib": "@germantax/components/lib", + "hooks": "@/hooks" + }, + "registries": { + "fabric": "https://registry.pikkufabric.com/{name}.json" + } +} diff --git a/apps/app/eslint.config.js b/apps/app/eslint.config.js new file mode 100644 index 0000000..d1f7c89 --- /dev/null +++ b/apps/app/eslint.config.js @@ -0,0 +1,49 @@ +import i18next from 'eslint-plugin-i18next' +import react from 'eslint-plugin-react' +import jsxA11y from 'eslint-plugin-jsx-a11y' + +/** + * Enforces the CONVENTIONS.md rules: + * - i18n keying (no-literal-string) + * - strict jsx-a11y + * - react hooks / components + * + * Custom rules that ship in Stage 3 (noted in CONVENTIONS.md): + * - no-array-methods-in-widgets + * - no-date-libraries-in-widgets + */ +export default [ + { + files: ['src/**/*.{ts,tsx}'], + ignores: [ + 'src/routeTree.gen.ts', + '**/*.gen.ts', + '**/*.gen.d.ts', + 'dist/**', + 'node_modules/**', + ], + plugins: { i18next, react, 'jsx-a11y': jsxA11y }, + languageOptions: { + ecmaVersion: 'latest', + sourceType: 'module', + parserOptions: { + ecmaFeatures: { jsx: true }, + }, + }, + rules: { + // i18n keying — every visible string must go through t() + 'i18next/no-literal-string': [ + 'error', + { + markupOnly: true, + onlyAttribute: ['label', 'placeholder', 'title', 'aria-label'], + ignoreCallee: ['t', 'i18n.t', 'useTranslation'], + ignoreComponent: ['Code', 'Kbd'], + }, + ], + + // A11y — Radix defaults stay; no escape hatch to <div onClick> + ...jsxA11y.configs.strict.rules, + }, + }, +] diff --git a/apps/app/index.html b/apps/app/index.html new file mode 100644 index 0000000..02d1eaf --- /dev/null +++ b/apps/app/index.html @@ -0,0 +1,16 @@ +<!doctype html> +<html lang="en"> + <head> + <meta charset="UTF-8" /> + <link rel="icon" type="image/svg+xml" href="/favicon.svg" /> + <meta name="viewport" content="width=device-width, initial-scale=1.0" /> + <title>Quorum + + + + + +
+ + + diff --git a/apps/app/messages/de.json b/apps/app/messages/de.json new file mode 100644 index 0000000..9f60fe1 --- /dev/null +++ b/apps/app/messages/de.json @@ -0,0 +1,184 @@ +{ + "$schema": "https://inlang.com/schema/inlang-message-format", + "common_brand_name": "Quorum", + "auth_login_title": "Willkommen zurück", + "auth_login_subtitle": "Melden Sie sich an, um Ihre GmbH-Beschlüsse zu verwalten.", + "auth_login_email": "E-Mail", + "auth_login_email_placeholder": "sie@beispiel.de", + "auth_login_password": "Passwort", + "auth_login_submit": "Anmelden", + "auth_login_forgot_password": "Passwort vergessen?", + "auth_login_no_account": "Noch kein Konto?", + "auth_login_signup_cta": "Jetzt registrieren", + "auth_login_or": "oder", + "auth_login_providers_continue_with": "Weiter mit {provider}", + "auth_login_errors_invalid_credentials": "E-Mail oder Passwort sind falsch.", + "auth_login_errors_account_locked": "Ihr Konto ist gesperrt. Wenden Sie sich an den Support.", + "auth_login_errors_generic": "Anmeldung fehlgeschlagen. Bitte erneut versuchen.", + "auth_signup_title": "Konto erstellen", + "auth_signup_subtitle": "GmbH-Beschlüsse in wenigen Minuten entwerfen.", + "auth_signup_display_name": "Ihr Name", + "auth_signup_password_hint": "Mindestens 8 Zeichen.", + "auth_signup_submit": "Konto erstellen", + "auth_signup_have_account": "Schon ein Konto?", + "auth_signup_login_cta": "Anmelden", + "auth_signup_errors_email_taken": "Ein Konto mit dieser E-Mail existiert bereits.", + "auth_signup_errors_generic": "Konto konnte nicht erstellt werden. Bitte erneut versuchen.", + "auth_logout_title": "Abgemeldet", + "auth_logout_subtitle": "Sie wurden abgemeldet. Bis bald.", + "auth_logout_back_to_login": "Zurück zur Anmeldung", + "auth_brand_headline_line1": "Jeder Beschluss,", + "auth_brand_headline_line2": "unterzeichnet, besiegelt und sicher archiviert.", + "auth_brand_tagline": "Ein revisionssicheres Archiv Ihrer GmbH-Beschlüsse, nach deutschem Gesellschaftsrecht.", + "app_title": "GmbH-Beschlussverwaltung", + "app_tagline": "Von der Vorlage zur Unterschrift bis zum Archiv.", + "app_nav_companies": "Unternehmen", + "app_nav_portfolio": "Portfolio", + "app_nav_logout": "Abmelden", + "app_nav_sign_in": "Anmelden", + "app_companies_list_title": "Ihre Unternehmen", + "app_companies_empty": "Sie sind noch in keinem Unternehmen Mitglied.", + "app_companies_new_company": "Neues Unternehmen", + "app_companies_cancel": "Abbrechen", + "app_companies_count_subtitle": [ + { + "declarations": [ + "input count", + "local countPlural = count: plural" + ], + "selectors": [ + "countPlural" + ], + "match": { + "countPlural=other": "{count} Unternehmen" + } + } + ], + "app_companies_create": "Unternehmen anlegen", + "app_companies_name": "Unternehmensname", + "app_companies_registry_no": "Handelsregisternummer", + "app_companies_address": "Adresse", + "app_companies_address_line1": "Adresse (Zeile 1)", + "app_companies_address_line2": "Adresse (Zeile 2)", + "app_companies_address_line2_optional": "Adresse (Zeile 2, optional)", + "app_companies_postcode": "PLZ", + "app_companies_town": "Ort", + "app_companies_country": "Land", + "app_companies_total_shares": "Gesamtanzahl Anteile", + "app_companies_total_shares_hint": "Die Gesamtzahl der vom Unternehmen ausgegebenen Anteile.", + "app_companies_shares_assigned": "{assigned} / {total} Anteile zugewiesen", + "app_companies_shares_remaining": [ + { + "declarations": [ + "input count", + "local countPlural = count: plural" + ], + "selectors": [ + "countPlural" + ], + "match": { + "countPlural=other": "{count} Anteile verbleibend" + } + } + ], + "app_companies_shares_fully_assigned": "Alle Anteile zugewiesen", + "app_companies_fiscal_year_end": "Geschäftsjahresende (MM-TT)", + "app_companies_fiscal_year_end_short": "GJ-Ende", + "app_companies_fiscal_month": "Geschäftsjahresende-Monat", + "app_companies_fiscal_day": "Tag", + "app_companies_fiscal_month_placeholder": "Monat wählen", + "app_companies_fiscal_day_placeholder": "Tag wählen", + "app_companies_role": "Rolle", + "app_companies_open": "Öffnen", + "app_companies_members": "Mitglieder", + "app_companies_add_member": "Mitglied hinzufügen", + "app_companies_add_member_title": "Mitglied hinzufügen", + "app_companies_member_email": "E-Mail des Mitglieds", + "app_companies_member_display_name": "Anzeigename", + "app_companies_member_role": "Rolle", + "app_companies_member_shares": "Anteile", + "app_companies_remove": "Entfernen", + "app_companies_roles_enum_managing_director": "Geschäftsführer", + "app_companies_roles_enum_shareholder": "Gesellschafter", + "app_companies_roles_enum_angel": "Angel-Investor", + "app_companies_months_10": "Oktober", + "app_companies_months_11": "November", + "app_companies_months_12": "Dezember", + "app_companies_months_01": "Januar", + "app_companies_months_02": "Februar", + "app_companies_months_03": "März", + "app_companies_months_04": "April", + "app_companies_months_05": "Mai", + "app_companies_months_06": "Juni", + "app_companies_months_07": "Juli", + "app_companies_months_08": "August", + "app_companies_months_09": "September", + "app_companies_empty_state_title": "Noch keine Unternehmen", + "app_companies_empty_state_description": "Legen Sie Ihr erstes Unternehmen an, um Beschlüsse zu entwerfen und zu archivieren.", + "app_companies_empty_state_cta": "Erstes Unternehmen anlegen", + "app_companies_errors_load_title": "Unternehmen konnten nicht geladen werden", + "app_companies_errors_create_title": "Unternehmen konnte nicht angelegt werden", + "app_companies_errors_add_member_title": "Mitglied konnte nicht hinzugefügt werden", + "app_companies_errors_name_required": "Unternehmensname ist erforderlich.", + "app_companies_errors_total_shares_invalid": "Bitte eine nicht-negative Ganzzahl eingeben.", + "app_companies_errors_shares_exceed_total": "Anteile übersteigen die verbleibenden {remaining} von {total}.", + "app_companies_errors_fiscal_invalid": "Format MM-TT verwenden (z. B. 12-31).", + "app_companies_errors_fiscal_month_required": "Bitte einen Monat wählen.", + "app_companies_errors_fiscal_day_required": "Bitte einen Tag wählen.", + "app_companies_success_title": "Unternehmen angelegt", + "app_companies_success_message": "{name} wurde zu Ihren Unternehmen hinzugefügt.", + "app_resolutions_title": "Beschlüsse", + "app_resolutions_new": "Neuer Beschluss", + "app_resolutions_new_title": "Neuer Beschluss", + "app_resolutions_create": "Entwurf anlegen", + "app_resolutions_select_template": "Vorlage wählen", + "app_resolutions_resolution_title": "Titel", + "app_resolutions_draft": "Entwurf", + "app_resolutions_published": "Veröffentlicht", + "app_resolutions_archived": "Archiviert", + "app_resolutions_publish": "Veröffentlichen & PDF erzeugen", + "app_resolutions_archive": "Archivieren", + "app_resolutions_open_pdf": "PDF öffnen", + "app_resolutions_created_at": "Erstellt", + "app_resolutions_published_at": "Veröffentlicht", + "app_resolutions_empty": "Noch keine Beschlüsse.", + "app_resolutions_fields": "Beschlussfelder", + "app_resolutions_errors_create_title": "Beschluss konnte nicht angelegt werden", + "auth_logout_signing_out": "Signing you out…", + "auth_logout_cleared": "Your session has been cleared.", + "auth_logout_sign_in_again": "Sign in again", + "app_nav_home": "Home", + "app_resolutions_resolution": "Resolution", + "app_resolutions_created": "Created", + "app_resolutions_id": "ID", + "app_resolutions_view_pdf": "View PDF", + "app_resolutions_no_fields": "No fields recorded.", + "app_resolutions_pdf_preview": "PDF preview", + "app_common_close": "Close", + "landing_eyebrow": "GmbH-Beschlussverwaltung", + "landing_headline_plain": "GmbH-Beschlüsse —", + "landing_headline_accent": "entworfen, unterzeichnet, archiviert.", + "landing_subline": "Von der Vorlage zur Unterschrift bis zum Archiv. Erstellen Sie rechtssichere Beschlüsse, verwalten Sie Gesellschafter und Anteile — in Minuten, nicht Meetings.", + "landing_cta_primary": "Jetzt kostenlos starten", + "landing_cta_secondary": "Demo buchen", + "landing_stat1_value": "12", + "landing_stat1_label": "Beschluss-Vorlagen", + "landing_stat2_value": "§ 48", + "landing_stat2_label": "GmbHG-konform", + "landing_stat3_value": "< 5 Min.", + "landing_stat3_label": "bis zum signierten PDF", + "landing_features_eyebrow": "Alles für Ihre GmbH", + "landing_feature1_title": "Fertige Vorlagen", + "landing_feature1_desc": "Gewinnverteilung, Kapitalerhöhung, GF-Bestellung — GmbH-spezifische Beschlüsse, von Anfang an korrekt formuliert.", + "landing_feature2_title": "PDF & Archiv", + "landing_feature2_desc": "Prüfungssichere PDFs sofort erstellen. Veröffentlichen, siegeln und archivieren mit lückenlosem Zeitstempel-Verlauf.", + "landing_feature3_title": "Mehrere Gesellschaften & Anteile", + "landing_feature3_desc": "Geschäftsführer, Gesellschafter und Gesellschafterliste aller GmbH-Einheiten an einem Ort verwalten.", + "landing_feature4_title": "Rechtssicher strukturiert", + "landing_feature4_desc": "Dokumente folgen dem deutschen Gesellschaftsrecht — damit das, was Sie unterzeichnen, auch gilt.", + "landing_footer": "QUORUM · GMBH GOVERNANCE · BERLIN", + "landing_nav_product": "Produkt", + "landing_nav_templates": "Vorlagen", + "landing_nav_pricing": "Preise", + "landing_nav_cta": "Loslegen" +} diff --git a/apps/app/messages/en.json b/apps/app/messages/en.json new file mode 100644 index 0000000..0b12779 --- /dev/null +++ b/apps/app/messages/en.json @@ -0,0 +1,197 @@ +{ + "$schema": "https://inlang.com/schema/inlang-message-format", + "common_brand_name": "Quorum", + "common_brand_tagline": "GmbH resolutions, without the paperwork.", + "common_nav_toggle": "Toggle navigation", + "common_nav_logout": "Log out", + "common_nav_items_companies": "Companies", + "common_nav_items_portfolio": "Portfolio", + "common_actions_submit": "Submit", + "common_actions_cancel": "Cancel", + "common_actions_save": "Save", + "common_actions_delete": "Delete", + "common_actions_confirm": "Confirm", + "common_states_loading": "Loading…", + "common_states_empty": "No results.", + "common_states_error": "Something went wrong.", + "auth_login_title": "Welcome back", + "auth_login_subtitle": "Sign in to manage your GmbH resolutions.", + "auth_login_email": "Email", + "auth_login_email_placeholder": "you@example.com", + "auth_login_password": "Password", + "auth_login_submit": "Sign in", + "auth_login_forgot_password": "Forgot password?", + "auth_login_no_account": "Don't have an account?", + "auth_login_signup_cta": "Create one", + "auth_login_or": "or", + "auth_login_providers_continue_with": "Continue with {provider}", + "auth_login_errors_invalid_credentials": "Email or password is incorrect.", + "auth_login_errors_account_locked": "Your account is locked. Contact support.", + "auth_login_errors_generic": "Sign in failed. Please try again.", + "auth_signup_title": "Create your account", + "auth_signup_subtitle": "Start drafting GmbH resolutions in minutes.", + "auth_signup_display_name": "Your name", + "auth_signup_password_hint": "At least 8 characters.", + "auth_signup_submit": "Create account", + "auth_signup_have_account": "Already have an account?", + "auth_signup_login_cta": "Sign in", + "auth_signup_errors_email_taken": "An account with this email already exists.", + "auth_signup_errors_generic": "Could not create your account. Please try again.", + "auth_logout_title": "Signed out", + "auth_logout_subtitle": "You've been signed out. See you soon.", + "auth_logout_back_to_login": "Back to sign in", + "auth_brand_headline_line1": "Every resolution,", + "auth_brand_headline_line2": "signed, sealed and kept where it belongs.", + "auth_brand_tagline": "An audit-ready archive of your GmbH's Beschlüsse, built on German corporate law.", + "app_title": "GmbH Resolution Manager", + "app_tagline": "From template to signature to archive.", + "app_nav_companies": "Companies", + "app_nav_portfolio": "Portfolio", + "app_nav_logout": "Sign out", + "app_nav_sign_in": "Sign in", + "app_companies_list_title": "Your companies", + "app_companies_empty": "You're not a member of any company yet.", + "app_companies_new_company": "New company", + "app_companies_cancel": "Cancel", + "app_companies_count_subtitle": [ + { + "declarations": [ + "input count", + "local countPlural = count: plural" + ], + "selectors": [ + "countPlural" + ], + "match": { + "countPlural=other": "{count} companies" + } + } + ], + "app_companies_create": "Create company", + "app_companies_name": "Company name", + "app_companies_registry_no": "Registry number", + "app_companies_address": "Address", + "app_companies_address_line1": "Address line 1", + "app_companies_address_line2": "Address line 2", + "app_companies_address_line2_optional": "Address line 2 (optional)", + "app_companies_postcode": "Postcode", + "app_companies_town": "Town / city", + "app_companies_country": "Country", + "app_companies_total_shares": "Total shares", + "app_companies_total_shares_hint": "The total number of shares the company has issued.", + "app_companies_shares_assigned": "{assigned} / {total} shares assigned", + "app_companies_shares_remaining": [ + { + "declarations": [ + "input count", + "local countPlural = count: plural" + ], + "selectors": [ + "countPlural" + ], + "match": { + "countPlural=other": "{count} shares remaining" + } + } + ], + "app_companies_shares_fully_assigned": "All shares assigned", + "app_companies_fiscal_year_end": "Fiscal year end (MM-DD)", + "app_companies_fiscal_year_end_short": "FY end", + "app_companies_fiscal_month": "Fiscal year-end month", + "app_companies_fiscal_day": "Day", + "app_companies_fiscal_month_placeholder": "Select month", + "app_companies_fiscal_day_placeholder": "Select day", + "app_companies_role": "Role", + "app_companies_open": "Open", + "app_companies_members": "Members", + "app_companies_add_member": "Add member", + "app_companies_add_member_title": "Add member", + "app_companies_member_email": "Member email", + "app_companies_member_display_name": "Display name", + "app_companies_member_role": "Role", + "app_companies_member_shares": "Shares", + "app_companies_remove": "Remove", + "app_companies_roles_enum_managing_director": "Managing director", + "app_companies_roles_enum_shareholder": "Shareholder", + "app_companies_roles_enum_angel": "Angel", + "app_companies_months_10": "October", + "app_companies_months_11": "November", + "app_companies_months_12": "December", + "app_companies_months_01": "January", + "app_companies_months_02": "February", + "app_companies_months_03": "March", + "app_companies_months_04": "April", + "app_companies_months_05": "May", + "app_companies_months_06": "June", + "app_companies_months_07": "July", + "app_companies_months_08": "August", + "app_companies_months_09": "September", + "app_companies_empty_state_title": "No companies yet", + "app_companies_empty_state_description": "Add your first company to start drafting and archiving resolutions.", + "app_companies_empty_state_cta": "Create your first company", + "app_companies_errors_load_title": "Couldn't load companies", + "app_companies_errors_create_title": "Couldn't create company", + "app_companies_errors_add_member_title": "Couldn't add member", + "app_companies_errors_name_required": "Company name is required.", + "app_companies_errors_total_shares_invalid": "Enter a non-negative whole number.", + "app_companies_errors_shares_exceed_total": "Shares exceed the {remaining} remaining of {total}.", + "app_companies_errors_fiscal_invalid": "Use MM-DD (e.g. 12-31).", + "app_companies_errors_fiscal_month_required": "Select a month.", + "app_companies_errors_fiscal_day_required": "Select a day.", + "app_companies_success_title": "Company created", + "app_companies_success_message": "{name} has been added to your companies.", + "app_resolutions_title": "Resolutions", + "app_resolutions_new": "New resolution", + "app_resolutions_new_title": "New resolution", + "app_resolutions_create": "Create draft", + "app_resolutions_select_template": "Select template", + "app_resolutions_resolution_title": "Title", + "app_resolutions_draft": "Draft", + "app_resolutions_published": "Published", + "app_resolutions_archived": "Archived", + "app_resolutions_publish": "Publish & generate PDF", + "app_resolutions_archive": "Archive", + "app_resolutions_open_pdf": "Open PDF", + "app_resolutions_created_at": "Created", + "app_resolutions_published_at": "Published", + "app_resolutions_empty": "No resolutions yet.", + "app_resolutions_fields": "Resolution fields", + "app_resolutions_errors_create_title": "Couldn't create resolution", + "auth_logout_signing_out": "Signing you out…", + "auth_logout_cleared": "Your session has been cleared.", + "auth_logout_sign_in_again": "Sign in again", + "app_nav_home": "Home", + "app_resolutions_resolution": "Resolution", + "app_resolutions_created": "Created", + "app_resolutions_id": "ID", + "app_resolutions_view_pdf": "View PDF", + "app_resolutions_no_fields": "No fields recorded.", + "app_resolutions_pdf_preview": "PDF preview", + "app_common_close": "Close", + "landing_eyebrow": "GmbH Resolution Manager", + "landing_headline_plain": "Board resolutions for your GmbH —", + "landing_headline_accent": "drafted, signed, archived.", + "landing_subline": "From template to signature to archive. Generate legally-structured Beschlüsse, manage shareholders and shares, and keep an audit-ready record — in minutes, not meetings.", + "landing_cta_primary": "Get started free", + "landing_cta_secondary": "Book a walkthrough", + "landing_stat1_value": "12", + "landing_stat1_label": "Beschluss templates", + "landing_stat2_value": "§ 48", + "landing_stat2_label": "GmbHG-conform", + "landing_stat3_value": "< 5 min", + "landing_stat3_label": "to a signed PDF", + "landing_features_eyebrow": "Everything your GmbH needs", + "landing_feature1_title": "Ready-to-use templates", + "landing_feature1_desc": "Profit distributions, capital increases, MD appointments — GmbH-specific Beschlüsse, drafted correctly from the start.", + "landing_feature2_title": "PDF & archive", + "landing_feature2_desc": "Generate audit-ready PDFs instantly. Publish, seal and archive with a complete, timestamped trail.", + "landing_feature3_title": "Multi-company & shares", + "landing_feature3_desc": "Manage directors, shareholders and the share ledger across every GmbH entity in one place.", + "landing_feature4_title": "Legally structured", + "landing_feature4_desc": "Documents follow German corporate law, so what you sign holds up where it matters.", + "landing_footer": "QUORUM · GMBH GOVERNANCE · BERLIN", + "landing_nav_product": "Product", + "landing_nav_templates": "Templates", + "landing_nav_pricing": "Pricing", + "landing_nav_cta": "Get started" +} diff --git a/apps/app/package.json b/apps/app/package.json new file mode 100644 index 0000000..7360d40 --- /dev/null +++ b/apps/app/package.json @@ -0,0 +1,63 @@ +{ + "name": "@germantax/app", + "version": "0.0.1", + "private": true, + "type": "module", + "fabric": { + "template": "_app_template_", + "templateVersion": "0.0.1", + "instantiatedAt": "2026-04-17T11:42:52Z" + }, + "scripts": { + "dev": "vite --port 7104", + "build": "tsc -b && vite build", + "preview": "vite preview --port 7104", + "tsc": "tsc --noEmit", + "lint": "eslint src", + "lint:css": "stylelint \"src/**/*.{css,ts,tsx}\"" + }, + "dependencies": { + "@germantax/components": "workspace:*", + "@germantax/functions": "workspace:*", + "@germantax/functions-sdk": "workspace:*", + "@germantax/mantine-theme": "workspace:*", + "@mantine/core": "^9.2.1", + "@mantine/hooks": "^9.2.1", + "@pikku/core": "^0.12.57", + "@pikku/fetch": "^0.12.6", + "@pikku/mantine": "^0.12.6", + "@pikku/react": "^0.12.5", + "@tanstack/react-form": "^0.43.0", + "@tanstack/react-query": "^5.90.10", + "@tanstack/react-router": "^1.100.0", + "@tanstack/react-start": "^1.100.0", + "@tanstack/react-table": "^8.20.0", + "better-auth": "^1.6.19", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "lucide-react": "^0.554.0", + "react": "^19.2.5", + "react-dom": "^19.2.5" + }, + "devDependencies": { + "@babel/core": "^7.26.0", + "@inlang/paraglide-js": "^2.20.0", + "@tanstack/router-generator": "^1.100.0", + "@tanstack/router-plugin": "^1.100.0", + "@types/node": "^22", + "@types/react": "^19", + "@types/react-dom": "^19", + "@vitejs/plugin-react": "^4.5.2", + "autoprefixer": "^10.4.20", + "eslint": "^9.15.0", + "eslint-plugin-i18next": "^6.1.1", + "eslint-plugin-jsx-a11y": "^6.10.2", + "eslint-plugin-react": "^7.37.2", + "postcss": "^8.5.3", + "stylelint": "^16.10.0", + "stylelint-config-standard": "^36.0.1", + "stylelint-use-logical": "^2.1.2", + "typescript": "^5.9", + "vite": "^7.0.0" + } +} diff --git a/apps/app/postcss.config.js b/apps/app/postcss.config.js new file mode 100644 index 0000000..b6dc034 --- /dev/null +++ b/apps/app/postcss.config.js @@ -0,0 +1,5 @@ +export default { + plugins: { + autoprefixer: {}, + }, +} diff --git a/apps/app/project.inlang/settings.json b/apps/app/project.inlang/settings.json new file mode 100644 index 0000000..7883ee6 --- /dev/null +++ b/apps/app/project.inlang/settings.json @@ -0,0 +1,7 @@ +{ + "$schema": "https://inlang.com/schema/project-settings", + "baseLocale": "en", + "locales": ["en", "de"], + "modules": ["https://cdn.jsdelivr.net/npm/@inlang/plugin-message-format@4/dist/index.js"], + "plugin.inlang.messageFormat": { "pathPattern": "./messages/{locale}.json" } +} diff --git a/apps/app/src/auth.config.ts b/apps/app/src/auth.config.ts new file mode 100644 index 0000000..c0320d2 --- /dev/null +++ b/apps/app/src/auth.config.ts @@ -0,0 +1,13 @@ +import { credentials } from './auth/providers/credentials' +import type { AuthProvider } from './auth/providers/_types' + +/** + * Active auth providers for this app. + * + * Providers are copied from `src/auth/providers/*.ts.inactive` into the + * active set. Each provider's backend handler reads its secrets via + * `secrets.getSecret()` (never from `process.env` on the client). + */ +export const activeProviders: AuthProvider[] = [credentials] + +export const primaryProvider = activeProviders[0] diff --git a/apps/app/src/auth.tsx b/apps/app/src/auth.tsx new file mode 100644 index 0000000..dd72ecd --- /dev/null +++ b/apps/app/src/auth.tsx @@ -0,0 +1,39 @@ +import { createContext, useContext } from 'react' +import { Navigate } from '@tanstack/react-router' +import { Center, Loader } from '@pikku/mantine/core' +import { usePikkuQuery } from '@germantax/functions-sdk/pikku/api.gen' +import { AppLayout } from './components/AppLayout' + +type Me = ReturnType>['data'] + +const AuthContext = createContext(null) + +export function useAuth(): NonNullable { + const ctx = useContext(AuthContext) + if (!ctx) { + throw new Error('useAuth must be used inside ') + } + return ctx +} + +export function RequireAuth({ children }: { children: React.ReactNode }) { + const { data, isLoading, error } = usePikkuQuery('getMe', undefined as never, { + retry: false, + }) + + if (isLoading) { + return ( +
+ +
+ ) + } + if (error || !data) { + return + } + return ( + + {children} + + ) +} diff --git a/apps/app/src/auth/AuthLayout.tsx b/apps/app/src/auth/AuthLayout.tsx new file mode 100644 index 0000000..7fd53d1 --- /dev/null +++ b/apps/app/src/auth/AuthLayout.tsx @@ -0,0 +1,163 @@ +import type { ReactNode } from 'react' +import { Box, Paper, Stack, Text, Title } from '@pikku/mantine/core' +import { m } from '@/i18n/messages' +import { useLocale } from '@/i18n/config' +import { asI18n } from '@pikku/react' +import type { I18nNode } from '@pikku/react' + +function QSeal({ size = 30, fontSize = 17, bg = '#1F5641', color = '#FBFAF7', radius = 7 }: { + size?: number; fontSize?: number; bg?: string; color?: string; radius?: number +}) { + return ( + + Q + + ) +} + +function BrandPanel({ tagline }: { tagline: I18nNode }) { + useLocale() + return ( + + + + + + § + + + + {m.auth_brand_headline_line1()} + <br /> + <span style={{ fontStyle: 'italic', color: '#8FCBA9' }}> + {m.auth_brand_headline_line2()} + </span> + + + + {tagline} + + + {/* Mini resolution card */} + + + + {asI18n('BESCHLUSS · 2026-014')} + + + Published + + + + + {asI18n('Appropriation of 2025 result')} + + + {asI18n('Distributable profit')} + {asI18n('€ 248,500.00')} + + + + + + ) +} + +export function AuthLayout({ + title, + subtitle, + tagline, + children, + footer, +}: { + title: I18nNode + subtitle?: I18nNode + tagline: I18nNode + children: ReactNode + footer?: I18nNode +}) { + return ( + + {/* Form side */} + + + + + + + {m.common_brand_name()} + + + +
+ + {title} + + {subtitle && ( + + {subtitle} + + )} +
+ {children} + {footer && ( + + {footer} + + )} +
+
+
+ + {/* Brand side — hidden on mobile */} + + + +
+ ) +} diff --git a/apps/app/src/auth/providers/_types.ts b/apps/app/src/auth/providers/_types.ts new file mode 100644 index 0000000..ba670fa --- /dev/null +++ b/apps/app/src/auth/providers/_types.ts @@ -0,0 +1,20 @@ +import type { ComponentType } from 'react' + +/** + * Shape every auth provider file exports. + * + * Providers are shadcn-style: copy from `providers/*.ts.inactive` into an + * active `.ts` file and register in `auth.config.ts`. Backend OAuth routes + * live in `@germantax/functions/src/auth/*`. + */ +export interface AuthProvider { + id: 'auth0' | 'email' | 'github' | 'google' | 'microsoft' | 'passwordless' + labelKey: string + Icon: ComponentType<{ size?: number | string }> + /** Variant tone used for provider button treatment. */ + tone: 'primary' | 'secondary' | 'default' + /** Required env var names — surfaced by the console's auth browser. */ + envVars: string[] + /** Kicks off the login flow. May redirect (OAuth) or open a modal (email). */ + login(): Promise | void +} diff --git a/apps/app/src/auth/providers/credentials.ts b/apps/app/src/auth/providers/credentials.ts new file mode 100644 index 0000000..f417e16 --- /dev/null +++ b/apps/app/src/auth/providers/credentials.ts @@ -0,0 +1,18 @@ +import { Mail } from 'lucide-react' +import type { AuthProvider } from './_types' + +/** + * Email + password via @pikku/auth-js credentials provider. + * The backend route is POST /auth/callback/credentials. + * Login is handled by the /login form directly. + */ +export const credentials: AuthProvider = { + id: 'email', + labelKey: 'auth:providers.email', + Icon: Mail, + tone: 'primary', + envVars: ['AUTH_SECRET'], + login: () => { + window.location.href = '/login' + }, +} diff --git a/apps/app/src/auth/session-provider.tsx b/apps/app/src/auth/session-provider.tsx new file mode 100644 index 0000000..632aa1d --- /dev/null +++ b/apps/app/src/auth/session-provider.tsx @@ -0,0 +1,87 @@ +import { + createContext, + useContext, + useEffect, + type ReactNode, +} from 'react' +import { useNavigate, useRouterState } from '@tanstack/react-router' +import { Center, Loader } from '@pikku/mantine/core' +import { usePikkuQuery } from '@germantax/functions-sdk/pikku/api.gen' + +export interface Session { + userId: string + email: string + displayName: string +} + +interface SessionContextValue { + session: Session + refetch: () => void +} + +const SessionContext = createContext(null) + +const PUBLIC_ROUTES = new Set(['/login', '/logout']) + +const isPublicRoute = (pathname: string) => PUBLIC_ROUTES.has(pathname) + +export function SessionProvider({ children }: { children: ReactNode }) { + const navigate = useNavigate() + const pathname = useRouterState({ select: (s) => s.location.pathname }) + const onPublicRoute = isPublicRoute(pathname) + + const me = usePikkuQuery('getMe', undefined as never, { + enabled: !onPublicRoute, + retry: false, + staleTime: 60_000, + }) + + useEffect(() => { + if (onPublicRoute) return + if (me.error) { + navigate({ + to: '/login', + search: { redirect: pathname } as never, + replace: true, + }) + } + }, [me.error, onPublicRoute, navigate, pathname]) + + if (onPublicRoute) { + return <>{children} + } + + if (me.isLoading || me.error || !me.data) { + return ( +
+ +
+ ) + } + + return ( + me.refetch() }} + > + {children} + + ) +} + +export function useSession(): Session { + const ctx = useContext(SessionContext) + if (!ctx) { + throw new Error('useSession must be used inside ') + } + return ctx.session +} + +export function useOptionalSession(): Session | null { + const ctx = useContext(SessionContext) + return ctx?.session ?? null +} + +export function useSessionRefetch(): () => void { + const ctx = useContext(SessionContext) + return ctx?.refetch ?? (() => {}) +} diff --git a/apps/app/src/components/AppLayout.tsx b/apps/app/src/components/AppLayout.tsx new file mode 100644 index 0000000..82a5af4 --- /dev/null +++ b/apps/app/src/components/AppLayout.tsx @@ -0,0 +1,207 @@ +import { Link, useLocation, useNavigate } from '@tanstack/react-router' +import { + AppShell, + Avatar, + Burger, + Group, + Menu, + NavLink, + Stack, + Text, + UnstyledButton, +} from '@pikku/mantine/core' +import { useDisclosure } from '@mantine/hooks' +import { useQueryClient } from '@tanstack/react-query' +import { m, mKey } from '@/i18n/messages' +import { useLocale } from '@/i18n/config' +import { asI18n } from '@pikku/react' +import { createContext, useContext, useEffect, useState } from 'react' +import { Building2, LayoutDashboard } from 'lucide-react' + +const PageTitleContext = createContext<((title: string | undefined) => void) | null>(null) + +export function PageTitle({ title }: { title: string }) { + usePageTitle(title) + return null +} + +export function usePageTitle(title: string | undefined) { + const setTitle = useContext(PageTitleContext) + useEffect(() => { + if (!setTitle) return + setTitle(title) + return () => setTitle(undefined) + }, [setTitle, title]) +} + +const NAV_ITEMS = [ + { to: '/companies', labelKey: 'common.nav.items.companies', Icon: Building2 }, + { to: '/portfolio', labelKey: 'common.nav.items.portfolio', Icon: LayoutDashboard }, +] as const + +const initials = (name: string) => + name + .split(/\s+/) + .filter(Boolean) + .slice(0, 2) + .map((p) => p[0]?.toUpperCase() ?? '') + .join('') + +function QSeal() { + return ( + + Q + + ) +} + +export function AppLayout({ + user, + children, +}: { + user: { userId: string; email: string; displayName: string } + children: React.ReactNode +}) { + useLocale() + const [opened, { toggle, close }] = useDisclosure() + const [pageTitle, setPageTitle] = useState(undefined) + const location = useLocation() + const navigate = useNavigate() + const qc = useQueryClient() + + const API_URL = import.meta.env.VITE_API_URL ?? 'http://localhost:4003' + + const handleLogout = async () => { + try { + const csrfRes = await fetch(`${API_URL}/auth/csrf`, { credentials: 'include' }) + const { csrfToken } = (await csrfRes.json()) as { csrfToken: string } + const body = new URLSearchParams({ csrfToken, callbackUrl: '/login' }) + await fetch(`${API_URL}/auth/signout`, { + method: 'POST', + headers: { 'content-type': 'application/x-www-form-urlencoded' }, + body, + credentials: 'include', + redirect: 'manual', + }) + qc.clear() + navigate({ to: '/login' }) + } catch { + navigate({ to: '/logout' }) + } + } + + return ( + + + + + {/* Logo area matches sidebar width */} + + + + + + {m.common_brand_name()} + + + + + {/* Right: breadcrumb + user */} + + + {pageTitle && ( + <> + + {m.common_brand_name()} + + / + + {asI18n(pageTitle ?? '')} + + + )} + + + + + + {asI18n(user.displayName)} + {asI18n(user.email)} + + + {asI18n(initials(user.displayName))} + + + + + {asI18n(user.email)} + + + {m.common_nav_logout()} + + + + + + + + + +

+ Workspace +

+ + {NAV_ITEMS.map(({ to, labelKey, Icon }) => ( + } + active={location.pathname.startsWith(to)} + onClick={close} + /> + ))} + +
+ + + {m.common_brand_tagline()} + + +
+ + {children} +
+
+ ) +} diff --git a/apps/app/src/direction.ts b/apps/app/src/direction.ts new file mode 100644 index 0000000..97c43ce --- /dev/null +++ b/apps/app/src/direction.ts @@ -0,0 +1,40 @@ +/** + * Document direction (LTR / RTL). + * + * v1 is EN-only but every widget and page must render correctly under + * RTL — the plumbing is always active. Precedence: + * + * 1. `?dir=rtl` query param (dev override — test any route in RTL) + * 2. localStorage `fabric-dir` (user preference across reloads) + * 3. `document.documentElement.dir` + * 4. 'ltr' fallback + * + * Locale-bound direction (Arabic / Hebrew / Persian → RTL) is post-v1. + * Shape supports it — call `setDirection('rtl')` when a locale loads. + */ + +export type Direction = 'ltr' | 'rtl' + +const STORAGE_KEY = 'fabric-dir' + +export function readInitialDirection(): Direction { + if (typeof window === 'undefined') return 'ltr' + + const query = new URLSearchParams(window.location.search).get('dir') + if (query === 'rtl' || query === 'ltr') return query + + const stored = window.localStorage.getItem(STORAGE_KEY) + if (stored === 'rtl' || stored === 'ltr') return stored + + const docDir = window.document.documentElement.dir + if (docDir === 'rtl' || docDir === 'ltr') return docDir + + return 'ltr' +} + +export function applyDirection(dir: Direction) { + if (typeof window !== 'undefined') { + window.localStorage.setItem(STORAGE_KEY, dir) + window.document.documentElement.dir = dir + } +} diff --git a/apps/app/src/i18n/config.ts b/apps/app/src/i18n/config.ts new file mode 100644 index 0000000..8b4cd86 --- /dev/null +++ b/apps/app/src/i18n/config.ts @@ -0,0 +1,101 @@ +// i18n configuration — Paraglide JS (inlang). Messages live in `messages/*.json` +// and are compiled to `src/paraglide/` by the Vite plugin (gitignored). This +// module is the locale-plumbing entry point; `@/i18n/messages` exposes the typed +// message functions (`m`). +// +// Add a language: drop `messages/.json` next to `en.json`, add the code to +// `project.inlang/settings.json` `locales`, and recompile. Content is reachable +// via the `/` URL prefix (e.g. `/fr`); the base locale (`en`) needs none. +import { useSyncExternalStore } from 'react' +import { locales, baseLocale, overwriteGetLocale, getLocale } from '../paraglide/runtime.js' + +export const supportedLocales = locales +export const defaultLocale = baseLocale +export type Locale = (typeof locales)[number] +// `getLocale()` reads the active locale (via the overwrite below) — used by +// pages that format dates/values for the current language. +export { getLocale } + +const LANGUAGE_STORAGE_KEY = 'germantax.language' + +const RTL_LOCALES = new Set(['ar', 'he', 'fa', 'ur']) +// Direction for a locale — RTL for Arabic/Hebrew/Farsi/Urdu, else LTR. Set this +// on at the root so the browser (and Mantine) mirror the layout. +export function localeDir(locale: string = defaultLocale): 'rtl' | 'ltr' { + return RTL_LOCALES.has(locale.split('-')[0]) ? 'rtl' : 'ltr' +} + +const normalizeLanguage = (value: string | null | undefined): Locale | undefined => { + const short = value?.slice(0, 2).toLowerCase() + return supportedLocales.includes(short as Locale) ? (short as Locale) : undefined +} + +// Initial locale: persisted choice → browser → → base. (Ported from +// the old i18next init; germantax has no in-app switcher, locale is read once.) +export function detectInitialLocale(): Locale { + if (typeof window === 'undefined') return defaultLocale + return ( + normalizeLanguage(window.localStorage.getItem(LANGUAGE_STORAGE_KEY)) ?? + normalizeLanguage(window.navigator.language) ?? + normalizeLanguage(window.document.documentElement.lang) ?? + defaultLocale + ) +} + +// ── reactive locale store ──────────────────────────────────────────────────── +// Paraglide's `getLocale()` is a module-global, decoupled from React. We bridge +// it to a tiny external store: `getLocale` reads `activeLocale`; components +// subscribe via `useLocale()` (useSyncExternalStore) so `m.*()` re-renders on +// switch — no page reload, no Paraglide `setLocale`, no app-specific context. +// The app's own setLocale (persist + ) calls `setActiveLocale`. +let activeLocale: Locale = defaultLocale +const listeners = new Set<() => void>() +overwriteGetLocale(() => activeLocale) + +export function setActiveLocale(next: Locale): void { + if (next === activeLocale) return + activeLocale = next + // Persist + reflect on (ported from the old i18next languageChanged + // handler), so a reload restores the choice. + if (typeof window !== 'undefined') { + window.localStorage.setItem(LANGUAGE_STORAGE_KEY, next) + window.document.documentElement.lang = next + } + for (const fn of listeners) fn() +} + +function subscribe(fn: () => void): () => void { + listeners.add(fn) + return () => listeners.delete(fn) +} + +// Resolve the initial locale once at startup (import side-effect, matching the +// old i18n.ts init). Importing this module from the app entry runs it. +activeLocale = detectInitialLocale() + +// Subscribe a component to locale changes so `m.*()` re-renders on switch. The +// codemod injects a bare `useLocale()` wherever `const { t } = useTranslation()` +// used to live; it also returns the active locale + direction for components +// that need them (e.g. the language switcher). +export function useLocale(): { locale: Locale; dir: 'ltr' | 'rtl'; setLocale: (l: Locale) => void } { + const locale = useSyncExternalStore(subscribe, () => activeLocale, () => activeLocale) + return { locale, dir: localeDir(locale), setLocale: setActiveLocale } +} + +// ── i18n-debug masking ─────────────────────────────────────────────────────── +// When enabled, every *translated* string is masked to block glyphs (█) so any +// readable text left on screen is text that never went through a message (a +// hardcoded/inlined string) — missing i18n at a glance. Toggle with `?i18n-debug` +// in the URL or `localStorage['i18n-debug'] = '1'` (`I18N_DEBUG=1` for SSR). +export function isI18nDebug(): boolean { + if (typeof process !== 'undefined' && process.env?.I18N_DEBUG === '1') return true + if (typeof window === 'undefined') return false + const params = new URLSearchParams(window.location.search) + if (params.has('i18n-debug')) return params.get('i18n-debug') !== '0' + return window.localStorage?.getItem('i18n-debug') === '1' +} + +/** Mask a rendered string when debug mode is on; otherwise pass it through. */ +export function maskI18n(s: string): string { + return isI18nDebug() ? s.replace(/\S/g, '█') : s +} diff --git a/apps/app/src/i18n/ident.ts b/apps/app/src/i18n/ident.ts new file mode 100644 index 0000000..fd3fe36 --- /dev/null +++ b/apps/app/src/i18n/ident.ts @@ -0,0 +1,12 @@ +// Single source of truth for turning an i18next dotted key (`landing.cards.x`) +// into a Paraglide snake_case message identifier (`landing_cards_x`). Used by the +// runtime resolver `mKey` for the unavoidable dynamic cases. +export const identOf = (dotPath: string): string => + dotPath + .replace(/([a-z0-9])([A-Z])/g, '$1_$2') + .replace(/([A-Z]+)([A-Z][a-z])/g, '$1_$2') + .replace(/[.\-\s]+/g, '_') + .replace(/[^A-Za-z0-9_]/g, '') + .replace(/_+/g, '_') + .replace(/^_+|_+$/g, '') + .toLowerCase() diff --git a/apps/app/src/i18n/messages.ts b/apps/app/src/i18n/messages.ts new file mode 100644 index 0000000..926e7ce --- /dev/null +++ b/apps/app/src/i18n/messages.ts @@ -0,0 +1,54 @@ +// Typed message functions for the app. `m.landing_title()` returns a branded +// I18nString, so it satisfies the @pikku/mantine i18n gate exactly where the old +// `t('landing.title')` used to — no call-site boilerplate. +// +// Each message is wrapped once so i18n-debug masking (█) still works (parity with +// the old i18next postProcessor). Wrapping the whole namespace forgoes Paraglide +// per-message tree-shaking — fine here: locale files are KB and the app bundled +// all of en.json under i18next anyway. +import { m as _m } from '../paraglide/messages.js' +import type { I18nString } from '@pikku/react' +import { identOf } from './ident.js' +import { maskI18n } from './config.js' + +type BrandReturn = T extends (...args: infer A) => unknown ? (...args: A) => I18nString : T +type Branded = { [K in keyof T]: BrandReturn } + +const _raw = _m as unknown as Record) => string> +const _wrapped: Record = {} +for (const key of Object.keys(_raw)) { + const fn = _raw[key] + _wrapped[key] = typeof fn === 'function' ? (...args: unknown[]) => maskI18n((fn as (...a: unknown[]) => string)(...args)) : fn +} + +/** Branded, debug-maskable message namespace. Drop-in for `t('...')`. */ +export const m = _wrapped as unknown as Branded + +/** + * Runtime key resolver for computed keys — `mKey(`landing.cards.${k}.title`)` or + * `mKey(MAP[k])`. Flattens the dotted key to its snake_case token and calls the + * message. Returns the raw key (and warns) on a miss, mirroring i18next's + * graceful degradation. Prefer static `m.token(...)` wherever the key is known. + */ +export const mKey = (key: string, args?: Record): I18nString => { + const fn = _raw[identOf(key)] + if (!fn) { + if (typeof console !== 'undefined') console.warn(`[i18n] missing message for key "${key}"`) + return maskI18n(key) as unknown as I18nString + } + return maskI18n(fn(args)) as unknown as I18nString +} + +/** + * Resolves an i18next `returnObjects` array (stored as indexed keys `prefix.0`, + * `prefix.1`, …) back into a list. Returns messages until the first gap. + */ +export const mList = (keyPrefix: string, args?: Record): I18nString[] => { + const out: I18nString[] = [] + for (let i = 0; ; i++) { + const fn = _raw[identOf(`${keyPrefix}.${i}`)] + if (!fn) break + out.push(maskI18n(fn(args)) as unknown as I18nString) + } + return out +} diff --git a/apps/app/src/lib/auth.ts b/apps/app/src/lib/auth.ts new file mode 100644 index 0000000..716940f --- /dev/null +++ b/apps/app/src/lib/auth.ts @@ -0,0 +1,41 @@ +import { createAuthClient } from 'better-auth/client' +import { organizationClient } from 'better-auth/client/plugins' +import { apiUrl } from './env' + +// Better Auth mounts at /api/auth (default basePath); the client appends the +// endpoint verbatim to baseURL, so the base must include /auth — otherwise it +// emits /api/sign-in/email and 404s on both sandbox and deploy. +export const authClient = createAuthClient({ + baseURL: `${apiUrl()}/auth`, + plugins: [organizationClient()], +}) + +export const INVALID_CREDENTIALS = 'INVALID_CREDENTIALS' +export const EMAIL_IN_USE = 'EMAIL_IN_USE' + +export async function signInWithPassword(email: string, password: string): Promise { + const { error } = await authClient.signIn.email({ email, password }) + if (error) throw new Error(INVALID_CREDENTIALS) +} + +export async function registerWithPassword( + email: string, + password: string, + name: string, +): Promise { + const { error } = await authClient.signUp.email({ + email, + password, + name: name.trim() || email.split('@')[0], + }) + if (error) { + if (error.status === 422 || /exist|taken/i.test(error.message ?? '')) { + throw new Error(EMAIL_IN_USE) + } + throw new Error('Unable to create account') + } +} + +export async function signOut(): Promise { + await authClient.signOut() +} diff --git a/apps/app/src/lib/env.ts b/apps/app/src/lib/env.ts new file mode 100644 index 0000000..83f44db --- /dev/null +++ b/apps/app/src/lib/env.ts @@ -0,0 +1,21 @@ +// Endpoints come from env, never hardcoded. +// +// In local dev VITE_API_URL is set at build time and Vite replaces the +// import.meta.env reference inline. In deployed/sandbox bundles VITE_API_URL is +// a runtime Worker binding — invisible to Vite at build time — so it's undefined +// in the bundle. We fall back to the current origin + /api, which is correct +// because the dispatcher routes every /api/* path to the API on the same host. +export function apiUrl(): string { + if (typeof window !== 'undefined' && (window as any).__E2E_API_URL) { + return (window as any).__E2E_API_URL as string + } + if (import.meta.env.SSR) { + // SSR context: the auth client is only consumed in the browser. Return the + // build-time var when available or a placeholder so SSR never throws. + return import.meta.env.VITE_API_URL ?? '/__api' + } + // Client: build-time var (local dev) or derive from the current origin. + // Better Auth's client requires an absolute base URL, so never return a bare + // relative '/api' here — '/api/auth' throws "Invalid base URL". + return import.meta.env.VITE_API_URL ?? `${window.location.origin}/api` +} diff --git a/apps/app/src/pages/__root.tsx b/apps/app/src/pages/__root.tsx new file mode 100644 index 0000000..4035fb5 --- /dev/null +++ b/apps/app/src/pages/__root.tsx @@ -0,0 +1,78 @@ +import { useEffect, useState } from 'react' +import { HeadContent, Outlet, Scripts, createRootRoute } from '@tanstack/react-router' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { MantineProvider } from '@pikku/mantine/core' +import { PikkuProvider, createPikku } from '@pikku/react' +import '@mantine/core/styles.css' +import { PikkuFetch } from '@germantax/functions-sdk/pikku/pikku-fetch.gen' +import { PikkuRPC } from '@germantax/functions-sdk/pikku/pikku-rpc.gen' +import { theme } from '@germantax/mantine-theme' +import '../theme.css' +import '../i18n/config' + +declare global { + interface Window { + __E2E_API_URL?: string + } +} + +export const Route = createRootRoute({ + head: () => ({ + meta: [ + { charSet: 'utf-8' }, + { name: 'viewport', content: 'width=device-width, initial-scale=1' }, + { title: 'GmbH Resolution Manager' }, + ], + }), + component: RootDocument, +}) + +function RootDocument() { + const [queryClient] = useState( + () => + new QueryClient({ + defaultOptions: { + queries: { + staleTime: 10_000, + retry: 1, + refetchOnWindowFocus: false, + }, + mutations: { retry: 0 }, + }, + }), + ) + const runtimeApiUrl = + typeof window !== 'undefined' ? window.__E2E_API_URL : undefined + const [pikku] = useState(() => + createPikku(PikkuFetch, PikkuRPC, { + serverUrl: runtimeApiUrl ?? import.meta.env.VITE_API_URL ?? '/api', + credentials: 'include', + }), + ) + + return ( + + + + + + + + + + + + + + + + + ) +} + +function HydrationReady() { + useEffect(() => { + window.document.documentElement.dataset.appHydrated = 'true' + }, []) + return null +} diff --git a/apps/app/src/pages/companies/$companyId/index.tsx b/apps/app/src/pages/companies/$companyId/index.tsx new file mode 100644 index 0000000..c784627 --- /dev/null +++ b/apps/app/src/pages/companies/$companyId/index.tsx @@ -0,0 +1,520 @@ +import { createFileRoute, Link, useNavigate } from '@tanstack/react-router' +import { RequireAuth } from '../../../auth' +import { + Alert, + Badge, + Box, + Button, + Card, + Container, + Group, + Modal, + NumberInput, + Select, + Stack, + Table, + Text, + Textarea, + TextInput, + Title, +} from '@pikku/mantine/core' +import { useDisclosure } from '@mantine/hooks' +import { useMemo, useState } from 'react' +import { m, mKey } from '@/i18n/messages' +import { useLocale, getLocale } from '@/i18n/config' +import { asI18n } from '@pikku/react' +import { AlertCircle, Plus } from 'lucide-react' +import { usePikkuQuery, usePikkuMutation } from '@germantax/functions-sdk/pikku/api.gen' + +const ROLES = ['managing_director', 'shareholder', 'angel'] as const +type Role = (typeof ROLES)[number] + +type AddMemberFormState = { + email: string + displayName: string + role: Role + shares: number | '' +} + +const EMPTY_MEMBER_FORM: AddMemberFormState = { + email: '', + displayName: '', + role: 'shareholder', + shares: '', +} + +function CompanyPage() { + const { companyId } = Route.useParams() + useLocale() + const navigate = useNavigate() + const company = usePikkuQuery('getCompany', { companyId }) + const members = usePikkuQuery('listMembers', { companyId }) + const resolutions = usePikkuQuery('listResolutions', { companyId }) + const templates = usePikkuQuery('listResolutionTemplates', undefined as never) + + const [memberModalOpened, { open: openMemberModal, close: closeMemberModal }] = + useDisclosure(false) + const [resolutionModalOpened, { open: openResolutionModal, close: closeResolutionModal }] = + useDisclosure(false) + + const [memberForm, setMemberForm] = useState(EMPTY_MEMBER_FORM) + const [memberError, setMemberError] = useState(null) + + const [templateKey, setTemplateKey] = useState(null) + const [resolutionTitle, setResolutionTitle] = useState('') + const [fieldValues, setFieldValues] = useState>({}) + const [resolutionError, setResolutionError] = useState(null) + + const addMember = usePikkuMutation('addMember', { + onSuccess: () => { + setMemberForm(EMPTY_MEMBER_FORM) + setMemberError(null) + closeMemberModal() + members.refetch() + company.refetch() + }, + onError: (err: unknown) => { + setMemberError(err instanceof Error ? err.message : String(err)) + }, + }) + const removeMember = usePikkuMutation('removeMember', { + onSuccess: () => { + members.refetch() + company.refetch() + }, + }) + const archiveResolution = usePikkuMutation('archiveResolution', { + onSuccess: () => resolutions.refetch(), + }) + const publishResolution = usePikkuMutation('publishResolution', { + onSuccess: () => resolutions.refetch(), + }) + const createResolution = usePikkuMutation('createResolution', { + onSuccess: ({ resolutionId }) => { + closeResolutionModal() + navigate({ + to: '/companies/$companyId/resolutions/$resolutionId', + params: { companyId, resolutionId } as never, + }) + }, + onError: (err: unknown) => { + setResolutionError(err instanceof Error ? err.message : String(err)) + }, + }) + + const isMd = company.data?.myRole === 'managing_director' + const totalShares = company.data?.totalShares ?? 0 + const assignedShares = company.data?.assignedShares ?? 0 + const remainingShares = Math.max(0, totalShares - assignedShares) + + const lang = getLocale()?.startsWith('de') ? 'de' : 'en' + const labelOf = (f: { labelDe: string; labelEn: string }) => + lang === 'de' ? f.labelDe : f.labelEn + const template = useMemo( + () => templates.data?.find((tpl) => tpl.key === templateKey), + [templates.data, templateKey], + ) + + const handleOpenMemberModal = () => { + setMemberForm(EMPTY_MEMBER_FORM) + setMemberError(null) + openMemberModal() + } + + const handleSubmitMember = (e: React.FormEvent) => { + e.preventDefault() + if (!memberForm.email || !memberForm.displayName) return + setMemberError(null) + addMember.mutate({ + companyId, + email: memberForm.email, + displayName: memberForm.displayName, + role: memberForm.role, + shares: memberForm.shares === '' ? null : Number(memberForm.shares), + }) + } + + const handleOpenResolutionModal = () => { + setTemplateKey(null) + setResolutionTitle('') + setFieldValues({}) + setResolutionError(null) + openResolutionModal() + } + + const handleSubmitResolution = (e: React.FormEvent) => { + e.preventDefault() + if (!templateKey || !resolutionTitle) return + setResolutionError(null) + createResolution.mutate({ + companyId, + templateKey, + title: resolutionTitle, + fieldValues, + }) + } + + return ( + + + {company.error && {asI18n(String(company.error.message))}} + {company.data && ( + + + {asI18n(company.data.name)} + {company.data.registryNo && ( + {asI18n(company.data.registryNo)} + )} + {(company.data.addressLine1 || company.data.town) && ( + + {asI18n( + [ + company.data.addressLine1, + company.data.addressLine2, + [company.data.postcode, company.data.town].filter(Boolean).join(' '), + company.data.country, + ] + .filter(Boolean) + .join(', '), + )} + + )} + {totalShares > 0 && ( + + {m.app_companies_shares_assigned({ + assigned: assignedShares, + total: totalShares, + })} + + )} + + {mKey(`app.companies.rolesEnum.${company.data.myRole}`)} + + )} + + + + + {m.app_companies_members()} + {isMd && ( + + )} + + + + + {m.app_companies_member_display_name()} + {m.app_companies_member_email()} + {m.app_companies_role()} + {m.app_companies_member_shares()} + + + + + {members.data?.map((member) => ( + + {member.displayName} + {member.email} + {mKey(`app.companies.rolesEnum.${member.role}`)} + {member.shares ?? '—'} + + {isMd && ( + + )} + + + ))} + +
+
+
+ + + + + {m.app_resolutions_title()} + + + + {resolutions.data && resolutions.data.length === 0 && ( + {m.app_resolutions_empty()} + )} + + + {resolutions.data?.map((r) => ( + + + + + {asI18n(r.title)} + + {mKey(`app.resolutions.${r.status}`)} + + + {asI18n(r.templateKey)} + + + + {isMd && r.status === 'draft' && ( + + )} + {isMd && r.status !== 'archived' && ( + + )} + + + + ))} + + + +
+ + + + + {memberError && ( + + )} + + + setMemberForm({ ...memberForm, email: e.currentTarget.value }) + } + required + /> + + setMemberForm({ ...memberForm, displayName: e.currentTarget.value }) + } + required + /> + ({ + value: tpl.key, + label: lang === 'de' ? tpl.titleDe : tpl.titleEn, + })) ?? [] + } + value={templateKey} + onChange={(v) => { + setTemplateKey(v) + setFieldValues({}) + const tpl = templates.data?.find((x) => x.key === v) + if (tpl) setResolutionTitle(lang === 'de' ? tpl.titleDe : tpl.titleEn) + }} + searchable + required + /> + setResolutionTitle(e.currentTarget.value)} + required + /> + + {template && ( + + {m.app_resolutions_fields()} + {template.fields.map((f) => { + const v = fieldValues[f.key] + if (f.type === 'textarea') { + return ( +