chore: germantax customer project

This commit is contained in:
e2e
2026-07-11 08:20:01 +02:00
commit f22ff25023
145 changed files with 9830 additions and 0 deletions

0
.e2e-runtime/golden.db Normal file
View File

35
.gitignore vendored Normal file
View File

@@ -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/

8
.yarnrc.yml Normal file
View File

@@ -0,0 +1,8 @@
approvedGitRepositories:
- "**"
enableScripts: true
nodeLinker: node-modules
npmMinimalAgeGate: 0

40
README.md Normal file
View File

@@ -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/`

10
apps/app/.gitignore vendored Normal file
View File

@@ -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.

View File

@@ -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"
]
}

49
apps/app/README.md Normal file
View File

@@ -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 <name>` (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/<name>/` 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` | `<title>`, 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.

17
apps/app/components.json Normal file
View File

@@ -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"
}
}

49
apps/app/eslint.config.js Normal file
View File

@@ -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,
},
},
]

16
apps/app/index.html Normal file
View File

@@ -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</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Source+Serif+4:ital,opsz,wght@0,8..60,300..600;1,8..60,300..500&family=Public+Sans:ital,wght@0,300..700;1,400&family=IBM+Plex+Mono:wght@400;500;600&display=swap" rel="stylesheet" />
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

184
apps/app/messages/de.json Normal file
View File

@@ -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"
}

197
apps/app/messages/en.json Normal file
View File

@@ -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"
}

63
apps/app/package.json Normal file
View File

@@ -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"
}
}

View File

@@ -0,0 +1,5 @@
export default {
plugins: {
autoprefixer: {},
},
}

View File

@@ -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" }
}

View File

@@ -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]

39
apps/app/src/auth.tsx Normal file
View File

@@ -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<typeof usePikkuQuery<'getMe'>>['data']
const AuthContext = createContext<Me | null>(null)
export function useAuth(): NonNullable<Me> {
const ctx = useContext(AuthContext)
if (!ctx) {
throw new Error('useAuth must be used inside <RequireAuth>')
}
return ctx
}
export function RequireAuth({ children }: { children: React.ReactNode }) {
const { data, isLoading, error } = usePikkuQuery('getMe', undefined as never, {
retry: false,
})
if (isLoading) {
return (
<Center mih="100vh">
<Loader size="sm" />
</Center>
)
}
if (error || !data) {
return <Navigate to="/login" />
}
return (
<AuthContext.Provider value={data}>
<AppLayout user={data}>{children}</AppLayout>
</AuthContext.Provider>
)
}

View File

@@ -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 (
<span style={{
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
width: size, height: size, borderRadius: radius,
background: bg, color, flexShrink: 0,
fontFamily: "'Source Serif 4', Georgia, serif",
fontWeight: 600, fontSize, lineHeight: 1,
}}>
Q
</span>
)
}
function BrandPanel({ tagline }: { tagline: I18nNode }) {
useLocale()
return (
<Box style={{
position: 'relative', overflow: 'hidden', flex: 1,
background: '#0E1F18',
display: 'flex', alignItems: 'center', justifyContent: 'center',
padding: 56, minHeight: '100vh',
}}>
<Box style={{
position: 'absolute', inset: 0,
background: 'radial-gradient(700px 460px at 70% 25%, rgba(46,111,84,.32), transparent 70%)',
pointerEvents: 'none',
}} />
<Box style={{ position: 'relative', maxWidth: 420 }}>
<Box style={{
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
width: 56, height: 56, borderRadius: 14,
border: '1px solid rgba(235,197,122,.4)', marginBottom: 28,
fontFamily: "'Source Serif 4', Georgia, serif",
fontSize: 26, color: '#EBC57A', fontWeight: 600,
}}>
§
</Box>
<Title order={2} style={{
fontFamily: "'Source Serif 4', Georgia, serif",
fontWeight: 380, fontSize: 'clamp(1.6rem, 2.4vw, 2.125rem)',
lineHeight: 1.22, color: '#F4F1E9', letterSpacing: '-.02em', margin: 0,
}}>
{m.auth_brand_headline_line1()}
<br />
<span style={{ fontStyle: 'italic', color: '#8FCBA9' }}>
{m.auth_brand_headline_line2()}
</span>
</Title>
<Text size="sm" style={{ marginTop: 22, lineHeight: 1.7, color: 'rgba(244,241,233,.6)' }}>
{tagline}
</Text>
{/* Mini resolution card */}
<Box style={{
marginTop: 36, background: '#FBFAF7', borderRadius: 4,
transform: 'rotate(-.8deg)',
boxShadow: '0 24px 60px -20px rgba(0,0,0,.5)',
overflow: 'hidden',
}}>
<Box style={{
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
padding: '14px 18px', borderBottom: '1px solid #EFEDE6',
}}>
<Text style={{ fontFamily: "'IBM Plex Mono', monospace", fontSize: 11, color: '#8A938C' }}>
{asI18n('BESCHLUSS · 2026-014')}
</Text>
<Box style={{
fontFamily: "'IBM Plex Mono', monospace", fontWeight: 600,
fontSize: 9, letterSpacing: '.18em', textTransform: 'uppercase',
color: '#1F5641', background: '#E7F0EA', padding: '3px 8px', borderRadius: 20,
}}>
Published
</Box>
</Box>
<Box style={{ padding: '16px 18px' }}>
<Text style={{ fontFamily: "'Source Serif 4', Georgia, serif", fontSize: 15, fontWeight: 600, color: '#16201B', marginBottom: 10 }}>
{asI18n('Appropriation of 2025 result')}
</Text>
<Box style={{ display: 'flex', justifyContent: 'space-between', padding: '7px 0', borderTop: '1px solid #EFEDE6' }}>
<Text size="xs" style={{ color: '#6B736C' }}>{asI18n('Distributable profit')}</Text>
<Text style={{ fontFamily: "'IBM Plex Mono', monospace", fontSize: 12, fontWeight: 600, color: '#16201B' }}>{asI18n('€ 248,500.00')}</Text>
</Box>
</Box>
</Box>
</Box>
</Box>
)
}
export function AuthLayout({
title,
subtitle,
tagline,
children,
footer,
}: {
title: I18nNode
subtitle?: I18nNode
tagline: I18nNode
children: ReactNode
footer?: I18nNode
}) {
return (
<Box style={{ display: 'flex', minHeight: '100vh' }}>
{/* Form side */}
<Box style={{
width: 560, flexShrink: 0,
background: '#fff', borderRight: '1px solid #E6E3DB',
display: 'flex', alignItems: 'center', justifyContent: 'center',
padding: 48,
}}>
<Paper p={0} w="100%" maw={360} bg="transparent">
<Stack gap="lg">
<Box style={{ display: 'flex', alignItems: 'center', gap: 11 }}>
<QSeal />
<Text style={{ fontFamily: "'Source Serif 4', Georgia, serif", fontSize: 19, fontWeight: 600, color: '#16201B' }}>
{m.common_brand_name()}
</Text>
</Box>
<div>
<Title order={2} style={{
fontFamily: "'Source Serif 4', Georgia, serif",
fontSize: 30, fontWeight: 600, color: '#16201B', letterSpacing: '-.02em', marginBottom: 6,
}}>
{title}
</Title>
{subtitle && (
<Text size="sm" style={{ color: '#6B736C' }}>
{subtitle}
</Text>
)}
</div>
{children}
{footer && (
<Text size="sm" style={{ color: '#6B736C' }} ta="center">
{footer}
</Text>
)}
</Stack>
</Paper>
</Box>
{/* Brand side — hidden on mobile */}
<Box display={{ base: 'none', md: 'flex' }} style={{ flex: 1 }}>
<BrandPanel tagline={tagline} />
</Box>
</Box>
)
}

View File

@@ -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> | void
}

View File

@@ -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'
},
}

View File

@@ -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<SessionContextValue | null>(null)
const PUBLIC_ROUTES = new Set<string>(['/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 (
<Center mih="100vh">
<Loader size="sm" />
</Center>
)
}
return (
<SessionContext.Provider
value={{ session: me.data, refetch: () => me.refetch() }}
>
{children}
</SessionContext.Provider>
)
}
export function useSession(): Session {
const ctx = useContext(SessionContext)
if (!ctx) {
throw new Error('useSession must be used inside <SessionProvider>')
}
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 ?? (() => {})
}

View File

@@ -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 (
<span style={{
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
width: 26, height: 26, borderRadius: 6, background: '#1F5641',
color: '#FBFAF7', fontFamily: "'Source Serif 4', Georgia, serif",
fontWeight: 600, fontSize: 15, lineHeight: 1, flexShrink: 0,
}}>
Q
</span>
)
}
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<string | undefined>(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 (
<PageTitleContext.Provider value={setPageTitle}>
<AppShell
header={{ height: 64 }}
navbar={{ width: 248, breakpoint: 'sm', collapsed: { mobile: !opened } }}
padding={{ base: 'md', md: 'xl' }}
styles={{
header: { borderBottom: '1px solid #E6E3DB', background: '#fff' },
navbar: { borderRight: '1px solid #E6E3DB', background: '#fff' },
}}
>
<AppShell.Header>
<Group h="100%" px={0} justify="space-between" wrap="nowrap">
{/* Logo area matches sidebar width */}
<Group
gap={11}
style={{
width: 248, flexShrink: 0, padding: '0 22px',
borderRight: '1px solid #EFEDE6', height: '100%',
}}
>
<Burger
opened={opened}
onClick={toggle}
hiddenFrom="sm"
size="sm"
aria-label={m.common_nav_toggle()}
/>
<Link to="/" style={{ textDecoration: 'none', display: 'flex', alignItems: 'center', gap: 11 }} onClick={close}>
<QSeal />
<Text style={{ fontFamily: "'Source Serif 4', Georgia, serif", fontSize: 17, fontWeight: 600, color: '#16201B' }}>
{m.common_brand_name()}
</Text>
</Link>
</Group>
{/* Right: breadcrumb + user */}
<Group justify="space-between" style={{ flex: 1, padding: '0 26px' }}>
<Group gap={12}>
{pageTitle && (
<>
<Text style={{ color: '#A9B0A6', fontFamily: "'IBM Plex Mono', monospace", fontSize: 12 }}>
{m.common_brand_name()}
</Text>
<span style={{ color: '#D8D3C8' }}>/</span>
<Text fw={600} size="sm" visibleFrom="sm" style={{ color: '#16201B' }}>
{asI18n(pageTitle ?? '')}
</Text>
</>
)}
</Group>
<Menu position="bottom-end" withArrow shadow="sm">
<Menu.Target>
<UnstyledButton style={{ display: 'flex', alignItems: 'center', gap: 10, padding: '4px 8px', borderRadius: 8 }}>
<Stack gap={0} align="flex-end" visibleFrom="sm">
<Text size="sm" fw={600} style={{ lineHeight: 1.2, color: '#16201B' }}>{asI18n(user.displayName)}</Text>
<Text size="xs" style={{ lineHeight: 1.2, color: '#A9B0A6', fontFamily: "'IBM Plex Mono', monospace" }}>{asI18n(user.email)}</Text>
</Stack>
<Avatar radius="xl" color="quorum" variant="light" size={34}>
{asI18n(initials(user.displayName))}
</Avatar>
</UnstyledButton>
</Menu.Target>
<Menu.Dropdown>
<Menu.Label>{asI18n(user.email)}</Menu.Label>
<Menu.Divider />
<Menu.Item color="red" onClick={handleLogout}>
{m.common_nav_logout()}
</Menu.Item>
</Menu.Dropdown>
</Menu>
</Group>
</Group>
</AppShell.Header>
<AppShell.Navbar p="md">
<AppShell.Section grow>
<p style={{
color: '#A9B0A6', fontFamily: "'IBM Plex Mono', monospace",
letterSpacing: '.06em', textTransform: 'uppercase', fontSize: 9,
padding: '0 10px', marginBottom: 10, margin: '0 0 10px 0',
}}>
Workspace
</p>
<Stack gap={4}>
{NAV_ITEMS.map(({ to, labelKey, Icon }) => (
<NavLink
key={to}
component={Link}
to={to}
label={mKey(labelKey)}
leftSection={<Icon size={16} />}
active={location.pathname.startsWith(to)}
onClick={close}
/>
))}
</Stack>
</AppShell.Section>
<AppShell.Section pt="md" style={{ borderTop: '1px solid #EFEDE6' }}>
<Text style={{ color: '#B7BDB6', fontFamily: "'IBM Plex Mono', monospace", lineHeight: 1.6, fontSize: 10 }}>
{m.common_brand_tagline()}
</Text>
</AppShell.Section>
</AppShell.Navbar>
<AppShell.Main style={{ background: '#FBFAF7' }}>{children}</AppShell.Main>
</AppShell>
</PageTitleContext.Provider>
)
}

40
apps/app/src/direction.ts Normal file
View File

@@ -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
}
}

101
apps/app/src/i18n/config.ts Normal file
View File

@@ -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/<lang>.json` next to `en.json`, add the code to
// `project.inlang/settings.json` `locales`, and recompile. Content is reachable
// via the `/<lang>` 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 <html dir> 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 → <html lang> → 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 + <html dir>) 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 <html lang> (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<Locale>(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
}

View File

@@ -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()

View File

@@ -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> = T extends (...args: infer A) => unknown ? (...args: A) => I18nString : T
type Branded<T> = { [K in keyof T]: BrandReturn<T[K]> }
const _raw = _m as unknown as Record<string, (args?: Record<string, unknown>) => string>
const _wrapped: Record<string, unknown> = {}
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<typeof _m>
/**
* 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<string, unknown>): 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<string, unknown>): 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
}

41
apps/app/src/lib/auth.ts Normal file
View File

@@ -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<void> {
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<void> {
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<void> {
await authClient.signOut()
}

21
apps/app/src/lib/env.ts Normal file
View File

@@ -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`
}

View File

@@ -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 (
<html lang="de" data-app-hydrated="false">
<head>
<HeadContent />
</head>
<body>
<QueryClientProvider client={queryClient}>
<MantineProvider theme={theme}>
<PikkuProvider pikku={pikku}>
<HydrationReady />
<Outlet />
</PikkuProvider>
</MantineProvider>
</QueryClientProvider>
<Scripts />
</body>
</html>
)
}
function HydrationReady() {
useEffect(() => {
window.document.documentElement.dataset.appHydrated = 'true'
}, [])
return null
}

View File

@@ -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<AddMemberFormState>(EMPTY_MEMBER_FORM)
const [memberError, setMemberError] = useState<string | null>(null)
const [templateKey, setTemplateKey] = useState<string | null>(null)
const [resolutionTitle, setResolutionTitle] = useState('')
const [fieldValues, setFieldValues] = useState<Record<string, unknown>>({})
const [resolutionError, setResolutionError] = useState<string | null>(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 (
<Container size="lg" py="xl">
<Stack gap="xl">
{company.error && <Alert color="red">{asI18n(String(company.error.message))}</Alert>}
{company.data && (
<Group justify="space-between" align="flex-end" wrap="wrap">
<Stack gap={2}>
<Title order={2}>{asI18n(company.data.name)}</Title>
{company.data.registryNo && (
<Text c="dimmed" size="sm">{asI18n(company.data.registryNo)}</Text>
)}
{(company.data.addressLine1 || company.data.town) && (
<Text c="dimmed" size="sm">
{asI18n(
[
company.data.addressLine1,
company.data.addressLine2,
[company.data.postcode, company.data.town].filter(Boolean).join(' '),
company.data.country,
]
.filter(Boolean)
.join(', '),
)}
</Text>
)}
{totalShares > 0 && (
<Text c="dimmed" size="sm">
{m.app_companies_shares_assigned({
assigned: assignedShares,
total: totalShares,
})}
</Text>
)}
</Stack>
<Badge size="lg">{mKey(`app.companies.rolesEnum.${company.data.myRole}`)}</Badge>
</Group>
)}
<Card withBorder radius="md" p="md">
<Stack gap="sm">
<Group justify="space-between">
<Title order={4}>{m.app_companies_members()}</Title>
{isMd && (
<Button
size="sm"
leftSection={<Plus size={16} aria-hidden="true" />}
onClick={handleOpenMemberModal}
>
{m.app_companies_add_member()}
</Button>
)}
</Group>
<Table withTableBorder withColumnBorders>
<Table.Thead>
<Table.Tr>
<Table.Th>{m.app_companies_member_display_name()}</Table.Th>
<Table.Th>{m.app_companies_member_email()}</Table.Th>
<Table.Th>{m.app_companies_role()}</Table.Th>
<Table.Th>{m.app_companies_member_shares()}</Table.Th>
<Table.Th />
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{members.data?.map((member) => (
<Table.Tr key={member.memberId}>
<Table.Td>{member.displayName}</Table.Td>
<Table.Td>{member.email}</Table.Td>
<Table.Td>{mKey(`app.companies.rolesEnum.${member.role}`)}</Table.Td>
<Table.Td>{member.shares ?? '—'}</Table.Td>
<Table.Td>
{isMd && (
<Button
variant="subtle"
color="red"
size="xs"
onClick={() =>
removeMember.mutate({ companyId, memberId: member.memberId })
}
>
{m.app_companies_remove()}
</Button>
)}
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
</Stack>
</Card>
<Card withBorder radius="md" p="md">
<Stack gap="sm">
<Group justify="space-between">
<Title order={4}>{m.app_resolutions_title()}</Title>
<Button
leftSection={<Plus size={16} aria-hidden="true" />}
onClick={handleOpenResolutionModal}
>
{m.app_resolutions_new()}
</Button>
</Group>
{resolutions.data && resolutions.data.length === 0 && (
<Text c="dimmed">{m.app_resolutions_empty()}</Text>
)}
<Stack gap="xs">
{resolutions.data?.map((r) => (
<Card key={r.resolutionId} withBorder radius="sm" p="sm">
<Group justify="space-between" wrap="nowrap">
<Stack gap={2}>
<Group gap="xs">
<Text fw={600}>{asI18n(r.title)}</Text>
<Badge color={r.status === 'published' ? 'green' : r.status === 'archived' ? 'gray' : 'blue'}>
{mKey(`app.resolutions.${r.status}`)}
</Badge>
</Group>
<Text size="xs" c="dimmed">{asI18n(r.templateKey)}</Text>
</Stack>
<Group gap="xs">
<Button
component={Link}
to="/companies/$companyId/resolutions/$resolutionId"
params={{ companyId, resolutionId: r.resolutionId } as any}
variant="light"
size="xs"
>
{m.app_companies_open()}
</Button>
{isMd && r.status === 'draft' && (
<Button
size="xs"
color="green"
onClick={() => publishResolution.mutate({ resolutionId: r.resolutionId })}
loading={publishResolution.isPending}
>
{m.app_resolutions_publish()}
</Button>
)}
{isMd && r.status !== 'archived' && (
<Button
size="xs"
variant="subtle"
color="gray"
onClick={() => archiveResolution.mutate({ resolutionId: r.resolutionId })}
>
{m.app_resolutions_archive()}
</Button>
)}
</Group>
</Group>
</Card>
))}
</Stack>
</Stack>
</Card>
</Stack>
<Modal
opened={memberModalOpened}
onClose={closeMemberModal}
title={m.app_companies_add_member_title()}
size="md"
centered
overlayProps={{ backgroundOpacity: 0.55, blur: 3 }}
>
<Box component="form" onSubmit={handleSubmitMember} noValidate>
<Stack gap="md">
{memberError && (
<Alert
role="alert"
color="red"
variant="light"
icon={<AlertCircle size={18} aria-hidden="true" />}
title={m.app_companies_errors_add_member_title()}
>
{asI18n(memberError)}
</Alert>
)}
<Stack gap="sm">
<TextInput
label={m.app_companies_member_email()}
type="email"
value={memberForm.email}
onChange={(e) =>
setMemberForm({ ...memberForm, email: e.currentTarget.value })
}
required
/>
<TextInput
label={m.app_companies_member_display_name()}
value={memberForm.displayName}
onChange={(e) =>
setMemberForm({ ...memberForm, displayName: e.currentTarget.value })
}
required
/>
<Select
label={m.app_companies_member_role()}
data={ROLES.map((r) => ({
value: r,
label: mKey(`app.companies.rolesEnum.${r}`),
}))}
value={memberForm.role}
onChange={(v) => v && setMemberForm({ ...memberForm, role: v as Role })}
allowDeselect={false}
/>
<NumberInput
label={m.app_companies_member_shares()}
description={
totalShares > 0
? remainingShares > 0
? m.app_companies_shares_remaining({ count: remainingShares })
: m.app_companies_shares_fully_assigned()
: undefined
}
value={memberForm.shares}
onChange={(v) =>
setMemberForm({
...memberForm,
shares: typeof v === 'number' ? v : '',
})
}
min={0}
max={totalShares > 0 ? remainingShares : undefined}
step={1}
allowDecimal={false}
disabled={totalShares > 0 && remainingShares === 0}
/>
</Stack>
<Group justify="flex-end" gap="sm">
<Button variant="subtle" onClick={closeMemberModal} type="button">
{m.app_companies_cancel()}
</Button>
<Button type="submit" loading={addMember.isPending}>
{m.app_companies_add_member()}
</Button>
</Group>
</Stack>
</Box>
</Modal>
<Modal
opened={resolutionModalOpened}
onClose={closeResolutionModal}
title={m.app_resolutions_new_title()}
size="lg"
centered
overlayProps={{ backgroundOpacity: 0.55, blur: 3 }}
>
<Box component="form" onSubmit={handleSubmitResolution} noValidate>
<Stack gap="md">
{resolutionError && (
<Alert
role="alert"
color="red"
variant="light"
icon={<AlertCircle size={18} aria-hidden="true" />}
title={m.app_resolutions_errors_create_title()}
>
{asI18n(resolutionError)}
</Alert>
)}
<Select
label={m.app_resolutions_select_template()}
data={
templates.data?.map((tpl) => ({
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
/>
<TextInput
label={m.app_resolutions_resolution_title()}
value={resolutionTitle}
onChange={(e) => setResolutionTitle(e.currentTarget.value)}
required
/>
{template && (
<Stack gap="xs">
<Text fw={600}>{m.app_resolutions_fields()}</Text>
{template.fields.map((f) => {
const v = fieldValues[f.key]
if (f.type === 'textarea') {
return (
<Textarea
key={f.key}
label={asI18n(labelOf(f))}
required={f.required}
value={(v as string) ?? ''}
onChange={(e) =>
setFieldValues({ ...fieldValues, [f.key]: e.currentTarget.value })
}
autosize
minRows={2}
/>
)
}
if (f.type === 'number' || f.type === 'currency') {
return (
<NumberInput
key={f.key}
label={asI18n(labelOf(f))}
required={f.required}
value={typeof v === 'number' ? v : ''}
onChange={(val) =>
setFieldValues({
...fieldValues,
[f.key]: typeof val === 'number' ? val : 0,
})
}
min={0}
decimalScale={f.type === 'currency' ? 2 : 0}
/>
)
}
return (
<TextInput
key={f.key}
type={f.type === 'date' ? 'date' : 'text'}
label={asI18n(labelOf(f))}
required={f.required}
value={(v as string) ?? ''}
onChange={(e) =>
setFieldValues({ ...fieldValues, [f.key]: e.currentTarget.value })
}
/>
)
})}
</Stack>
)}
<Group justify="flex-end" gap="sm">
<Button variant="subtle" onClick={closeResolutionModal} type="button">
{m.app_companies_cancel()}
</Button>
<Button
type="submit"
disabled={!templateKey || !resolutionTitle}
loading={createResolution.isPending}
>
{m.app_resolutions_create()}
</Button>
</Group>
</Stack>
</Box>
</Modal>
</Container>
)
}
export const Route = createFileRoute('/companies/$companyId/')({
component: () => <RequireAuth><CompanyPage /></RequireAuth>,
})

View File

@@ -0,0 +1,338 @@
import { createFileRoute, Link } from '@tanstack/react-router'
import { RequireAuth } from '../../../../auth'
import {
ActionIcon,
Alert,
Anchor,
Badge,
Box,
Breadcrumbs,
Button,
Card,
Container,
Divider,
Group,
Loader,
Paper,
SimpleGrid,
Stack,
Text,
Title,
Tooltip,
} from '@pikku/mantine/core'
import { m, mKey } from '@/i18n/messages'
import { useLocale, getLocale } from '@/i18n/config'
import { asI18n } from '@pikku/react'
import { useMemo, useState } from 'react'
import { usePikkuQuery, usePikkuMutation } from '@germantax/functions-sdk/pikku/api.gen'
type FieldType = 'text' | 'textarea' | 'number' | 'date' | 'currency'
const formatValue = (
value: unknown,
type: FieldType,
locale: string,
): string => {
if (value === undefined || value === null || value === '') return '—'
if (type === 'currency') {
const n = typeof value === 'number' ? value : Number(value)
if (Number.isFinite(n)) {
return new Intl.NumberFormat(locale, {
style: 'currency',
currency: 'EUR',
maximumFractionDigits: 2,
}).format(n)
}
}
if (type === 'date') {
const d = new Date(String(value))
if (!Number.isNaN(d.getTime())) {
return new Intl.DateTimeFormat(locale, { dateStyle: 'medium' }).format(d)
}
}
if (type === 'number') {
const n = typeof value === 'number' ? value : Number(value)
if (Number.isFinite(n)) return new Intl.NumberFormat(locale).format(n)
}
return String(value)
}
const formatTimestamp = (iso: string | null | undefined, locale: string) => {
if (!iso) return null
const d = new Date(iso)
if (Number.isNaN(d.getTime())) return null
return new Intl.DateTimeFormat(locale, {
dateStyle: 'medium',
timeStyle: 'short',
}).format(d)
}
const statusColor = (status: string) =>
status === 'published' ? 'green' : status === 'archived' ? 'gray' : 'blue'
function ResolutionPage() {
const { resolutionId, companyId } = Route.useParams()
useLocale()
const locale = getLocale() || 'en'
const r = usePikkuQuery('getResolution', { resolutionId })
const company = usePikkuQuery('getCompany', { companyId })
const templates = usePikkuQuery(
'listResolutionTemplates',
undefined as never,
)
const pdfUrl = usePikkuMutation('getResolutionPdfUrl')
const publish = usePikkuMutation('publishResolution', {
onSuccess: () => r.refetch(),
})
const archive = usePikkuMutation('archiveResolution', {
onSuccess: () => r.refetch(),
})
const [embeddedPdf, setEmbeddedPdf] = useState<string | null>(null)
const isMd = company.data?.myRole === 'managing_director'
const template = useMemo(
() => templates.data?.find((tpl) => tpl.key === r.data?.templateKey),
[templates.data, r.data?.templateKey],
)
const labelLang = locale.startsWith('de') ? 'labelDe' : 'labelEn'
const titleLang = locale.startsWith('de') ? 'titleDe' : 'titleEn'
const fieldRows = useMemo(() => {
if (!r.data) return []
const values = r.data.fieldValues as Record<string, unknown>
if (template?.fields?.length) {
return template.fields.map((f) => ({
key: f.key,
label: (f as any)[labelLang] ?? f.key,
value: formatValue(values?.[f.key], f.type as FieldType, locale),
}))
}
return Object.entries(values ?? {}).map(([k, v]) => ({
key: k,
label: k,
value: formatValue(v, 'text', locale),
}))
}, [r.data, template, labelLang, locale])
const openPdf = async (embed: boolean) => {
const res = await pdfUrl.mutateAsync({ resolutionId })
if (embed) {
setEmbeddedPdf(res.url)
} else {
window.open(res.url, '_blank', 'noopener,noreferrer')
}
}
const createdAt = formatTimestamp(r.data?.createdAt, locale)
const publishedAt = formatTimestamp(r.data?.publishedAt, locale)
return (
<Container size="lg" py="xl">
<Stack gap="lg">
<Breadcrumbs>
<Anchor component={Link} to="/" size="sm">
{m.app_nav_home({ defaultValue: 'Home' })}
</Anchor>
<Anchor
component={Link}
to="/companies/$companyId"
params={{ companyId } as any}
size="sm"
>
{asI18n(company.data?.name ?? '…')}
</Anchor>
<Text size="sm" c="dimmed">
{m.app_resolutions_resolution({ defaultValue: 'Resolution' })}
</Text>
</Breadcrumbs>
{r.error && <Alert color="red">{asI18n(String(r.error.message))}</Alert>}
{!r.data && !r.error && (
<Group justify="center" py="xl">
<Loader size="sm" />
</Group>
)}
{r.data && (
<>
<Paper withBorder radius="md" p="lg">
<Stack gap="md">
<Group justify="space-between" align="flex-start" wrap="nowrap">
<Stack gap={4} style={{ minWidth: 0 }}>
<Text size="xs" c="dimmed" tt="uppercase" fw={600}>
{template
? (template as any)[titleLang]
: r.data.templateKey}
</Text>
<Title order={2} style={{ wordBreak: 'break-word' }}>
{asI18n(r.data.title)}
</Title>
</Stack>
<Badge size="lg" color={statusColor(r.data.status)} variant="light">
{mKey(`app.resolutions.${r.data.status}`)}
</Badge>
</Group>
<Divider />
<SimpleGrid cols={{ base: 1, sm: 3 }} spacing="md">
<Stack gap={2}>
<Text size="xs" c="dimmed" tt="uppercase">
{m.app_resolutions_created({ defaultValue: 'Created' })}
</Text>
<Text size="sm">{asI18n(createdAt ?? '—')}</Text>
</Stack>
<Stack gap={2}>
<Text size="xs" c="dimmed" tt="uppercase">
{m.app_resolutions_published({ defaultValue: 'Published' })}
</Text>
<Text size="sm">{asI18n(publishedAt ?? '—')}</Text>
</Stack>
<Stack gap={2}>
<Text size="xs" c="dimmed" tt="uppercase">
{m.app_resolutions_id({ defaultValue: 'ID' })}
</Text>
<Tooltip label={asI18n(resolutionId)}>
<Text size="sm" ff="monospace">
{asI18n(`${resolutionId.slice(0, 8)}`)}
</Text>
</Tooltip>
</Stack>
</SimpleGrid>
<Group gap="sm">
{isMd && r.data.status === 'draft' && (
<Button
color="green"
loading={publish.isPending}
onClick={() => publish.mutate({ resolutionId })}
>
{m.app_resolutions_publish()}
</Button>
)}
{r.data.status === 'published' && (
<>
<Button
variant="filled"
loading={pdfUrl.isPending && embeddedPdf === null}
onClick={() => openPdf(true)}
>
{m.app_resolutions_view_pdf({
defaultValue: 'View PDF',
})}
</Button>
<Button
variant="default"
loading={pdfUrl.isPending && embeddedPdf !== null}
onClick={() => openPdf(false)}
>
{m.app_resolutions_open_pdf({
defaultValue: 'Open in new tab',
})}
</Button>
</>
)}
{isMd && r.data.status !== 'archived' && (
<Button
variant="subtle"
color="gray"
onClick={() => archive.mutate({ resolutionId })}
>
{m.app_resolutions_archive()}
</Button>
)}
</Group>
</Stack>
</Paper>
<Card withBorder radius="md" p="lg">
<Stack gap="md">
<Group justify="space-between">
<Title order={4}>
{m.app_resolutions_fields({ defaultValue: 'Details' })}
</Title>
{template && (
<Text size="xs" c="dimmed">
{(template as any).descriptionDe}
</Text>
)}
</Group>
{fieldRows.length === 0 ? (
<Text size="sm" c="dimmed">
{m.app_resolutions_no_fields({
defaultValue: 'No fields recorded.',
})}
</Text>
) : (
<Stack gap={0}>
{fieldRows.map((row, i) => (
<Box key={row.key}>
{i > 0 && <Divider />}
<SimpleGrid
cols={{ base: 1, sm: 3 }}
spacing="md"
py="sm"
>
<Text size="sm" c="dimmed" fw={500}>
{row.label}
</Text>
<Box style={{ gridColumn: 'span 2' }}>
<Text size="sm" style={{ whiteSpace: 'pre-wrap' }}>
{asI18n(row.value)}
</Text>
</Box>
</SimpleGrid>
</Box>
))}
</Stack>
)}
</Stack>
</Card>
{embeddedPdf && (
<Card withBorder radius="md" p={0} style={{ overflow: 'hidden' }}>
<Group
justify="space-between"
px="md"
py="xs"
style={{ borderBottom: '1px solid var(--mantine-color-gray-3)' }}
>
<Text size="sm" fw={500}>
{m.app_resolutions_pdf_preview({
defaultValue: 'PDF preview',
})}
</Text>
<ActionIcon
variant="subtle"
onClick={() => setEmbeddedPdf(null)}
aria-label={m.app_common_close({ defaultValue: 'Close' })}
>
×
</ActionIcon>
</Group>
<Box style={{ height: '80vh' }}>
<iframe
title="Resolution PDF"
src={embeddedPdf}
style={{ width: '100%', height: '100%', border: 0 }}
/>
</Box>
</Card>
)}
</>
)}
</Stack>
</Container>
)
}
export const Route = createFileRoute(
'/companies/$companyId/resolutions/$resolutionId',
)({
component: () => <RequireAuth><ResolutionPage /></RequireAuth>,
})

View File

@@ -0,0 +1,141 @@
import { createFileRoute, useNavigate } from '@tanstack/react-router'
import { RequireAuth } from '../../../../auth'
import {
Alert,
Button,
Card,
Container,
NumberInput,
Select,
Stack,
Text,
Textarea,
TextInput,
Title,
} from '@pikku/mantine/core'
import { useState, useMemo } from 'react'
import { m } from '@/i18n/messages'
import { useLocale, getLocale } from '@/i18n/config'
import { asI18n } from '@pikku/react'
import { usePikkuQuery, usePikkuMutation } from '@germantax/functions-sdk/pikku/api.gen'
function NewResolutionPage() {
const { companyId } = Route.useParams()
useLocale()
const navigate = useNavigate()
const templates = usePikkuQuery('listResolutionTemplates', undefined as never)
const create = usePikkuMutation('createResolution', {
onSuccess: ({ resolutionId }) => {
navigate({
to: '/companies/$companyId/resolutions/$resolutionId',
params: { companyId, resolutionId },
})
},
})
const [templateKey, setTemplateKey] = useState<string | null>(null)
const [title, setTitle] = useState('')
const [values, setValues] = useState<Record<string, unknown>>({})
const template = useMemo(
() => templates.data?.find((t) => t.key === templateKey),
[templates.data, templateKey],
)
const lang = getLocale()?.startsWith('de') ? 'de' : 'en'
const labelOf = (f: { labelDe: string; labelEn: string }) => (lang === 'de' ? f.labelDe : f.labelEn)
return (
<Container size="md" py="xl">
<Stack gap="xl">
<Title order={2}>{m.app_resolutions_new()}</Title>
<Card withBorder radius="md" p="md">
<Stack gap="sm">
<Select
label={m.app_resolutions_select_template()}
data={
templates.data?.map((tpl) => ({ value: tpl.key, label: tpl.titleDe })) ?? []
}
value={templateKey}
onChange={(v) => {
setTemplateKey(v)
setValues({})
const tpl = templates.data?.find((x) => x.key === v)
if (tpl) setTitle(tpl.titleDe)
}}
/>
<TextInput
label={m.app_resolutions_resolution_title()}
value={title}
onChange={(e) => setTitle(e.currentTarget.value)}
/>
{template && (
<Stack gap="xs">
<Text fw={600}>{m.app_resolutions_fields()}</Text>
{template.fields.map((f) => {
const v = values[f.key]
if (f.type === 'textarea') {
return (
<Textarea
key={f.key}
label={asI18n(labelOf(f))}
required={f.required}
value={(v as string) ?? ''}
onChange={(e) => setValues({ ...values, [f.key]: e.currentTarget.value })}
/>
)
}
if (f.type === 'number' || f.type === 'currency') {
return (
<NumberInput
key={f.key}
label={asI18n(labelOf(f))}
required={f.required}
value={typeof v === 'number' ? v : ''}
onChange={(val) => setValues({ ...values, [f.key]: typeof val === 'number' ? val : 0 })}
min={0}
decimalScale={f.type === 'currency' ? 2 : 0}
/>
)
}
return (
<TextInput
key={f.key}
type={f.type === 'date' ? 'date' : 'text'}
label={asI18n(labelOf(f))}
required={f.required}
value={(v as string) ?? ''}
onChange={(e) => setValues({ ...values, [f.key]: e.currentTarget.value })}
/>
)
})}
</Stack>
)}
{create.error && <Alert color="red">{asI18n(String(create.error.message))}</Alert>}
<Button
disabled={!templateKey || !title}
loading={create.isPending}
onClick={() =>
templateKey &&
create.mutate({
companyId,
templateKey,
title,
fieldValues: values,
})
}
>
{m.app_resolutions_create()}
</Button>
</Stack>
</Card>
</Stack>
</Container>
)
}
export const Route = createFileRoute('/companies/$companyId/resolutions/new')({
component: () => <RequireAuth><NewResolutionPage /></RequireAuth>,
})

View File

@@ -0,0 +1,515 @@
import { createFileRoute, useNavigate } from '@tanstack/react-router'
import { RequireAuth } from '../../auth'
import {
Alert,
Badge,
Box,
Button,
Card,
Container,
Group,
Modal,
NumberInput,
Select,
SimpleGrid,
Skeleton,
Stack,
Text,
TextInput,
ThemeIcon,
Title,
} from '@pikku/mantine/core'
import { useDisclosure } from '@mantine/hooks'
import { useEffect, useState } from 'react'
import { m, mKey } from '@/i18n/messages'
import { useLocale } from '@/i18n/config'
import { asI18n } from '@pikku/react'
import { Building2, Plus, AlertCircle, CheckCircle2 } from 'lucide-react'
import { usePikkuQuery, usePikkuMutation } from '@germantax/functions-sdk/pikku/api.gen'
const MONTHS = [
'01', '02', '03', '04', '05', '06', '07', '08', '09', '10', '11', '12',
] as const
function daysInMonth(month: string): number {
const m = Number(month)
if (!m) return 31
if ([4, 6, 9, 11].includes(m)) return 30
if (m === 2) return 29
return 31
}
type FormState = {
name: string
registryNo: string
addressLine1: string
addressLine2: string
postcode: string
town: string
country: string
totalShares: number | ''
fiscalMonth: string
fiscalDay: string
}
const EMPTY_FORM: FormState = {
name: '',
registryNo: '',
addressLine1: '',
addressLine2: '',
postcode: '',
town: '',
country: '',
totalShares: '',
fiscalMonth: '',
fiscalDay: '',
}
type FormErrorKey = keyof FormState | 'fiscalYearEnd'
function CompanyCard({
company,
onOpen,
}: {
company: {
companyId: string
name: string
registryNo?: string | null
town?: string | null
country?: string | null
totalShares: number
assignedShares: number
fiscalYearEnd?: string | null
role: string
}
onOpen: (id: string) => void
}) {
useLocale()
const location = [company.town, company.country].filter(Boolean).join(', ')
return (
<Card
withBorder
radius="md"
p="md"
role="button"
tabIndex={0}
onClick={() => onOpen(company.companyId)}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
onOpen(company.companyId)
}
}}
style={{
cursor: 'pointer',
transition: 'transform 150ms ease, box-shadow 150ms ease',
height: '100%',
}}
onMouseEnter={(e) => {
e.currentTarget.style.transform = 'translateY(-2px)'
e.currentTarget.style.boxShadow = '0 4px 12px rgba(0,0,0,0.08)'
}}
onMouseLeave={(e) => {
e.currentTarget.style.transform = 'translateY(0)'
e.currentTarget.style.boxShadow = ''
}}
aria-label={`${company.name}${mKey(`app.companies.rolesEnum.${company.role}` as any) /* TODO i18n typed dynamic key */}`}
>
<Stack gap="xs" h="100%" justify="space-between">
<Stack gap={6}>
<Group justify="space-between" wrap="nowrap" align="flex-start">
<Title order={5} style={{ wordBreak: 'break-word' }}>
{asI18n(company.name)}
</Title>
<Badge variant="light" style={{ flexShrink: 0, whiteSpace: 'nowrap' }}>
{mKey(`app.companies.rolesEnum.${company.role}` as any) /* TODO i18n typed dynamic key */}
</Badge>
</Group>
{company.registryNo && (
<Text size="sm" c="dimmed">
{asI18n(company.registryNo)}
</Text>
)}
{location && (
<Text size="sm" c="dimmed" lineClamp={1}>
{asI18n(location)}
</Text>
)}
</Stack>
<Group gap="xs" mt="xs" wrap="wrap">
{company.totalShares > 0 && (
<Badge variant="default" size="sm" radius="sm">
{m.app_companies_shares_assigned({
assigned: company.assignedShares,
total: company.totalShares,
})}
</Badge>
)}
{company.fiscalYearEnd && (
<Badge variant="default" size="sm" radius="sm">
{asI18n(`${m.app_companies_fiscal_year_end_short()}: ${company.fiscalYearEnd}`)}
</Badge>
)}
</Group>
</Stack>
</Card>
)
}
function CompanyFormFields({
form,
setForm,
errors,
idPrefix,
}: {
form: FormState
setForm: (next: FormState) => void
errors: Partial<Record<FormErrorKey, string>>
idPrefix: string
}) {
useLocale()
const monthOptions = MONTHS.map((m) => ({
value: m,
label: mKey(`app.companies.months.${m}`),
}))
const dayOptions = Array.from({ length: daysInMonth(form.fiscalMonth) }, (_, i) => {
const v = String(i + 1).padStart(2, '0')
return { value: v, label: v }
})
return (
<Stack gap="sm">
<TextInput
id={`${idPrefix}-name`}
label={m.app_companies_name()}
value={form.name}
onChange={(e) => setForm({ ...form, name: e.currentTarget.value })}
error={errors.name}
required
autoFocus
/>
<TextInput
id={`${idPrefix}-registryNo`}
label={m.app_companies_registry_no()}
value={form.registryNo}
onChange={(e) => setForm({ ...form, registryNo: e.currentTarget.value })}
/>
<TextInput
id={`${idPrefix}-addressLine1`}
label={m.app_companies_address_line1()}
value={form.addressLine1}
onChange={(e) => setForm({ ...form, addressLine1: e.currentTarget.value })}
/>
<TextInput
id={`${idPrefix}-addressLine2`}
label={m.app_companies_address_line2_optional()}
value={form.addressLine2}
onChange={(e) => setForm({ ...form, addressLine2: e.currentTarget.value })}
/>
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="sm">
<TextInput
id={`${idPrefix}-postcode`}
label={m.app_companies_postcode()}
value={form.postcode}
onChange={(e) => setForm({ ...form, postcode: e.currentTarget.value })}
/>
<TextInput
id={`${idPrefix}-town`}
label={m.app_companies_town()}
value={form.town}
onChange={(e) => setForm({ ...form, town: e.currentTarget.value })}
/>
</SimpleGrid>
<TextInput
id={`${idPrefix}-country`}
label={m.app_companies_country()}
value={form.country}
onChange={(e) => setForm({ ...form, country: e.currentTarget.value })}
/>
<NumberInput
id={`${idPrefix}-totalShares`}
label={m.app_companies_total_shares()}
description={m.app_companies_total_shares_hint()}
value={form.totalShares}
onChange={(v) =>
setForm({ ...form, totalShares: typeof v === 'number' ? v : '' })
}
min={0}
step={1}
allowDecimal={false}
error={errors.totalShares}
required
/>
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="sm">
<Select
id={`${idPrefix}-fiscalMonth`}
label={m.app_companies_fiscal_month()}
placeholder={m.app_companies_fiscal_month_placeholder()}
data={monthOptions}
value={form.fiscalMonth || null}
onChange={(v) => {
const month = v ?? ''
const max = daysInMonth(month)
const day = form.fiscalDay && Number(form.fiscalDay) > max ? '' : form.fiscalDay
setForm({ ...form, fiscalMonth: month, fiscalDay: day })
}}
error={errors.fiscalYearEnd}
clearable
/>
<Select
id={`${idPrefix}-fiscalDay`}
label={m.app_companies_fiscal_day()}
placeholder={m.app_companies_fiscal_day_placeholder()}
data={dayOptions}
value={form.fiscalDay || null}
onChange={(v) => setForm({ ...form, fiscalDay: v ?? '' })}
disabled={!form.fiscalMonth}
clearable
/>
</SimpleGrid>
</Stack>
)
}
const FISCAL_RE = /^(0[1-9]|1[0-2])-(0[1-9]|[12]\d|3[01])$/
function CompaniesPage() {
useLocale()
const navigate = useNavigate()
const list = usePikkuQuery('listMyCompanies', undefined as never)
const [modalOpened, { open: openModal, close: closeModal }] = useDisclosure(false)
const [form, setForm] = useState<FormState>(EMPTY_FORM)
const [errors, setErrors] = useState<Partial<Record<FormErrorKey, string>>>({})
const [successName, setSuccessName] = useState<string | null>(null)
const [submitError, setSubmitError] = useState<string | null>(null)
const create = usePikkuMutation('createCompany', {
onSuccess: (_data, vars) => {
setForm(EMPTY_FORM)
setErrors({})
setSubmitError(null)
setSuccessName(vars?.name ?? null)
closeModal()
list.refetch()
},
onError: (err: unknown) => {
const msg = err instanceof Error ? err.message : String(err)
setSubmitError(msg)
},
})
useEffect(() => {
if (!successName) return
const t = setTimeout(() => setSuccessName(null), 5000)
return () => clearTimeout(t)
}, [successName])
const validate = (f: FormState) => {
const next: Partial<Record<FormErrorKey, string>> = {}
if (!f.name.trim()) next.name = m.app_companies_errors_name_required()
if (typeof f.totalShares !== 'number' || f.totalShares < 0 || !Number.isInteger(f.totalShares)) {
next.totalShares = m.app_companies_errors_total_shares_invalid()
}
const fy = f.fiscalMonth && f.fiscalDay ? `${f.fiscalMonth}-${f.fiscalDay}` : ''
if (f.fiscalMonth && !f.fiscalDay) {
next.fiscalYearEnd = m.app_companies_errors_fiscal_day_required()
} else if (!f.fiscalMonth && f.fiscalDay) {
next.fiscalYearEnd = m.app_companies_errors_fiscal_month_required()
} else if (fy && !FISCAL_RE.test(fy)) {
next.fiscalYearEnd = m.app_companies_errors_fiscal_invalid()
}
return next
}
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
const v = validate(form)
setErrors(v)
if (Object.keys(v).length > 0) return
setSubmitError(null)
const fiscalYearEnd =
form.fiscalMonth && form.fiscalDay ? `${form.fiscalMonth}-${form.fiscalDay}` : undefined
create.mutate({
name: form.name.trim(),
registryNo: form.registryNo.trim() || undefined,
addressLine1: form.addressLine1.trim() || undefined,
addressLine2: form.addressLine2.trim() || undefined,
postcode: form.postcode.trim() || undefined,
town: form.town.trim() || undefined,
country: form.country.trim() || undefined,
totalShares: typeof form.totalShares === 'number' ? form.totalShares : 0,
fiscalYearEnd,
})
}
const handleOpenCompany = (id: string) => {
navigate({ to: '/companies/$companyId', params: { companyId: id } as never })
}
const handleOpenModal = () => {
setErrors({})
setSubmitError(null)
openModal()
}
const isLoading = list.isLoading
const companies = list.data ?? []
const isEmpty = !isLoading && !list.error && companies.length === 0
return (
<Container size="lg" py="xl">
<Stack gap="lg">
<Group justify="space-between" align="flex-end" wrap="wrap">
<Stack gap={4}>
<Title order={2}>{m.app_companies_list_title()}</Title>
{!isLoading && !list.error && companies.length > 0 && (
<Text c="dimmed" size="sm">
{m.app_companies_count_subtitle({ count: companies.length })}
</Text>
)}
</Stack>
{companies.length > 0 && (
<Button
leftSection={<Plus size={16} aria-hidden="true" />}
onClick={handleOpenModal}
>
{m.app_companies_new_company()}
</Button>
)}
</Group>
{successName && (
<Alert
role="status"
color="green"
variant="light"
icon={<CheckCircle2 size={18} aria-hidden="true" />}
withCloseButton
onClose={() => setSuccessName(null)}
title={m.app_companies_success_title()}
>
{m.app_companies_success_message({ name: successName })}
</Alert>
)}
{list.error && (
<Alert
role="alert"
color="red"
variant="light"
icon={<AlertCircle size={18} aria-hidden="true" />}
title={m.app_companies_errors_load_title()}
>
{asI18n(String(list.error.message))}
</Alert>
)}
{isLoading && (
<SimpleGrid cols={{ base: 1, sm: 2, md: 3 }} spacing="md">
{Array.from({ length: 6 }).map((_, i) => (
<Card key={i} withBorder radius="md" p="md">
<Stack gap="xs">
<Group justify="space-between" wrap="nowrap">
<Skeleton height={20} width="60%" />
<Skeleton height={20} width={80} radius="xl" />
</Group>
<Skeleton height={12} width="40%" mt="xs" />
<Skeleton height={12} width="80%" />
<Skeleton height={20} width={110} radius="sm" mt="md" />
</Stack>
</Card>
))}
</SimpleGrid>
)}
{isEmpty && (
<Card withBorder radius="md" p="xl">
<Stack align="center" gap="md" py="lg">
<ThemeIcon size={64} radius="xl" variant="light" color="blue">
<Building2 size={32} aria-hidden="true" />
</ThemeIcon>
<Stack gap={4} align="center">
<Title order={3} ta="center">
{m.app_companies_empty_state_title()}
</Title>
<Text c="dimmed" ta="center" maw={420}>
{m.app_companies_empty_state_description()}
</Text>
</Stack>
<Button
size="md"
leftSection={<Plus size={16} aria-hidden="true" />}
onClick={handleOpenModal}
>
{m.app_companies_empty_state_cta()}
</Button>
</Stack>
</Card>
)}
{!isLoading && companies.length > 0 && (
<SimpleGrid cols={{ base: 1, sm: 2, md: 3 }} spacing="md">
{companies.map((c) => (
<CompanyCard
key={c.companyId}
company={c as any}
onOpen={handleOpenCompany}
/>
))}
</SimpleGrid>
)}
</Stack>
<Modal
opened={modalOpened}
onClose={closeModal}
title={m.app_companies_create()}
size="md"
centered
overlayProps={{ backgroundOpacity: 0.55, blur: 3 }}
>
<Box component="form" onSubmit={handleSubmit} noValidate>
<Stack gap="md">
{submitError && (
<Alert
role="alert"
color="red"
variant="light"
icon={<AlertCircle size={18} aria-hidden="true" />}
title={m.app_companies_errors_create_title()}
>
{asI18n(submitError)}
</Alert>
)}
<CompanyFormFields
form={form}
setForm={setForm}
errors={errors}
idPrefix="modal"
/>
<Group justify="flex-end" gap="sm">
<Button variant="subtle" onClick={closeModal} type="button">
{m.app_companies_cancel()}
</Button>
<Button type="submit" loading={create.isPending}>
{m.app_companies_create()}
</Button>
</Group>
</Stack>
</Box>
</Modal>
</Container>
)
}
function CompaniesPageWrapper() {
return (
<RequireAuth>
<CompaniesPage />
</RequireAuth>
)
}
export const Route = createFileRoute('/companies/')({
component: CompaniesPageWrapper,
})

View File

@@ -0,0 +1,267 @@
import { createFileRoute, Link } from '@tanstack/react-router'
import { Box, Button, Group, SimpleGrid, Text, Title } from '@pikku/mantine/core'
import { useLocale } from '@/i18n/config'
import { m } from '@/i18n/messages'
import { asI18n } 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 (
<span style={{
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
width: size, height: size, borderRadius: radius,
background: bg, color, flexShrink: 0,
fontFamily: "'Source Serif 4', Georgia, serif",
fontWeight: 600, fontSize, lineHeight: 1,
}}>
Q
</span>
)
}
function LandingNav() {
return (
<Box style={{
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
padding: '22px 48px', borderBottom: '1px solid rgba(255,255,255,.07)',
}}>
<Group gap={11}>
<QSeal bg="#2E6F54" />
<Text style={{ fontFamily: "'Source Serif 4', Georgia, serif", fontSize: 20, fontWeight: 600, color: '#F4F1E9', letterSpacing: '-.01em' }}>
{m.common_brand_name()}
</Text>
</Group>
<Group gap={34}>
<Text size="sm" style={{ color: 'rgba(244,241,233,.62)' }}>{m.landing_nav_product()}</Text>
<Text size="sm" style={{ color: 'rgba(244,241,233,.62)' }}>{m.landing_nav_templates()}</Text>
<Text size="sm" style={{ color: 'rgba(244,241,233,.62)' }}>{m.landing_nav_pricing()}</Text>
<Link to="/login" style={{ textDecoration: 'none' }}>
<Text size="sm" style={{ color: 'rgba(244,241,233,.62)' }}>{m.app_nav_sign_in()}</Text>
</Link>
<Link to="/signup" style={{ textDecoration: 'none' }}>
<Box style={{
fontSize: 14, fontWeight: 600, color: '#0E1F18',
background: '#EBC57A', padding: '9px 18px', borderRadius: 6,
cursor: 'pointer',
}}>
{m.landing_nav_cta()}
</Box>
</Link>
</Group>
</Box>
)
}
function FloatingResolutionCard() {
return (
<Box style={{
background: '#FBFAF7', borderRadius: 4,
boxShadow: '0 30px 80px -24px rgba(0,0,0,.5)',
transform: 'rotate(.6deg)', overflow: 'hidden',
}}>
<Box style={{
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
padding: '18px 22px', borderBottom: '1px solid #E6E3DB',
}}>
<Group gap={9}>
<QSeal size={22} fontSize={12} radius={5} />
<Text style={{ fontFamily: "'IBM Plex Mono', monospace", fontSize: 11, color: '#8A938C', letterSpacing: '.04em' }}>
{asI18n('BESCHLUSS · 2026-014')}
</Text>
</Group>
<Box style={{
fontFamily: "'IBM Plex Mono', monospace", fontWeight: 600,
fontSize: 10, letterSpacing: '.18em', textTransform: 'uppercase',
color: '#1F5641', background: '#E7F0EA', padding: '4px 9px', borderRadius: 20,
}}>
Published
</Box>
</Box>
<Box style={{ padding: '24px 24px 26px' }}>
<Text style={{ fontFamily: "'IBM Plex Mono', monospace", fontSize: 11, letterSpacing: '.18em', textTransform: 'uppercase', color: '#A9B0A6', marginBottom: 8 }}>
{asI18n('Profit Distribution')}
</Text>
<Text style={{ fontFamily: "'Source Serif 4', Georgia, serif", fontSize: 21, fontWeight: 600, color: '#16201B', lineHeight: 1.25, marginBottom: 20 }}>
{asI18n('Resolution on the appropriation of the 2025 annual result')}
</Text>
{([
['Distributable profit', '€ 248,500.00'],
['Per share (25,000)', '€ 9.94'],
['Record date', '31 May 2026'],
] as [string, string][]).map(([label, value]) => (
<Box key={label} style={{ display: 'flex', justifyContent: 'space-between', padding: '11px 0', borderTop: '1px solid #EFEDE6' }}>
<Text size="sm" style={{ color: '#55615A' }}>{asI18n(label)}</Text>
<Text style={{ fontFamily: "'IBM Plex Mono', monospace", fontSize: 13, fontWeight: 600, color: '#16201B' }}>{asI18n(value)}</Text>
</Box>
))}
<Box style={{ display: 'flex', alignItems: 'center', gap: 12, marginTop: 22, paddingTop: 18, borderTop: '1px solid #EFEDE6' }}>
<Box style={{
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
width: 40, height: 40, borderRadius: '50%', border: '1.5px solid #B08A3E',
color: '#B08A3E', fontFamily: "'IBM Plex Mono', monospace", fontSize: 9, textAlign: 'center', lineHeight: 1.1,
}}>
SIG<br />NED
</Box>
<Box>
<Text size="xs" fw={600} style={{ color: '#16201B' }}>{asI18n('M. Bauer · Managing Director')}</Text>
<Text style={{ fontFamily: "'IBM Plex Mono', monospace", fontSize: 11, color: '#8A938C' }}>{asI18n('signed 02 Jun 2026, 14:21')}</Text>
</Box>
</Box>
</Box>
</Box>
)
}
const FEATURES = [
{ num: '01', titleKey: 'landing_feature1_title' as const, descKey: 'landing_feature1_desc' as const },
{ num: '02', titleKey: 'landing_feature2_title' as const, descKey: 'landing_feature2_desc' as const },
{ num: '03', titleKey: 'landing_feature3_title' as const, descKey: 'landing_feature3_desc' as const },
{ num: '04', titleKey: 'landing_feature4_title' as const, descKey: 'landing_feature4_desc' as const },
]
function IndexPage() {
useLocale()
return (
<Box style={{ background: '#0E1F18', minHeight: '100vh' }}>
<LandingNav />
{/* Hero */}
<Box style={{
position: 'relative', padding: '84px 48px 76px',
display: 'grid', gridTemplateColumns: '1.05fr .95fr', gap: 56, alignItems: 'center',
}}>
<Box style={{
position: 'absolute', inset: 0,
background: 'radial-gradient(900px 460px at 16% 18%, rgba(46,111,84,.30), transparent 70%)',
pointerEvents: 'none',
}} />
{/* Left column */}
<Box style={{ position: 'relative' }}>
<Box style={{
display: 'inline-flex', alignItems: 'center', gap: 9,
padding: '7px 14px', border: '1px solid rgba(235,197,122,.34)',
borderRadius: 30, marginBottom: 28,
}}>
<Box style={{ width: 5, height: 5, borderRadius: '50%', background: '#EBC57A', flexShrink: 0 }} />
<Text style={{
fontFamily: "'IBM Plex Mono', monospace", fontWeight: 600,
fontSize: 11, letterSpacing: '.18em', textTransform: 'uppercase', color: '#EBC57A',
}}>
{m.landing_eyebrow()}
</Text>
</Box>
<Title style={{
margin: 0, fontFamily: "'Source Serif 4', Georgia, serif",
fontWeight: 380, fontSize: 'clamp(2.5rem, 4vw, 3.875rem)',
lineHeight: 1.06, letterSpacing: '-.025em', color: '#F4F1E9',
}}>
{m.landing_headline_plain()}<br />
<span style={{ fontStyle: 'italic', color: '#8FCBA9' }}>
{m.landing_headline_accent()}
</span>
</Title>
<Text style={{ margin: '28px 0 0', maxWidth: 460, fontSize: 17, lineHeight: 1.7, color: 'rgba(244,241,233,.66)' }}>
{m.landing_subline()}
</Text>
<Group gap={13} mt={36}>
<Button
component={Link}
to="/signup"
style={{
fontSize: 15, fontWeight: 600, color: '#0E1F18',
background: '#EBC57A', border: 'none',
padding: '13px 24px', borderRadius: 7, height: 'auto',
}}
>
{m.landing_cta_primary()}
</Button>
<Button
component={Link}
to="/login"
variant="outline"
style={{
fontSize: 15, fontWeight: 500, color: '#F4F1E9',
border: '1px solid rgba(244,241,233,.26)',
padding: '13px 22px', borderRadius: 7, height: 'auto',
background: 'transparent',
}}
>
{m.landing_cta_secondary()}
</Button>
</Group>
{/* Stats */}
<Group gap={0} mt={44} align="stretch">
{[
{ value: m.landing_stat1_value(), label: m.landing_stat1_label() },
{ value: m.landing_stat2_value(), label: m.landing_stat2_label() },
{ value: m.landing_stat3_value(), label: m.landing_stat3_label() },
].map(({ value, label }, i) => (
<Group key={String(value)} gap={0} align="stretch">
{i > 0 && (
<Box style={{ width: 1, background: 'rgba(244,241,233,.12)', marginInline: 28, alignSelf: 'stretch' }} />
)}
<Box>
<Text style={{ fontFamily: "'IBM Plex Mono', monospace", fontSize: 24, fontWeight: 600, color: '#F4F1E9' }}>
{value}
</Text>
<Text size="xs" style={{ color: 'rgba(244,241,233,.5)', marginTop: 3 }}>{label}</Text>
</Box>
</Group>
))}
</Group>
</Box>
{/* Right column */}
<Box style={{ position: 'relative' }}>
<FloatingResolutionCard />
</Box>
</Box>
{/* Feature strip */}
<Box style={{ borderTop: '1px solid rgba(255,255,255,.07)', padding: '54px 48px 64px' }}>
<Text style={{
fontFamily: "'IBM Plex Mono', monospace", fontWeight: 600,
fontSize: 11, letterSpacing: '.18em', textTransform: 'uppercase',
color: 'rgba(244,241,233,.42)', textAlign: 'center', marginBottom: 38,
}}>
{m.landing_features_eyebrow()}
</Text>
<SimpleGrid cols={{ base: 1, sm: 2, md: 4 }} spacing="xl">
{FEATURES.map(({ num, titleKey, descKey }) => (
<Box key={num}>
<Text style={{ fontFamily: "'IBM Plex Mono', monospace", fontSize: 13, color: '#8FCBA9', marginBottom: 14 }}>
{asI18n(num)}
</Text>
<Text style={{ fontFamily: "'Source Serif 4', Georgia, serif", fontSize: 18, fontWeight: 600, color: '#F4F1E9', marginBottom: 8 }}>
{m[titleKey]()}
</Text>
<Text size="sm" style={{ lineHeight: 1.65, color: 'rgba(244,241,233,.55)' }}>
{m[descKey]()}
</Text>
</Box>
))}
</SimpleGrid>
</Box>
{/* Footer */}
<Box style={{ padding: '20px 48px', borderTop: '1px solid rgba(255,255,255,.06)', textAlign: 'center' }}>
<Text style={{
fontFamily: "'IBM Plex Mono', monospace", fontSize: 11,
color: 'rgba(244,241,233,.32)', letterSpacing: '.06em',
}}>
{m.landing_footer()}
</Text>
</Box>
</Box>
)
}
export const Route = createFileRoute('/')({
component: IndexPage,
})

View File

@@ -0,0 +1,80 @@
import { createFileRoute, Link, useNavigate } from '@tanstack/react-router'
import { Alert, Anchor, Button, PasswordInput, Stack, Text, TextInput } from '@pikku/mantine/core'
import { useState } from 'react'
import { m } from '@/i18n/messages'
import { useLocale } from '@/i18n/config'
import { asI18n } from '@pikku/react'
import { useMutation } from '@tanstack/react-query'
import { AuthLayout } from '../auth/AuthLayout'
import { signInWithPassword, INVALID_CREDENTIALS } from '../lib/auth'
function LoginPage() {
useLocale()
const navigate = useNavigate()
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const login = useMutation({
mutationFn: () => signInWithPassword(email.trim(), password),
onSuccess: () => navigate({ to: '/companies' }),
})
const errorMsg = login.error instanceof Error
? login.error.message === INVALID_CREDENTIALS
? m.auth_login_errors_invalid_credentials()
: asI18n(login.error.message)
: null
return (
<AuthLayout
title={m.auth_login_title()}
subtitle={m.auth_login_subtitle()}
tagline={m.app_tagline()}
footer={
<>
{m.auth_login_no_account()}{' '}
<Anchor component={Link} to="/signup" fw={500}>
{m.auth_login_signup_cta()}
</Anchor>
</>
}
>
<form onSubmit={(e) => { e.preventDefault(); login.mutate() }}>
<Stack gap="sm">
<TextInput
type="email"
label={m.auth_login_email()}
placeholder={m.auth_login_email_placeholder()}
value={email}
onChange={(e) => setEmail(e.currentTarget.value)}
required
size="md"
radius="md"
autoComplete="email"
/>
<PasswordInput
label={m.auth_login_password()}
value={password}
onChange={(e) => setPassword(e.currentTarget.value)}
required
size="md"
radius="md"
autoComplete="current-password"
/>
{errorMsg && (
<Alert color="red" variant="light">
<Text size="sm">{errorMsg}</Text>
</Alert>
)}
<Button type="submit" loading={login.isPending} fullWidth size="md" radius="md">
{m.auth_login_submit()}
</Button>
</Stack>
</form>
</AuthLayout>
)
}
export const Route = createFileRoute('/login')({
component: LoginPage,
})

View File

@@ -0,0 +1,72 @@
import { useEffect, useState } from 'react'
import { createFileRoute, useNavigate } from '@tanstack/react-router'
import { Alert, Button, Center, Paper, Stack, Text, Title } from '@pikku/mantine/core'
import { m } from '@/i18n/messages'
import { useLocale } from '@/i18n/config'
import { asI18n } from '@pikku/react'
import { useQueryClient } from '@tanstack/react-query'
function LogoutPage() {
useLocale()
const navigate = useNavigate()
const queryClient = useQueryClient()
const [error, setError] = useState<string | null>(null)
const [done, setDone] = useState(false)
useEffect(() => {
let cancelled = false
const run = async () => {
try {
const csrfRes = await fetch('/auth/csrf', { credentials: 'include' })
const { csrfToken } = (await csrfRes.json()) as { csrfToken: string }
const body = new URLSearchParams({ csrfToken, callbackUrl: '/login' })
await fetch('/auth/signout', {
method: 'POST',
headers: { 'content-type': 'application/x-www-form-urlencoded' },
body,
credentials: 'include',
redirect: 'manual',
})
if (cancelled) return
queryClient.clear()
setDone(true)
} catch (err) {
if (!cancelled) setError(String((err as Error).message))
}
}
run()
return () => {
cancelled = true
}
}, [queryClient])
return (
<Center mih="100vh" p="md">
<Paper withBorder radius="md" p="xl" maw={420} w="100%">
<Stack gap="md">
<Title order={2}>{m.auth_logout_title({ defaultValue: 'Signed out' })}</Title>
{error && <Alert color="red">{asI18n(error)}</Alert>}
{!done && !error && (
<Text size="sm" c="dimmed">
{m.auth_logout_signing_out({ defaultValue: 'Signing you out…' })}
</Text>
)}
{done && (
<>
<Text size="sm" c="dimmed">
{m.auth_logout_cleared({ defaultValue: 'Your session has been cleared.' })}
</Text>
<Button onClick={() => navigate({ to: '/login' })}>
{m.auth_logout_sign_in_again({ defaultValue: 'Sign in again' })}
</Button>
</>
)}
</Stack>
</Paper>
</Center>
)
}
export const Route = createFileRoute('/logout')({
component: LogoutPage,
})

View File

@@ -0,0 +1,50 @@
import { createFileRoute, Link } from '@tanstack/react-router'
import { RequireAuth } from '../auth'
import { Container, Stack, Title, Text, Card, Group, Badge, Button } from '@pikku/mantine/core'
import { m, mKey } from '@/i18n/messages'
import { useLocale } from '@/i18n/config'
import { asI18n } from '@pikku/react'
import { usePikkuQuery } from '@germantax/functions-sdk/pikku/api.gen'
function PortfolioPage() {
useLocale()
const list = usePikkuQuery('listMyCompanies', undefined as never)
return (
<Container size="lg" py="xl">
<Stack gap="md">
<Title order={2}>{m.app_nav_portfolio()}</Title>
{list.data && list.data.length === 0 && <Text c="dimmed">{m.app_companies_empty()}</Text>}
<Stack gap="sm">
{list.data?.map((c) => (
<Card key={c.companyId} withBorder radius="md" p="md">
<Group justify="space-between">
<Stack gap={2}>
<Title order={5}>{asI18n(c.name)}</Title>
{c.registryNo && <Text size="xs" c="dimmed">{asI18n(c.registryNo)}</Text>}
</Stack>
<Group>
<Badge>{mKey(`app.companies.rolesEnum.${c.role}`)}</Badge>
<Button component={Link} to="/companies/$companyId" params={{ companyId: c.companyId } as any} size="xs" variant="light">
{m.app_companies_open()}
</Button>
</Group>
</Group>
</Card>
))}
</Stack>
</Stack>
</Container>
)
}
function PortfolioPageWrapper() {
return (
<RequireAuth>
<PortfolioPage />
</RequireAuth>
)
}
export const Route = createFileRoute('/portfolio')({
component: PortfolioPageWrapper,
})

View File

@@ -0,0 +1,95 @@
import { createFileRoute, Link, useNavigate } from '@tanstack/react-router'
import { Alert, Anchor, Button, PasswordInput, Stack, Text, TextInput } from '@pikku/mantine/core'
import { useState } from 'react'
import { m } from '@/i18n/messages'
import { useLocale } from '@/i18n/config'
import { asI18n } from '@pikku/react'
import { useMutation } from '@tanstack/react-query'
import { AuthLayout } from '../auth/AuthLayout'
import { registerWithPassword, signInWithPassword, EMAIL_IN_USE } from '../lib/auth'
function SignupPage() {
useLocale()
const navigate = useNavigate()
const [displayName, setDisplayName] = useState('')
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const signup = useMutation({
mutationFn: async () => {
await registerWithPassword(email.trim(), password, displayName.trim())
await signInWithPassword(email.trim(), password)
},
onSuccess: () => navigate({ to: '/companies' }),
})
const errorMsg = signup.error instanceof Error
? signup.error.message === EMAIL_IN_USE
? m.auth_signup_errors_email_taken()
: asI18n(signup.error.message)
: null
return (
<AuthLayout
title={m.auth_signup_title()}
subtitle={m.auth_signup_subtitle()}
tagline={m.app_tagline()}
footer={
<>
{m.auth_signup_have_account()}{' '}
<Anchor component={Link} to="/login" fw={500}>
{m.auth_signup_login_cta()}
</Anchor>
</>
}
>
<form onSubmit={(e) => { e.preventDefault(); signup.mutate() }}>
<Stack gap="sm">
<TextInput
label={m.auth_signup_display_name()}
value={displayName}
onChange={(e) => setDisplayName(e.currentTarget.value)}
required
size="md"
radius="md"
autoComplete="name"
/>
<TextInput
type="email"
label={m.auth_login_email()}
placeholder={m.auth_login_email_placeholder()}
value={email}
onChange={(e) => setEmail(e.currentTarget.value)}
required
size="md"
radius="md"
autoComplete="email"
/>
<PasswordInput
label={m.auth_login_password()}
description={m.auth_signup_password_hint()}
value={password}
onChange={(e) => setPassword(e.currentTarget.value)}
required
minLength={8}
size="md"
radius="md"
autoComplete="new-password"
/>
{errorMsg && (
<Alert color="red" variant="light">
<Text size="sm">{errorMsg}</Text>
</Alert>
)}
<Button type="submit" loading={signup.isPending} fullWidth size="md" radius="md">
{m.auth_signup_submit()}
</Button>
</Stack>
</form>
</AuthLayout>
)
}
export const Route = createFileRoute('/signup')({
component: SignupPage,
})

16
apps/app/src/router.tsx Normal file
View File

@@ -0,0 +1,16 @@
import { createRouter } from '@tanstack/react-router'
import { routeTree } from './routeTree.gen'
export function getRouter() {
return createRouter({
routeTree,
scrollRestoration: true,
defaultPreload: 'intent',
})
}
declare module '@tanstack/react-router' {
interface Register {
router: ReturnType<typeof getRouter>
}
}

4
apps/app/src/theme.css Normal file
View File

@@ -0,0 +1,4 @@
html, body, #root {
height: 100%;
margin: 0;
}

24
apps/app/tsconfig.json Normal file
View File

@@ -0,0 +1,24 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"moduleResolution": "Bundler",
"jsx": "react-jsx",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"allowImportingTsExtensions": false,
"resolveJsonModule": true,
"isolatedModules": true,
"allowJs": true,
"checkJs": false,
"noEmit": true,
"types": ["vite/client"],
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
}
},
"include": ["src", "vite.config.ts"]
}

6
apps/app/tsr.config.json Normal file
View File

@@ -0,0 +1,6 @@
{
"routesDirectory": "./src/pages",
"generatedRouteTree": "./src/routeTree.gen.ts",
"routeFileIgnorePrefix": "-",
"quoteStyle": "single"
}

34
apps/app/vite.config.ts Normal file
View File

@@ -0,0 +1,34 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import { tanstackStart } from '@tanstack/react-start/plugin/vite'
import { paraglideVitePlugin } from '@inlang/paraglide-js'
import path from 'node:path'
export default defineConfig({
plugins: [
// Compile messages/*.json → src/paraglide so `m`/`mKey` resolve, with HMR on
// message edits. Must run first.
paraglideVitePlugin({ project: './project.inlang', outdir: './src/paraglide' }),
tanstackStart({
router: {
routesDirectory: path.resolve(import.meta.dirname, 'src/pages'),
generatedRouteTree: path.resolve(import.meta.dirname, 'src/routeTree.gen.ts'),
routeFileIgnorePrefix: '-',
quoteStyle: 'single',
},
}),
react(),
],
server: {
port: 7104,
host: '0.0.0.0',
proxy: {
'/api': 'http://localhost:4003',
},
},
resolve: {
alias: {
'@': path.resolve(import.meta.dirname, 'src'),
},
},
})

1963
bun.lock Normal file

File diff suppressed because it is too large Load Diff

26
db/annotations.ts Normal file
View File

@@ -0,0 +1,26 @@
import type { DbClassificationMap } from '/Users/yasser/git/pikku/germantax/.pikku/db/classification-map.gen.d.ts'
export const classifications = {
"account": {
},
"audit_log": {
},
"company": {
},
"company_member": {
},
"invitation": {
},
"member": {
},
"organization": {
},
"resolution": {
},
"session": {
},
"user": {
},
"verification": {
},
} satisfies DbClassificationMap

View File

@@ -0,0 +1,63 @@
CREATE TABLE "user" (
user_id TEXT PRIMARY KEY DEFAULT (lower(substr(hex(randomblob(4)), 1, 8) || '-' || substr(hex(randomblob(2)), 1, 4) || '-4' || substr(hex(randomblob(2)), 2, 3) || '-' || substr('89ab', abs(random()) % 4 + 1, 1) || substr(hex(randomblob(2)), 2, 3) || '-' || hex(randomblob(6)))),
email TEXT NOT NULL UNIQUE,
display_name TEXT NOT NULL,
password_hash TEXT,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP -- @date
);
CREATE TABLE company (
company_id TEXT PRIMARY KEY DEFAULT (lower(substr(hex(randomblob(4)), 1, 8) || '-' || substr(hex(randomblob(2)), 1, 4) || '-4' || substr(hex(randomblob(2)), 2, 3) || '-' || substr('89ab', abs(random()) % 4 + 1, 1) || substr(hex(randomblob(2)), 2, 3) || '-' || hex(randomblob(6)))),
name TEXT NOT NULL,
registry_no TEXT,
address_line1 TEXT,
address_line2 TEXT,
postcode TEXT,
town TEXT,
country TEXT,
total_shares INTEGER NOT NULL DEFAULT 0,
fiscal_year_end TEXT,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP -- @date
);
CREATE TABLE company_member (
member_id TEXT PRIMARY KEY DEFAULT (lower(substr(hex(randomblob(4)), 1, 8) || '-' || substr(hex(randomblob(2)), 1, 4) || '-4' || substr(hex(randomblob(2)), 2, 3) || '-' || substr('89ab', abs(random()) % 4 + 1, 1) || substr(hex(randomblob(2)), 2, 3) || '-' || hex(randomblob(6)))),
company_id TEXT NOT NULL REFERENCES company(company_id) ON DELETE CASCADE,
user_id TEXT NOT NULL REFERENCES "user"(user_id) ON DELETE CASCADE,
role TEXT NOT NULL CHECK (role IN ('managing_director', 'shareholder', 'angel')),
shares INTEGER,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, -- @date
UNIQUE (company_id, user_id)
);
CREATE INDEX idx_company_member_company ON company_member(company_id);
CREATE INDEX idx_company_member_user ON company_member(user_id);
CREATE TABLE resolution (
resolution_id TEXT PRIMARY KEY DEFAULT (lower(substr(hex(randomblob(4)), 1, 8) || '-' || substr(hex(randomblob(2)), 1, 4) || '-4' || substr(hex(randomblob(2)), 2, 3) || '-' || substr('89ab', abs(random()) % 4 + 1, 1) || substr(hex(randomblob(2)), 2, 3) || '-' || hex(randomblob(6)))),
company_id TEXT NOT NULL REFERENCES company(company_id) ON DELETE CASCADE,
template_key TEXT NOT NULL,
title TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'draft' CHECK (status IN ('draft', 'published', 'archived')),
field_values TEXT NOT NULL DEFAULT '{}', -- @json:Record<string, unknown>
pdf_key TEXT,
created_by TEXT NOT NULL REFERENCES "user"(user_id),
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, -- @date
published_at TEXT -- @date
);
CREATE INDEX idx_resolution_company ON resolution(company_id);
CREATE INDEX idx_resolution_status ON resolution(status);
CREATE TABLE audit_log (
audit_id TEXT PRIMARY KEY DEFAULT (lower(substr(hex(randomblob(4)), 1, 8) || '-' || substr(hex(randomblob(2)), 1, 4) || '-4' || substr(hex(randomblob(2)), 2, 3) || '-' || substr('89ab', abs(random()) % 4 + 1, 1) || substr(hex(randomblob(2)), 2, 3) || '-' || hex(randomblob(6)))),
table_name TEXT NOT NULL,
record_id TEXT NOT NULL,
action TEXT NOT NULL CHECK (action IN ('INSERT', 'UPDATE', 'DELETE')),
user_id TEXT REFERENCES "user"(user_id),
changed_fields TEXT, -- @json:Record<string, unknown>
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP -- @date
);
CREATE INDEX idx_audit_log_record ON audit_log(table_name, record_id);
CREATE INDEX idx_audit_log_created ON audit_log(created_at);

15
db/seed.sql Normal file
View File

@@ -0,0 +1,15 @@
-- Seed data for local dev.
-- Applied by `pikku db seed` and `pikku db reset`.
-- password for all seed users is: test1234
-- hash = pbkdf2:100000:<fixed-salt>:<derived-key> (PBKDF2-SHA-256, 100k iterations)
INSERT OR IGNORE INTO "user" (user_id, email, display_name, password_hash) VALUES
('seed-user-1', 'founder@example.com', 'Anna Müller', 'pbkdf2:100000:42424242424242424242424242424242:368e3b41696f0f194428b3580c0666cc8aa5c21e4a5d0c7cd6a3729f8dbd0264'),
('seed-user-2', 'angel@example.com', 'Bernd Schmidt', 'pbkdf2:100000:42424242424242424242424242424242:368e3b41696f0f194428b3580c0666cc8aa5c21e4a5d0c7cd6a3729f8dbd0264');
INSERT OR IGNORE INTO company (company_id, name, registry_no, address_line1, postcode, town, country, total_shares, fiscal_year_end) VALUES
('seed-co-1', 'Beispiel GmbH', 'HRB 123456', 'Musterstraße 1', '10115', 'Berlin', 'Germany', 10000, '12-31');
INSERT OR IGNORE INTO company_member (member_id, company_id, user_id, role, shares) VALUES
('seed-cm-1', 'seed-co-1', 'seed-user-1', 'managing_director', 6000),
('seed-cm-2', 'seed-co-1', 'seed-user-2', 'angel', 4000);

10
db/sqlite-seed.sql Normal file
View File

@@ -0,0 +1,10 @@
-- No seed data required for germantax.
-- ─── Demo auth account (better-auth credential) ───────────────────────
-- Demo login: admin@germantax.example / admin1234. password is a better-auth
-- scrypt hash (salt:hash, self-contained → portable).
INSERT OR IGNORE INTO "user" (id, name, email, email_verified, display_name, created_at, updated_at) VALUES
('u_admin', 'Admin', 'admin@germantax.example', 1, 'Admin', '2026-01-01T00:00:00Z', '2026-01-01T00:00:00Z');
INSERT OR IGNORE INTO "account" (id, account_id, provider_id, user_id, password, created_at, updated_at) VALUES
('acct_u_admin', 'u_admin', 'credential', 'u_admin', '26550d3214a4686b4f15ffd02572492a:4efb9aec00703934709d5fa4264656801ab85673c569c76977e7f060323312f838f7bd3cb74003c8639834191cb1095be539ddd45b7135c95b084437cc18e67b', '2026-01-01T00:00:00Z', '2026-01-01T00:00:00Z');

63
db/sqlite/0001-init.sql Normal file
View File

@@ -0,0 +1,63 @@
CREATE TABLE "user" (
user_id TEXT PRIMARY KEY DEFAULT (lower(substr(hex(randomblob(4)), 1, 8) || '-' || substr(hex(randomblob(2)), 1, 4) || '-4' || substr(hex(randomblob(2)), 2, 3) || '-' || substr('89ab', abs(random()) % 4 + 1, 1) || substr(hex(randomblob(2)), 2, 3) || '-' || hex(randomblob(6)))),
email TEXT NOT NULL UNIQUE,
display_name TEXT NOT NULL,
password_hash TEXT,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP -- @date
);
CREATE TABLE company (
company_id TEXT PRIMARY KEY DEFAULT (lower(substr(hex(randomblob(4)), 1, 8) || '-' || substr(hex(randomblob(2)), 1, 4) || '-4' || substr(hex(randomblob(2)), 2, 3) || '-' || substr('89ab', abs(random()) % 4 + 1, 1) || substr(hex(randomblob(2)), 2, 3) || '-' || hex(randomblob(6)))),
name TEXT NOT NULL,
registry_no TEXT,
address_line1 TEXT,
address_line2 TEXT,
postcode TEXT,
town TEXT,
country TEXT,
total_shares INTEGER NOT NULL DEFAULT 0,
fiscal_year_end TEXT,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP -- @date
);
CREATE TABLE company_member (
member_id TEXT PRIMARY KEY DEFAULT (lower(substr(hex(randomblob(4)), 1, 8) || '-' || substr(hex(randomblob(2)), 1, 4) || '-4' || substr(hex(randomblob(2)), 2, 3) || '-' || substr('89ab', abs(random()) % 4 + 1, 1) || substr(hex(randomblob(2)), 2, 3) || '-' || hex(randomblob(6)))),
company_id TEXT NOT NULL REFERENCES company(company_id) ON DELETE CASCADE,
user_id TEXT NOT NULL REFERENCES "user"(user_id) ON DELETE CASCADE,
role TEXT NOT NULL CHECK (role IN ('managing_director', 'shareholder', 'angel')),
shares INTEGER,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, -- @date
UNIQUE (company_id, user_id)
);
CREATE INDEX idx_company_member_company ON company_member(company_id);
CREATE INDEX idx_company_member_user ON company_member(user_id);
CREATE TABLE resolution (
resolution_id TEXT PRIMARY KEY DEFAULT (lower(substr(hex(randomblob(4)), 1, 8) || '-' || substr(hex(randomblob(2)), 1, 4) || '-4' || substr(hex(randomblob(2)), 2, 3) || '-' || substr('89ab', abs(random()) % 4 + 1, 1) || substr(hex(randomblob(2)), 2, 3) || '-' || hex(randomblob(6)))),
company_id TEXT NOT NULL REFERENCES company(company_id) ON DELETE CASCADE,
template_key TEXT NOT NULL,
title TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'draft' CHECK (status IN ('draft', 'published', 'archived')),
field_values TEXT NOT NULL DEFAULT '{}', -- @json:Record<string, unknown>
pdf_key TEXT,
created_by TEXT NOT NULL REFERENCES "user"(user_id),
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, -- @date
published_at TEXT -- @date
);
CREATE INDEX idx_resolution_company ON resolution(company_id);
CREATE INDEX idx_resolution_status ON resolution(status);
CREATE TABLE audit_log (
audit_id TEXT PRIMARY KEY DEFAULT (lower(substr(hex(randomblob(4)), 1, 8) || '-' || substr(hex(randomblob(2)), 1, 4) || '-4' || substr(hex(randomblob(2)), 2, 3) || '-' || substr('89ab', abs(random()) % 4 + 1, 1) || substr(hex(randomblob(2)), 2, 3) || '-' || hex(randomblob(6)))),
table_name TEXT NOT NULL,
record_id TEXT NOT NULL,
action TEXT NOT NULL CHECK (action IN ('INSERT', 'UPDATE', 'DELETE')),
user_id TEXT REFERENCES "user"(user_id),
changed_fields TEXT, -- @json:Record<string, unknown>
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP -- @date
);
CREATE INDEX idx_audit_log_record ON audit_log(table_name, record_id);
CREATE INDEX idx_audit_log_created ON audit_log(created_at);

View File

@@ -0,0 +1,107 @@
-- Migrate from Auth.js / custom user table to Better Auth.
-- Better Auth owns user, session, account, verification tables.
-- Drop tables in FK dependency order, then recreate with new FKs pointing to user(id).
-- Drop dependent tables first
DROP TABLE IF EXISTS audit_log;
DROP TABLE IF EXISTS resolution;
DROP TABLE IF EXISTS company_member;
DROP TABLE IF EXISTS "user";
-- Better Auth user table with displayName as additionalField
CREATE TABLE "user" (
"id" text not null primary key,
"name" text not null,
"email" text not null unique,
"email_verified" integer not null,
"image" text,
"display_name" text not null default '',
"created_at" date not null,
"updated_at" date not null
);
-- Recreate company_member referencing user(id)
CREATE TABLE company_member (
member_id TEXT PRIMARY KEY DEFAULT (lower(substr(hex(randomblob(4)), 1, 8) || '-' || substr(hex(randomblob(2)), 1, 4) || '-4' || substr(hex(randomblob(2)), 2, 3) || '-' || substr('89ab', abs(random()) % 4 + 1, 1) || substr(hex(randomblob(2)), 2, 3) || '-' || hex(randomblob(6)))),
company_id TEXT NOT NULL REFERENCES company(company_id) ON DELETE CASCADE,
user_id TEXT NOT NULL REFERENCES "user"(id) ON DELETE CASCADE,
role TEXT NOT NULL CHECK (role IN ('managing_director', 'shareholder', 'angel')),
shares INTEGER,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE (company_id, user_id)
);
CREATE INDEX idx_company_member_company ON company_member(company_id);
CREATE INDEX idx_company_member_user ON company_member(user_id);
-- Recreate resolution referencing user(id)
CREATE TABLE resolution (
resolution_id TEXT PRIMARY KEY DEFAULT (lower(substr(hex(randomblob(4)), 1, 8) || '-' || substr(hex(randomblob(2)), 1, 4) || '-4' || substr(hex(randomblob(2)), 2, 3) || '-' || substr('89ab', abs(random()) % 4 + 1, 1) || substr(hex(randomblob(2)), 2, 3) || '-' || hex(randomblob(6)))),
company_id TEXT NOT NULL REFERENCES company(company_id) ON DELETE CASCADE,
template_key TEXT NOT NULL,
title TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'draft' CHECK (status IN ('draft', 'published', 'archived')),
field_values TEXT NOT NULL DEFAULT '{}',
pdf_key TEXT,
created_by TEXT NOT NULL REFERENCES "user"(id),
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
published_at TEXT
);
CREATE INDEX idx_resolution_company ON resolution(company_id);
CREATE INDEX idx_resolution_status ON resolution(status);
-- Recreate audit_log referencing user(id)
CREATE TABLE audit_log (
audit_id TEXT PRIMARY KEY DEFAULT (lower(substr(hex(randomblob(4)), 1, 8) || '-' || substr(hex(randomblob(2)), 1, 4) || '-4' || substr(hex(randomblob(2)), 2, 3) || '-' || substr('89ab', abs(random()) % 4 + 1, 1) || substr(hex(randomblob(2)), 2, 3) || '-' || hex(randomblob(6)))),
table_name TEXT NOT NULL,
record_id TEXT NOT NULL,
action TEXT NOT NULL CHECK (action IN ('INSERT', 'UPDATE', 'DELETE')),
user_id TEXT REFERENCES "user"(id),
changed_fields TEXT,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX idx_audit_log_record ON audit_log(table_name, record_id);
CREATE INDEX idx_audit_log_created ON audit_log(created_at);
-- Better Auth session/account/verification tables
CREATE TABLE IF NOT EXISTS "session" (
"id" text not null primary key,
"expires_at" date not null,
"token" text not null unique,
"created_at" date not null,
"updated_at" date not null,
"ip_address" text,
"user_agent" text,
"user_id" text not null references "user" ("id") on delete cascade
);
CREATE TABLE IF NOT EXISTS "account" (
"id" text not null primary key,
"account_id" text not null,
"provider_id" text not null,
"user_id" text not null references "user" ("id") on delete cascade,
"access_token" text,
"refresh_token" text,
"id_token" text,
"access_token_expires_at" date,
"refresh_token_expires_at" date,
"scope" text,
"password" text,
"created_at" date not null,
"updated_at" date not null
);
CREATE TABLE IF NOT EXISTS "verification" (
"id" text not null primary key,
"identifier" text not null,
"value" text not null,
"expires_at" date not null,
"created_at" date not null,
"updated_at" date not null
);
CREATE INDEX IF NOT EXISTS "session_user_id_idx" ON "session" ("user_id");
CREATE INDEX IF NOT EXISTS "account_user_id_idx" ON "account" ("user_id");
CREATE INDEX IF NOT EXISTS "verification_identifier_idx" ON "verification" ("identifier");

View File

@@ -0,0 +1,33 @@
-- Better Auth organization plugin tables.
-- Adds organization, member, invitation tables used by the organization() plugin.
CREATE TABLE IF NOT EXISTS "organization" (
"id" text not null primary key,
"name" text not null,
"slug" text unique,
"logo" text,
"created_at" date not null,
"metadata" text
);
CREATE TABLE IF NOT EXISTS "member" (
"id" text not null primary key,
"organization_id" text not null references "organization" ("id") on delete cascade,
"user_id" text not null references "user" ("id") on delete cascade,
"role" text not null,
"created_at" date not null
);
CREATE TABLE IF NOT EXISTS "invitation" (
"id" text not null primary key,
"organization_id" text not null references "organization" ("id") on delete cascade,
"email" text not null,
"role" text,
"status" text not null,
"expires_at" date not null,
"inviter_id" text not null references "user" ("id") on delete cascade
);
CREATE INDEX IF NOT EXISTS "member_organization_id_idx" ON "member" ("organization_id");
CREATE INDEX IF NOT EXISTS "member_user_id_idx" ON "member" ("user_id");
CREATE INDEX IF NOT EXISTS "invitation_organization_id_idx" ON "invitation" ("organization_id");

View File

@@ -0,0 +1,29 @@
-- ============================================================
-- Fabric audit event sink — the table Pikku Fabric writes AuditEvent
-- records to. This is the framework-level event stream (distinct from
-- any domain-level change history).
-- Columns mirror the AuditEvent shape (camelCase → snake_case for the
-- CamelCasePlugin kysely).
-- ============================================================
CREATE TABLE IF NOT EXISTS audit (
event_id TEXT PRIMARY KEY,
type TEXT NOT NULL,
source TEXT,
outcome TEXT,
occurred_at TEXT NOT NULL DEFAULT (datetime('now')),
function_id TEXT,
wire_type TEXT,
wire_id TEXT,
trace_id TEXT,
transaction_id TEXT,
query_id TEXT,
actor TEXT,
input TEXT,
metadata TEXT
);
CREATE INDEX IF NOT EXISTS idx_audit_type ON audit(type);
CREATE INDEX IF NOT EXISTS idx_audit_occurred ON audit(occurred_at);
CREATE INDEX IF NOT EXISTS idx_audit_function ON audit(function_id);
CREATE INDEX IF NOT EXISTS idx_audit_trace ON audit(trace_id);

View File

@@ -0,0 +1,11 @@
-- =============================================================================
-- BETTER AUTH ACTOR PLUGIN — user-flow actors
-- Adds the `actor` boolean the Better Auth `actor()` plugin declares so its
-- schema is covered by an explicit migration and the db drift check passes.
-- Actor rows are synthetic users signed in by pikkuUserFlow via
-- POST /api/auth/sign-in/actor with the server-held USER_FLOW_ACTOR_SECRET;
-- the flag rides into the pikku core session so audits/analytics can address
-- synthetic traffic. A non-actor user can never be signed in this way.
-- =============================================================================
ALTER TABLE "user" ADD COLUMN "actor" integer NOT NULL DEFAULT 0;

16
db/sqlite/0006-admin.sql Normal file
View File

@@ -0,0 +1,16 @@
-- =============================================================================
-- BETTER AUTH ADMIN PLUGIN — user administration
-- Adds the columns the Better Auth `admin()` plugin declares so its schema is
-- covered by an explicit migration and the db drift check passes. The plugin
-- exposes /api/auth/admin/* (listUsers, setRole, ban, impersonateUser, …);
-- `impersonated_by` marks a session created via "view as user" so it can be
-- reverted with admin.stopImpersonating. All fields are optional — a user with
-- `role` = 'admin' is an administrator; everyone else stays a normal user.
-- =============================================================================
ALTER TABLE "user" ADD COLUMN "role" text;
ALTER TABLE "user" ADD COLUMN "banned" integer DEFAULT 0;
ALTER TABLE "user" ADD COLUMN "ban_reason" text;
ALTER TABLE "user" ADD COLUMN "ban_expires" date;
ALTER TABLE "session" ADD COLUMN "impersonated_by" text;

13
db/sqlite/0007-fabric.sql Normal file
View File

@@ -0,0 +1,13 @@
-- =============================================================================
-- BETTER AUTH FABRIC PLUGIN — Fabric operator admin sessions
-- Adds the `fabric` boolean the Better Auth `fabric()` plugin declares so its
-- schema is covered by an explicit migration and the db drift check passes.
-- Fabric rows are synthetic operator users minted by POST /api/auth/sign-in/fabric
-- after verifying a short-lived RS256 token the Fabric control plane signed
-- (checked against FABRIC_AUTH_PUBLIC_KEY). They are created with role 'admin' so
-- the console Users tab can list/impersonate real end-users WITHOUT the operator
-- being one of them; these rows are filtered out of any end-user listing. A real
-- user can never be signed in this way.
-- =============================================================================
ALTER TABLE "user" ADD COLUMN "fabric" integer NOT NULL DEFAULT 0;

20
e2e/package.json Normal file
View File

@@ -0,0 +1,20 @@
{
"name": "@germantax/e2e",
"version": "0.0.1",
"private": true,
"type": "module",
"description": "End-to-end test harness — Cucumber + Playwright.",
"scripts": {
"test": "E2E_TIMEOUT=90000 E2E_MANAGE_SERVERS=1 E2E_ISOLATED_DB=1 cucumber-js --config tests/cucumber.mjs --tags 'not @skip'",
"test:headed": "HEADED=1 E2E_MANAGE_SERVERS=1 E2E_ISOLATED_DB=1 cucumber-js --config tests/cucumber.mjs --tags 'not @skip'",
"test:tag": "cucumber-js --config tests/cucumber.mjs --tags",
"tsc": "tsc --noEmit"
},
"devDependencies": {
"@cucumber/cucumber": "^11.0.0",
"@playwright/test": "^1.50.0",
"@types/node": "^22",
"tsx": "^4.21.0",
"typescript": "~5.8.0"
}
}

7
e2e/tests/cucumber.mjs Normal file
View File

@@ -0,0 +1,7 @@
export default {
requireModule: ['tsx'],
require: ['tests/support/**/*.ts', 'tests/steps/**/*.ts'],
paths: ['tests/features/**/*.feature'],
format: ['progress', 'html:tests/reports/cucumber-report.html'],
forceExit: true,
}

View File

@@ -0,0 +1,24 @@
Feature: Authentication
Scenario: Redirect unauthenticated user to login
Given I visit "/companies"
Then the URL contains "/login"
Scenario: Successful login redirects to companies
Given I visit "/login"
When I fill "input[type='email']" with "founder@example.com"
And I fill "input[type='password']" with "test1234"
And I click "Sign in"
Then the URL contains "/companies"
Scenario: Wrong password shows an error
Given I visit "/login"
When I fill "input[type='email']" with "founder@example.com"
And I fill "input[type='password']" with "wrongpassword"
And I click "Sign in"
Then I see "Email or password is incorrect"
Scenario: Logged-in user can log out
Given I am logged in as "founder@example.com" with password "test1234"
When I log out
Then I see "Signed out"

View File

@@ -0,0 +1,20 @@
Feature: Companies
Background:
Given I am logged in as "founder@example.com" with password "test1234"
Scenario: Companies page loads with seed data
Given I visit "/companies"
Then I see "Your companies"
And I see "Beispiel GmbH"
Scenario: Navigate to a company detail page
Given I visit "/companies"
When I click "Beispiel GmbH"
Then the URL contains "/companies/"
And I see "Beispiel GmbH"
Scenario: Company detail shows resolutions section
Given I visit "/companies"
When I click "Beispiel GmbH"
Then I see "Resolutions"

View File

@@ -0,0 +1,20 @@
Feature: Resolutions
Background:
Given I am logged in as "founder@example.com" with password "test1234"
Scenario: Open new resolution modal from company page
Given I visit "/companies"
When I click "Beispiel GmbH"
And I click "New resolution"
Then I see "Select template"
Scenario: Create a new resolution and land on its detail page
Given I visit "/companies"
When I click "Beispiel GmbH"
And I click "New resolution"
And I select "Annual budget approval" from "Select template"
And I fill "Title" with "Budget Review 2025"
And I click "Create draft"
Then the URL contains "/resolutions/"
And I see "Budget Review 2025"

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,234 @@
import { Given, When, Then } from '@cucumber/cucumber'
import { expect } from '@playwright/test'
import type { AppWorld } from '../support/world.js'
Given('I visit {string}', async function (this: AppWorld, path: string) {
await this.gotoApp(path)
})
Given(
'I am logged in as {string} with password {string}',
async function (this: AppWorld, email: string, password: string) {
await this.login(email, password)
},
)
When('I log in as {string} with password {string}', async function (
this: AppWorld,
email: string,
password: string,
) {
await this.login(email, password)
})
When('I log out', async function (this: AppWorld) {
await this.logout()
})
const looksLikeCss = (s: string) => /[\[#.>:]/.test(s) || s.startsWith('input')
const escapeRegex = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
async function field(world: AppWorld, name: string) {
if (looksLikeCss(name)) return world.page.locator(name).first()
const byId = world.page.locator(`[data-testid="${name}"]`).first()
try {
await byId.waitFor({ state: 'attached', timeout: 5000 })
const inner = byId.locator('input, textarea, select').first()
if (await inner.count()) return inner
return byId
} catch {
// fall through
}
const byLabel = world.page.getByLabel(name, { exact: false })
if (await byLabel.count()) return byLabel.first()
return world.page.getByPlaceholder(name, { exact: false }).first()
}
async function isVisibleWithin(
target: ReturnType<AppWorld['page']['locator']>,
timeout = 5000,
) {
try {
await target.waitFor({ state: 'visible', timeout })
return true
} catch {
return false
}
}
async function activate(target: ReturnType<AppWorld['page']['locator']>, timeout = 5000) {
try {
await target.click({ timeout })
return
} catch {
await target.evaluate((node) => {
;(node as HTMLElement).click()
})
}
}
async function settleAfterClick(world: AppWorld, previousUrl?: string) {
if (previousUrl) {
await world.page
.waitForURL((url) => url.toString() !== previousUrl, { timeout: 3000 })
.catch(() => {})
}
await world.page.waitForLoadState('networkidle', { timeout: 1000 }).catch(() => {})
await world.page.waitForTimeout(750)
}
async function clickAndConfirmIfNeeded(world: AppWorld, target: ReturnType<AppWorld['page']['locator']>) {
await activate(target)
await settleAfterClick(world)
if (
await isVisibleWithin(
world.page.getByRole('button', { name: /bestätigen\?|confirm\?/i }).first(),
1000,
)
) {
await activate(
world.page.getByRole('button', { name: /bestätigen\?|confirm\?/i }).first(),
1000,
)
await world.page.waitForLoadState('networkidle', { timeout: 1000 }).catch(() => {})
await world.page.waitForTimeout(250)
return
}
if (
await isVisibleWithin(
world.page.getByText(/bestätigen\?|confirm\?/i, { exact: false }).first(),
1000,
)
) {
await activate(
world.page.getByText(/bestätigen\?|confirm\?/i, { exact: false }).first(),
1000,
)
await world.page.waitForLoadState('networkidle', { timeout: 1000 }).catch(() => {})
await world.page.waitForTimeout(250)
}
}
When(
'I fill {string} with {string}',
async function (this: AppWorld, name: string, value: string) {
const target = await field(this, name)
await target.fill('')
await target.fill(value)
},
)
When('I check {string}', async function (this: AppWorld, name: string) {
const target = await field(this, name)
await target.check()
})
When('I uncheck {string}', async function (this: AppWorld, name: string) {
const target = await field(this, name)
await target.uncheck()
})
When(
'I select {string} from {string}',
async function (this: AppWorld, value: string, name: string) {
const target = await field(this, name)
const tag = await target
.evaluate((el) => (el as HTMLElement).tagName.toLowerCase())
.catch(() => '')
if (tag === 'select') {
await target.selectOption({ label: value })
return
}
await target.click()
await this.page.getByRole('option', { name: value, exact: false }).first().click()
},
)
Then(
'in row {string} I do not see {string}',
async function (this: AppWorld, rowText: string, text: string) {
const row = this.page.getByRole('row', { name: new RegExp(rowText) })
await expect(row.getByText(text, { exact: false }).first()).toBeHidden()
},
)
When(
'in row {string} I click {string}',
async function (this: AppWorld, rowText: string, label: string) {
const row = this.page.getByRole('row', { name: new RegExp(rowText) })
const roles = ['button', 'link', 'menuitem'] as const
const previousUrl = this.page.url()
for (const role of roles) {
const candidate = row.getByRole(role, { name: new RegExp(label, 'i') }).first()
if (await isVisibleWithin(candidate)) {
await activate(candidate)
await settleAfterClick(this, previousUrl)
return
}
}
const byText = row.getByText(label, { exact: false }).first()
if (await isVisibleWithin(byText)) {
await activate(byText)
await settleAfterClick(this, previousUrl)
return
}
throw new Error(`Could not click "${label}" inside row "${rowText}"`)
},
)
When('I click {string}', async function (this: AppWorld, label: string) {
const byId = this.page.getByTestId(label).first()
if (await isVisibleWithin(byId)) {
await clickAndConfirmIfNeeded(this, byId)
return
}
const fuzzyName = new RegExp(escapeRegex(label), 'i')
const roles = ['button', 'tab', 'link', 'menuitem'] as const
for (const role of roles) {
const candidate = this.page.getByRole(role, { name: fuzzyName }).first()
if (await isVisibleWithin(candidate)) {
await clickAndConfirmIfNeeded(this, candidate)
return
}
}
const byText = this.page.getByText(label, { exact: false }).first()
if (await isVisibleWithin(byText)) {
await clickAndConfirmIfNeeded(this, byText)
return
}
throw new Error(`Could not click "${label}"`)
})
When('I accept the next confirmation', async function (this: AppWorld) {
this.page.once('dialog', (d) => void d.accept())
})
Then('I see {string}', async function (this: AppWorld, text: string) {
await this.expectText(text)
})
Then(
'the field {string} has value {string}',
async function (this: AppWorld, name: string, value: string) {
const target = await field(this, name)
await expect(target).toHaveValue(value)
},
)
Then('I do not see {string}', async function (this: AppWorld, text: string) {
await expect(
this.page.getByText(text, { exact: false }).first(),
).toBeHidden()
})
Then('the URL contains {string}', async function (this: AppWorld, fragment: string) {
await this.page.waitForURL((url) => url.toString().includes(fragment))
})
Then(
'the URL does not contain {string}',
async function (this: AppWorld, fragment: string) {
await this.page.waitForURL((url) => !url.toString().includes(fragment))
},
)

248
e2e/tests/support/hooks.ts Normal file
View File

@@ -0,0 +1,248 @@
import {
Before,
After,
BeforeAll,
AfterAll,
setDefaultTimeout,
} from '@cucumber/cucumber'
import { cp, mkdir, rm } from 'fs/promises'
import { spawn, execFileSync, type ChildProcess } from 'child_process'
import { resolve, dirname } from 'path'
import { fileURLToPath, pathToFileURL } from 'url'
import { createRequire } from 'module'
import type { AppWorld } from './world.js'
import { config } from './types.js'
setDefaultTimeout(config.responseTimeout)
let backendProcess: ChildProcess | undefined
let frontendProcess: ChildProcess | undefined
let goldenDbPath: string | undefined
let scenarioDbPath: string | undefined
async function waitForUrl(url: string, label: string, timeoutMs = 60_000) {
const deadline = Date.now() + timeoutMs
while (Date.now() < deadline) {
try {
const res = await fetch(url)
if (res.status < 500) return
} catch {
// not ready yet
}
await new Promise((r) => setTimeout(r, 500))
}
throw new Error(`[e2e] ${label} did not become ready at ${url} within ${timeoutMs}ms`)
}
function startProcess({
cmd,
cwd,
env,
label,
detached = true,
}: {
cmd: string
cwd: string
env: NodeJS.ProcessEnv
label: string
detached?: boolean
}) {
const p = spawn(cmd, {
cwd,
env,
stdio: 'pipe',
shell: true,
detached,
})
p.stderr?.on('data', (d: Buffer) => process.stderr.write(`[${label}] ${d}`))
p.stdout?.on('data', (d: Buffer) => process.stdout.write(`[${label}] ${d}`))
return p
}
async function stopProcess(p: ChildProcess | undefined) {
if (!p?.pid) return
try {
process.kill(-p.pid, 'SIGTERM')
} catch {
try {
p.kill('SIGTERM')
} catch {
// ignore
}
}
await new Promise((r) => setTimeout(r, 1000))
}
function killPort(port: number) {
try {
const out = execFileSync('lsof', ['-ti', `tcp:${port}`], {
encoding: 'utf8',
}).trim()
if (!out) return
for (const pid of out.split(/\s+/)) {
if (!pid) continue
try {
process.kill(Number(pid), 'SIGTERM')
} catch {
// ignore
}
}
} catch {
// no listeners / lsof unavailable
}
}
const __dirname = dirname(fileURLToPath(import.meta.url))
const repoRoot = resolve(__dirname, '../../..')
const runtimeDir = resolve(repoRoot, '.e2e-runtime')
const functionsOutDir = resolve(repoRoot, 'packages/functions/.pikku')
const defaultFrontendCmd = 'bun run dev'
// @pikku/cli locks its `exports` to `.` only, so these db test-helpers aren't
// importable by bare specifier. Resolve the package dir via the `.` export and
// dynamic-import the file by absolute path — the exports map only governs bare
// specifiers, not absolute paths. Lazy (not top-level) because cucumber
// transpiles this module to CJS, which bans top-level await.
async function loadDbHelpers() {
const cliBinDir = dirname(createRequire(import.meta.url).resolve('@pikku/cli'))
return import(
pathToFileURL(resolve(cliBinDir, '../src/functions/db/local-db.js')).href
)
}
// Seed inline via node:sqlite rather than the CLI's `seed()`: under cucumber's
// registered ESM loader the CLI's memoized sqlite runtime reads an empty schema;
// a fresh DatabaseSync against the same file sees the tables correctly.
async function seedGoldenDb(dbFile: string, seedFile: string) {
const { readFileSync, existsSync } = await import('fs')
if (!seedFile || !existsSync(seedFile)) return
const { DatabaseSync } = await (new Function('return import("node:sqlite")')() as Promise<{
DatabaseSync: new (f: string) => { exec(sql: string): void; close(): void }
}>)
const db = new DatabaseSync(dbFile)
db.exec('BEGIN')
try {
db.exec(readFileSync(seedFile, 'utf8'))
db.exec('COMMIT')
} catch (err) {
db.exec('ROLLBACK')
throw err
} finally {
db.close()
}
}
async function buildGoldenDb() {
const { resolveLocalDb, reset, migrateAndCodegen } = await loadDbHelpers()
await mkdir(runtimeDir, { recursive: true })
goldenDbPath = resolve(runtimeDir, 'golden.db')
await rm(goldenDbPath, { force: true })
const resolved = resolveLocalDb(goldenDbPath, repoRoot, functionsOutDir, runtimeDir)
if (!resolved) {
throw new Error('[e2e] failed to resolve local DB config')
}
reset(resolved, repoRoot)
await migrateAndCodegen(resolved)
await seedGoldenDb(resolved.dbFile, resolved.seedFile)
}
async function startScenarioBackend(scenarioId: string) {
if (!goldenDbPath) {
throw new Error('[e2e] golden DB missing')
}
await stopProcess(backendProcess)
killPort(4003)
scenarioDbPath = resolve(runtimeDir, `${scenarioId}.db`)
await rm(scenarioDbPath, { force: true })
await cp(goldenDbPath, scenarioDbPath)
backendProcess = startProcess({
cmd: 'bun run dev:backend',
cwd: repoRoot,
env: {
...process.env,
NODE_ENV: 'test',
PIKKU_DEV_DB_FILE: scenarioDbPath,
DATABASE_URL: `file:${scenarioDbPath}`,
},
label: 'backend',
})
await waitForUrl(config.apiUrl, 'backend')
}
BeforeAll(async function () {
process.env.API_URL = process.env.API_URL ?? 'http://localhost:4003'
process.env.APP_URL = process.env.APP_URL ?? 'http://localhost:7104'
if (config.isolatedDb) {
await buildGoldenDb()
}
if (!config.manageServers) {
if (config.isolatedDb) {
throw new Error('[e2e] E2E_ISOLATED_DB=1 requires E2E_MANAGE_SERVERS=1')
}
return
}
const frontendCmd = process.env.E2E_FRONTEND_CMD ?? defaultFrontendCmd
killPort(7104)
frontendProcess = startProcess({
cmd: frontendCmd,
cwd: repoRoot,
env: {
...process.env,
NODE_ENV: 'test',
VITE_API_URL: process.env.API_URL,
},
label: 'frontend',
})
await waitForUrl(config.appUrl, 'frontend')
})
AfterAll(async function () {
await stopProcess(backendProcess)
await stopProcess(frontendProcess)
if (scenarioDbPath) await rm(scenarioDbPath, { force: true }).catch(() => {})
if (goldenDbPath) await rm(goldenDbPath, { force: true }).catch(() => {})
})
Before(async function (this: AppWorld, { pickle }) {
if (config.isolatedDb) {
const scenarioId = pickle.id.replace(/[^a-zA-Z0-9_-]/g, '_')
await startScenarioBackend(`scenario-${scenarioId}`)
}
await this.openBrowser()
if (!config.isolatedDb && config.resetUrl) {
const body = config.resetRpcName
? JSON.stringify({ rpcName: config.resetRpcName, data: {} })
: undefined
await fetch(config.resetUrl, {
method: 'POST',
headers: body ? { 'content-type': 'application/json' } : undefined,
body,
})
.then((res) => {
if (!res.ok) {
process.stderr.write(
`[e2e] WARN: reset hook ${config.resetUrl} returned ${res.status}\n`,
)
}
})
.catch(() => {
process.stderr.write(`[e2e] WARN: reset hook ${config.resetUrl} unreachable\n`)
})
}
})
After(async function (this: AppWorld) {
await this.closeBrowser()
if (config.isolatedDb) {
await stopProcess(backendProcess)
backendProcess = undefined
if (scenarioDbPath) {
await rm(scenarioDbPath, { force: true }).catch(() => {})
scenarioDbPath = undefined
}
}
})

View File

@@ -0,0 +1,38 @@
// Project-level e2e configuration. Override via env vars per environment.
//
// Local defaults:
// apps/app → vite on 7104
// backend → Pikku API on 4003
export const config = {
/** URL of the running frontend (vite dev server or built preview). */
get appUrl() {
return process.env.APP_URL ?? 'http://localhost:7104'
},
/** URL of the running backend (Pikku API). */
get apiUrl() {
return process.env.API_URL ?? 'http://localhost:4003'
},
/** Per-step Playwright timeout (ms). */
get responseTimeout() {
return Number(process.env.E2E_TIMEOUT ?? 30_000)
},
/** Whether the harness should spawn the dev servers itself. */
get manageServers() {
return process.env.E2E_MANAGE_SERVERS === '1'
},
/** Whether the harness should isolate each scenario with a copied golden DB. */
get isolatedDb() {
return process.env.E2E_ISOLATED_DB === '1'
},
/**
* Reset hook URL — POST here to reset the DB to seed state between scenarios.
* Project must implement this as a sessionless RPC gated behind a test-only flag.
*/
get resetUrl() {
return process.env.E2E_RESET_URL
},
get resetRpcName() {
return process.env.E2E_RESET_RPC_NAME
},
} as const

109
e2e/tests/support/world.ts Normal file
View File

@@ -0,0 +1,109 @@
import { World, setWorldConstructor } from '@cucumber/cucumber'
import {
chromium,
type Browser,
type BrowserContext,
type Page,
} from '@playwright/test'
import { config } from './types.js'
export class AppWorld extends World {
browser!: Browser
context!: BrowserContext
page!: Page
async openBrowser() {
const headed = process.env.HEADED === '1' || process.env.HEADED === 'true'
this.browser = await chromium.launch({
headless: !headed,
slowMo: headed ? 150 : 0,
})
this.context = await this.browser.newContext({
locale: process.env.E2E_LOCALE ?? 'de-DE',
})
await this.context.addInitScript((apiUrl) => {
;(window as typeof window & { __E2E_API_URL?: string }).__E2E_API_URL = apiUrl
}, config.apiUrl)
this.page = await this.context.newPage()
this.page.setDefaultTimeout(config.responseTimeout)
}
async closeBrowser() {
await this.context?.close()
await this.browser?.close()
}
async gotoApp(path: string) {
const url = path.startsWith('http')
? path
: `${config.appUrl}${path.startsWith('/') ? path : `/${path}`}`
await this.page.goto(url)
await this.page.waitForFunction(
() => document.documentElement.dataset.appHydrated === 'true',
undefined,
{ timeout: config.responseTimeout },
)
await this.page.waitForLoadState('networkidle', { timeout: 1000 }).catch(() => {})
await this.page.waitForTimeout(500)
}
async login(email: string, password: string) {
// Use Playwright's request API (same cookie jar, no SameSite restrictions)
// so CSRF double-submit works across the auth-js proxy boundary.
const csrfRes = await this.page.request.get(`${config.apiUrl}/auth/csrf`)
const { csrfToken } = (await csrfRes.json()) as { csrfToken: string }
const body = new URLSearchParams({ email, password, csrfToken, callbackUrl: '/' })
await this.page.request.post(`${config.apiUrl}/auth/callback/credentials`, {
headers: { 'content-type': 'application/x-www-form-urlencoded' },
data: body.toString(),
})
// Navigate to the app after auth cookies are set
await this.gotoApp('/companies')
}
async logout() {
await this.gotoApp('/logout')
}
async callRpc(name: string, data: Record<string, unknown> = {}) {
const apiUrl = config.apiUrl
const result = await this.page.evaluate(
async ({ apiUrl, name, data }) => {
const res = await fetch(`${apiUrl}/rpc/${name}`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ data }),
})
if (!res.ok) {
const body = await res.text().catch(() => '')
throw new Error(`RPC ${name} failed (${res.status}): ${body}`)
}
return res.json()
},
{ apiUrl, name, data },
)
return result
}
async expectText(text: string) {
const locator = this.page.getByText(text, { exact: false })
const deadline = Date.now() + config.responseTimeout
while (Date.now() < deadline) {
const count = await locator.count()
for (let i = 0; i < count; i++) {
if (await locator.nth(i).isVisible().catch(() => false)) {
return
}
}
await this.page.waitForTimeout(100)
}
throw new Error(`Timed out waiting for visible text: ${text}`)
}
async getPageText(): Promise<string> {
return this.page.innerText('body')
}
}
setWorldConstructor(AppWorld)

14
e2e/tsconfig.json Normal file
View File

@@ -0,0 +1,14 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"types": ["node"],
"noEmit": true,
"skipLibCheck": true,
"esModuleInterop": true,
"resolveJsonModule": true,
"strict": true
},
"include": ["tests/**/*.ts"]
}

14
emails/locales/de.json Normal file
View File

@@ -0,0 +1,14 @@
{
"common": {
"footer": "Wenn du dieses Konto nicht erstellt hast, kannst du diese E-Mail ignorieren."
},
"confirmEmail": {
"subject": "Bestätige deine E-Mail für {{appName}}",
"preview": "Bestätige deine E-Mail-Adresse, um die Einrichtung deines Kontos abzuschließen.",
"heading": "Bestätige deine E-Mail",
"intro": "Danke für deine Anmeldung bei {{appName}}! Bestätige {{email}}, um die Einrichtung deines Kontos abzuschließen.",
"cta": "E-Mail bestätigen",
"fallback": "Falls der Button nicht funktioniert, kopiere diese URL in deinen Browser:",
"expiry": "Dieser Link läuft in 24 Stunden ab."
}
}

14
emails/locales/en.json Normal file
View File

@@ -0,0 +1,14 @@
{
"common": {
"footer": "If you did not create this account, you can safely ignore this email."
},
"confirmEmail": {
"subject": "Confirm your email for {{appName}}",
"preview": "Confirm your email address to finish setting up your account.",
"heading": "Confirm your email",
"intro": "Thanks for signing up for {{appName}}! Confirm {{email}} to finish setting up your account.",
"cta": "Confirm email",
"fallback": "If the button does not work, copy and paste this URL into your browser:",
"expiry": "This link expires in 24 hours."
}
}

View File

@@ -0,0 +1,3 @@
<p style="margin:32px 0 0;color:{{theme.colors.muted}};font-size:13px;line-height:1.6;">
{{t.common.footer}}
</p>

View File

@@ -0,0 +1,15 @@
<!doctype html>
<html lang="{{locale}}">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>{{subject}}</title>
</head>
<body style="margin:0;padding:0;background:{{theme.colors.canvas}};font-family:{{theme.fonts.body}};">
<div style="padding:32px 16px;">
<div style="max-width:560px;margin:0 auto;background:{{theme.colors.surface}};border:1px solid {{theme.colors.border}};border-radius:24px;padding:32px;">
{{content}}
</div>
</div>
</body>
</html>

View File

@@ -0,0 +1,27 @@
<p style="margin:0 0 12px;color:{{theme.colors.accent}};font-size:12px;font-weight:700;letter-spacing:0.12em;text-transform:uppercase;">
{{appName}}
</p>
<h1 style="margin:0 0 16px;color:{{theme.colors.text}};font-size:28px;line-height:1.1;">
{{t.confirmEmail.heading}}
</h1>
<p style="margin:0 0 24px;color:{{theme.colors.muted}};font-size:16px;line-height:1.6;">
{{t.confirmEmail.intro}}
</p>
<p style="margin:0 0 24px;">
<a
href="{{confirmUrl}}"
style="display:inline-block;background:{{theme.colors.button}};color:{{theme.colors.buttonText}};text-decoration:none;padding:14px 22px;border-radius:999px;font-weight:700;"
>
{{t.confirmEmail.cta}}
</a>
</p>
<p style="margin:0 0 8px;color:{{theme.colors.text}};font-size:14px;line-height:1.6;">
{{t.confirmEmail.fallback}}
</p>
<p style="margin:0;color:{{theme.colors.muted}};font-size:14px;line-height:1.6;word-break:break-all;">
{{confirmUrl}}
</p>
<p style="margin:24px 0 0;color:{{theme.colors.muted}};font-size:13px;line-height:1.6;">
{{t.confirmEmail.expiry}}
</p>
{{> footer}}

View File

@@ -0,0 +1 @@
{{t.confirmEmail.subject}}

View File

@@ -0,0 +1,7 @@
{{t.confirmEmail.heading}}
{{t.confirmEmail.intro}}
{{confirmUrl}}
{{t.confirmEmail.expiry}}

16
emails/theme.json Normal file
View File

@@ -0,0 +1,16 @@
{
"appName": "Germantax",
"fonts": {
"body": "Inter, Arial, sans-serif"
},
"colors": {
"canvas": "#0b1020",
"surface": "#11182d",
"border": "#263252",
"text": "#f7f8fb",
"muted": "#a8b0c5",
"accent": "#7dd3fc",
"button": "#f59e0b",
"buttonText": "#111827"
}
}

16
fabric.config.json Normal file
View File

@@ -0,0 +1,16 @@
{
"projectId": "__PROJECT_ID__",
"frontends": {
"app": {
"cwd": "apps/app",
"kind": "ssr",
"primary": true,
"deploy": true,
"dev": {
"command": ["yarn", "dev"],
"port": 7104,
"healthPath": "/"
}
}
}
}

BIN
germantax-root-0.0.1.tgz Normal file

Binary file not shown.

37
package.json Normal file
View File

@@ -0,0 +1,37 @@
{
"name": "@germantax/root",
"version": "0.0.1",
"private": true,
"workspaces": [
"apps/*",
"packages/*",
"e2e"
],
"scripts": {
"prebuild": "pikku all",
"dev": "bun --filter @germantax/app dev",
"dev:backend": "pikku dev --port 4003",
"db:reset": "pikku db reset",
"db:migrate": "pikku db migrate",
"e2e": "bun --filter @germantax/e2e test",
"e2e:headed": "bun --filter @germantax/e2e test:headed",
"tsc": "bun run --filter '*' tsc",
"lint": "bun --filter '*' run lint"
},
"dependencies": {
"@pikku/addon-console": "^0.12.26",
"@pikku/better-auth": "^0.12.16",
"@pikku/cloudflare": "^0.12.13",
"@pikku/core": "^0.12.57",
"@pikku/kysely-sqlite": "^0.12.8",
"@pikku/schema-cfworker": "^0.12.4"
},
"devDependencies": {
"@pikku/cli": "^0.12.76"
},
"fabric": {
"template": "germantax",
"templateVersion": "0.0.1"
},
"packageManager": "bun@1.3.14"
}

View File

@@ -0,0 +1,17 @@
{
"name": "@germantax/components",
"version": "0.0.1",
"private": true,
"type": "module",
"exports": {
".": "./src/index.ts"
},
"dependencies": {
"@mantine/hooks": "^9.2.1",
"@pikku/mantine": "^0.12.6",
"@pikku/react": "^0.12.5"
},
"peerDependencies": {
"react": "^19.2.5"
}
}

View File

@@ -0,0 +1,2 @@
export { PageHeader } from './page-header.js'
export { StatusBadge } from './status-badge.js'

View File

@@ -0,0 +1,27 @@
import { Title, Text, Group, Stack } from '@pikku/mantine/core'
import { type ReactNode } from 'react'
import type { I18nNode } from '@pikku/react'
export function PageHeader({
title,
description,
action,
}: {
title: I18nNode
description?: I18nNode
action?: ReactNode
}) {
return (
<Group justify="space-between" align="flex-start">
<Stack gap={4}>
<Title order={2}>{title}</Title>
{description && (
<Text size="sm" c="dimmed">
{description}
</Text>
)}
</Stack>
{action}
</Group>
)
}

View File

@@ -0,0 +1,33 @@
import { Badge } from '@pikku/mantine/core'
import { asI18n } from '@pikku/react'
const STATUS_COLORS: Record<string, string> = {
draft: 'gray',
pending: 'yellow',
in_review: 'orange',
under_review: 'orange',
approved: 'blue',
active: 'green',
open: 'green',
filed: 'green',
completed: 'gray',
archived: 'gray',
closed: 'gray',
rejected: 'red',
overdue: 'red',
cancelled: 'red',
}
function formatStatus(status: string): string {
return status.replace(/_/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase())
}
export function StatusBadge({ status }: { status: string }) {
const color = STATUS_COLORS[status] ?? 'gray'
return (
<Badge color={color} variant="light" size="sm">
{asI18n(formatStatus(status))}
</Badge>
)
}

View File

@@ -0,0 +1,19 @@
{
"name": "@germantax/functions-sdk",
"version": "0.0.1",
"private": true,
"type": "module",
"exports": {
"./pikku/api.gen": "./src/pikku/api.gen.ts",
"./pikku/pikku-fetch.gen": "./src/pikku/pikku-fetch.gen.ts",
"./pikku/pikku-rpc.gen": "./src/pikku/pikku-rpc.gen.ts",
"./pikku/rpc-map.gen": "./src/pikku/rpc-map.gen.d.ts",
"./pikku/http-map.gen": "./src/pikku/http-map.gen.d.ts",
"./pikku/*": "./src/pikku/*"
},
"dependencies": {
"@pikku/fetch": "^0.12.6",
"@pikku/react": "^0.12.5",
"@tanstack/react-query": "^5"
}
}

70
packages/functions/db/schema.d.ts vendored Normal file
View File

@@ -0,0 +1,70 @@
// Generated by @pikku/cli — do not edit by hand.
// Run `pikku db migrate` to refresh.
import type { ColumnType } from 'kysely'
export type Generated<T> = T extends ColumnType<infer S, infer I, infer U>
? ColumnType<S, I | undefined, U>
: ColumnType<T, T | undefined, T>
export interface AuditLog {
auditId: Generated<string>
tableName: string
recordId: string
action: "INSERT" | "UPDATE" | "DELETE"
userId: string | null
changedFields: string | null
createdAt: Generated<Date>
}
export interface Company {
companyId: Generated<string>
name: string
registryNo: string | null
addressLine1: string | null
addressLine2: string | null
postcode: string | null
town: string | null
country: string | null
totalShares: Generated<number>
fiscalYearEnd: string | null
createdAt: Generated<Date>
}
export interface CompanyMember {
memberId: Generated<string>
companyId: string
userId: string
role: "managing_director" | "shareholder" | "angel"
shares: number | null
createdAt: Generated<Date>
}
export interface Resolution {
resolutionId: Generated<string>
companyId: string
templateKey: string
title: string
status: Generated<"draft" | "published" | "archived">
fieldValues: Generated<string>
pdfKey: string | null
createdBy: string
createdAt: Generated<Date>
publishedAt: Date | null
}
export interface User {
userId: Generated<string>
email: string
displayName: string
passwordHash: string | null
createdAt: Generated<Date>
}
export interface DB {
auditLog: AuditLog
company: Company
companyMember: CompanyMember
resolution: Resolution
user: User
}

View File

@@ -0,0 +1,26 @@
{
"name": "@germantax/functions",
"version": "0.0.1",
"type": "module",
"private": true,
"imports": {
"#pikku": "./.pikku/pikku-types.gen.ts",
"#pikku/*.gen.js": "./.pikku/*.gen.ts",
"#pikku/*": "./.pikku/*"
},
"dependencies": {
"@cloudflare/workers-types": "^4.20241218.0",
"@pikku/better-auth": "^0.12.16",
"@pikku/browser": "^0.12.43",
"@pikku/cloudflare": "^0.12.13",
"@pikku/core": "^0.12.57",
"@pikku/kysely": "^0.13.0",
"@pikku/kysely-sqlite": "^0.12.8",
"@pikku/schedule": "^0.12.3",
"@pikku/schema-cfworker": "^0.12.4",
"better-auth": "^1.6.19",
"kysely": "^0.29.0",
"puppeteer-core": "22.13.1",
"zod": "^4.3.6"
}
}

View File

@@ -0,0 +1,23 @@
{
"$schema": "https://raw.githubusercontent.com/pikkujs/pikku/refs/heads/main/packages/cli/cli.schema.json",
"rootDir": ".",
"tsconfig": "../../tsconfig.json",
"srcDirectories": ["src"],
"outDir": ".pikku",
"clientFiles": {
"rpcMapDeclarationFile": "../sdk/src/pikku/rpc-map.gen.d.ts",
"httpMapDeclarationFile": "../sdk/src/pikku/http-map.gen.d.ts",
"fetchFile": "../sdk/src/pikku/pikku-fetch.gen.ts",
"rpcWiringsFile": "../sdk/src/pikku/pikku-rpc.gen.ts",
"reactQueryFile": "../sdk/src/pikku/api.gen.ts"
},
"packageMappings": {
".": "@germantax/functions"
},
"schema": {
"supportsImportAttributes": true
},
"scaffold": {
"rpc": "auth"
}
}

View File

@@ -0,0 +1,29 @@
import type {
CoreServices,
CoreSingletonServices,
CoreConfig,
CoreUserSession,
ContentService,
} from "@pikku/core"
import type { Kysely } from "kysely"
import type { BrowserService } from "@pikku/browser"
import type { DB } from "./types/db.types.js"
import type { AuditService } from "./services/audit.service.js"
export interface UserSession extends CoreUserSession {
userId: string
email: string
displayName: string
}
export interface Config extends CoreConfig {}
export interface SingletonServices extends CoreSingletonServices<Config> {
kysely: Kysely<DB>
content: ContentService
browser: BrowserService
}
export interface Services extends CoreServices<SingletonServices> {
audit: AuditService
}

View File

@@ -0,0 +1,11 @@
import { pikkuConfig } from "../.pikku/pikku-types.gen.js"
export const createConfig = pikkuConfig(async () => ({
port: 4003,
dev: {
db: process.env.PIKKU_DEV_DB_FILE
? { file: process.env.PIKKU_DEV_DB_FILE }
: true,
content: true,
},
} as any))

View File

@@ -0,0 +1,19 @@
import { z } from 'zod'
import { pikkuFunc } from '#pikku/pikku-types.gen.js'
export const GetMeOutput = z.object({
userId: z.string(),
email: z.string(),
displayName: z.string(),
})
export const getMe = pikkuFunc({
expose: true,
description: 'Return the current authenticated user session.',
output: GetMeOutput,
func: async (_services, _data, { session }) => ({
userId: session!.userId,
email: session!.email,
displayName: session!.displayName,
}),
})

View File

@@ -0,0 +1,54 @@
import { z } from 'zod'
import { pikkuFunc } from '#pikku/pikku-types.gen.js'
export const CreateCompanyInput = z.object({
name: z.string().min(2),
registryNo: z.string().optional(),
addressLine1: z.string().optional(),
addressLine2: z.string().optional(),
postcode: z.string().optional(),
town: z.string().optional(),
country: z.string().optional(),
totalShares: z.number().int().nonnegative(),
fiscalYearEnd: z.string().optional(),
})
export const CreateCompanyOutput = z.object({ companyId: z.string() })
export const createCompany = pikkuFunc({
expose: true,
description: 'Create a new GmbH and add the current user as managing_director.',
input: CreateCompanyInput,
output: CreateCompanyOutput,
func: async ({ kysely, audit }, data, { session }) => {
const userId = session!.userId
const company = await kysely
.insertInto('company')
.values({
name: data.name,
registryNo: data.registryNo ?? null,
addressLine1: data.addressLine1 ?? null,
addressLine2: data.addressLine2 ?? null,
postcode: data.postcode ?? null,
town: data.town ?? null,
country: data.country ?? null,
totalShares: data.totalShares,
fiscalYearEnd: data.fiscalYearEnd ?? null,
})
.returning(['companyId'])
.executeTakeFirstOrThrow()
await kysely
.insertInto('companyMember')
.values({
companyId: company.companyId,
userId,
role: 'managing_director',
shares: null,
})
.execute()
await audit.onInsert('company', company.companyId, { ...data })
return { companyId: company.companyId }
},
})

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