chore: germantax customer project
This commit is contained in:
0
.e2e-runtime/golden.db
Normal file
0
.e2e-runtime/golden.db
Normal file
BIN
.e2e-runtime/scenario-0de759d8-2c5a-44e6-a997-3401935185a4.db
Normal file
BIN
.e2e-runtime/scenario-0de759d8-2c5a-44e6-a997-3401935185a4.db
Normal file
Binary file not shown.
BIN
.e2e-runtime/scenario-3f0e875c-b90a-4c94-958d-b24fdbcd9a52.db
Normal file
BIN
.e2e-runtime/scenario-3f0e875c-b90a-4c94-958d-b24fdbcd9a52.db
Normal file
Binary file not shown.
35
.gitignore
vendored
Normal file
35
.gitignore
vendored
Normal 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
8
.yarnrc.yml
Normal file
@@ -0,0 +1,8 @@
|
||||
approvedGitRepositories:
|
||||
- "**"
|
||||
|
||||
enableScripts: true
|
||||
|
||||
nodeLinker: node-modules
|
||||
|
||||
npmMinimalAgeGate: 0
|
||||
40
README.md
Normal file
40
README.md
Normal 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
10
apps/app/.gitignore
vendored
Normal 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.
|
||||
33
apps/app/.stylelintrc.json
Normal file
33
apps/app/.stylelintrc.json
Normal 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
49
apps/app/README.md
Normal 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
17
apps/app/components.json
Normal 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
49
apps/app/eslint.config.js
Normal 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
16
apps/app/index.html
Normal 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
184
apps/app/messages/de.json
Normal 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
197
apps/app/messages/en.json
Normal 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
63
apps/app/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
5
apps/app/postcss.config.js
Normal file
5
apps/app/postcss.config.js
Normal file
@@ -0,0 +1,5 @@
|
||||
export default {
|
||||
plugins: {
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
7
apps/app/project.inlang/settings.json
Normal file
7
apps/app/project.inlang/settings.json
Normal 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" }
|
||||
}
|
||||
13
apps/app/src/auth.config.ts
Normal file
13
apps/app/src/auth.config.ts
Normal 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
39
apps/app/src/auth.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
163
apps/app/src/auth/AuthLayout.tsx
Normal file
163
apps/app/src/auth/AuthLayout.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
20
apps/app/src/auth/providers/_types.ts
Normal file
20
apps/app/src/auth/providers/_types.ts
Normal 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
|
||||
}
|
||||
18
apps/app/src/auth/providers/credentials.ts
Normal file
18
apps/app/src/auth/providers/credentials.ts
Normal 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'
|
||||
},
|
||||
}
|
||||
87
apps/app/src/auth/session-provider.tsx
Normal file
87
apps/app/src/auth/session-provider.tsx
Normal 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 ?? (() => {})
|
||||
}
|
||||
207
apps/app/src/components/AppLayout.tsx
Normal file
207
apps/app/src/components/AppLayout.tsx
Normal 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
40
apps/app/src/direction.ts
Normal 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
101
apps/app/src/i18n/config.ts
Normal 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
|
||||
}
|
||||
12
apps/app/src/i18n/ident.ts
Normal file
12
apps/app/src/i18n/ident.ts
Normal 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()
|
||||
54
apps/app/src/i18n/messages.ts
Normal file
54
apps/app/src/i18n/messages.ts
Normal 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
41
apps/app/src/lib/auth.ts
Normal 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
21
apps/app/src/lib/env.ts
Normal 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`
|
||||
}
|
||||
78
apps/app/src/pages/__root.tsx
Normal file
78
apps/app/src/pages/__root.tsx
Normal 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
|
||||
}
|
||||
520
apps/app/src/pages/companies/$companyId/index.tsx
Normal file
520
apps/app/src/pages/companies/$companyId/index.tsx
Normal 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>,
|
||||
})
|
||||
@@ -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>,
|
||||
})
|
||||
141
apps/app/src/pages/companies/$companyId/resolutions/new.tsx
Normal file
141
apps/app/src/pages/companies/$companyId/resolutions/new.tsx
Normal 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>,
|
||||
})
|
||||
515
apps/app/src/pages/companies/index.tsx
Normal file
515
apps/app/src/pages/companies/index.tsx
Normal 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,
|
||||
})
|
||||
267
apps/app/src/pages/index.tsx
Normal file
267
apps/app/src/pages/index.tsx
Normal 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,
|
||||
})
|
||||
80
apps/app/src/pages/login.tsx
Normal file
80
apps/app/src/pages/login.tsx
Normal 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,
|
||||
})
|
||||
72
apps/app/src/pages/logout.tsx
Normal file
72
apps/app/src/pages/logout.tsx
Normal 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,
|
||||
})
|
||||
50
apps/app/src/pages/portfolio.tsx
Normal file
50
apps/app/src/pages/portfolio.tsx
Normal 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,
|
||||
})
|
||||
95
apps/app/src/pages/signup.tsx
Normal file
95
apps/app/src/pages/signup.tsx
Normal 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
16
apps/app/src/router.tsx
Normal 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
4
apps/app/src/theme.css
Normal file
@@ -0,0 +1,4 @@
|
||||
html, body, #root {
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
}
|
||||
24
apps/app/tsconfig.json
Normal file
24
apps/app/tsconfig.json
Normal 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
6
apps/app/tsr.config.json
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"routesDirectory": "./src/pages",
|
||||
"generatedRouteTree": "./src/routeTree.gen.ts",
|
||||
"routeFileIgnorePrefix": "-",
|
||||
"quoteStyle": "single"
|
||||
}
|
||||
34
apps/app/vite.config.ts
Normal file
34
apps/app/vite.config.ts
Normal 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'),
|
||||
},
|
||||
},
|
||||
})
|
||||
26
db/annotations.ts
Normal file
26
db/annotations.ts
Normal 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
|
||||
63
db/migrations/0001-init.sql
Normal file
63
db/migrations/0001-init.sql
Normal 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
15
db/seed.sql
Normal 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
10
db/sqlite-seed.sql
Normal 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
63
db/sqlite/0001-init.sql
Normal 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);
|
||||
107
db/sqlite/0002-better-auth.sql
Normal file
107
db/sqlite/0002-better-auth.sql
Normal 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");
|
||||
33
db/sqlite/0003-ba-organization.sql
Normal file
33
db/sqlite/0003-ba-organization.sql
Normal 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");
|
||||
29
db/sqlite/0004-fabric-audit.sql
Normal file
29
db/sqlite/0004-fabric-audit.sql
Normal 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);
|
||||
11
db/sqlite/0005-user-actor.sql
Normal file
11
db/sqlite/0005-user-actor.sql
Normal 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
16
db/sqlite/0006-admin.sql
Normal 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
13
db/sqlite/0007-fabric.sql
Normal 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
20
e2e/package.json
Normal 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
7
e2e/tests/cucumber.mjs
Normal 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,
|
||||
}
|
||||
24
e2e/tests/features/auth.feature
Normal file
24
e2e/tests/features/auth.feature
Normal 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"
|
||||
20
e2e/tests/features/companies.feature
Normal file
20
e2e/tests/features/companies.feature
Normal 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"
|
||||
20
e2e/tests/features/resolutions.feature
Normal file
20
e2e/tests/features/resolutions.feature
Normal 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"
|
||||
50
e2e/tests/reports/cucumber-report.html
Normal file
50
e2e/tests/reports/cucumber-report.html
Normal file
File diff suppressed because one or more lines are too long
234
e2e/tests/steps/common.steps.ts
Normal file
234
e2e/tests/steps/common.steps.ts
Normal 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
248
e2e/tests/support/hooks.ts
Normal 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
|
||||
}
|
||||
}
|
||||
})
|
||||
38
e2e/tests/support/types.ts
Normal file
38
e2e/tests/support/types.ts
Normal 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
109
e2e/tests/support/world.ts
Normal 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
14
e2e/tsconfig.json
Normal 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
14
emails/locales/de.json
Normal 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
14
emails/locales/en.json
Normal 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."
|
||||
}
|
||||
}
|
||||
3
emails/partials/footer.html
Normal file
3
emails/partials/footer.html
Normal 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>
|
||||
15
emails/partials/layout.html
Normal file
15
emails/partials/layout.html
Normal 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>
|
||||
27
emails/templates/confirm-email.html
Normal file
27
emails/templates/confirm-email.html
Normal 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}}
|
||||
1
emails/templates/confirm-email.subject.txt
Normal file
1
emails/templates/confirm-email.subject.txt
Normal file
@@ -0,0 +1 @@
|
||||
{{t.confirmEmail.subject}}
|
||||
7
emails/templates/confirm-email.text.txt
Normal file
7
emails/templates/confirm-email.text.txt
Normal file
@@ -0,0 +1,7 @@
|
||||
{{t.confirmEmail.heading}}
|
||||
|
||||
{{t.confirmEmail.intro}}
|
||||
|
||||
{{confirmUrl}}
|
||||
|
||||
{{t.confirmEmail.expiry}}
|
||||
16
emails/theme.json
Normal file
16
emails/theme.json
Normal 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
16
fabric.config.json
Normal 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": "/"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
germantax-root-0.0.1.tgz
Normal file
BIN
germantax-root-0.0.1.tgz
Normal file
Binary file not shown.
37
package.json
Normal file
37
package.json
Normal 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"
|
||||
}
|
||||
17
packages/components/package.json
Normal file
17
packages/components/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
2
packages/components/src/index.ts
Normal file
2
packages/components/src/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { PageHeader } from './page-header.js'
|
||||
export { StatusBadge } from './status-badge.js'
|
||||
27
packages/components/src/page-header.tsx
Normal file
27
packages/components/src/page-header.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
33
packages/components/src/status-badge.tsx
Normal file
33
packages/components/src/status-badge.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
19
packages/functions-sdk/package.json
Normal file
19
packages/functions-sdk/package.json
Normal 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
70
packages/functions/db/schema.d.ts
vendored
Normal 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
|
||||
}
|
||||
26
packages/functions/package.json
Normal file
26
packages/functions/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
23
packages/functions/pikku.config.json
Normal file
23
packages/functions/pikku.config.json
Normal 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"
|
||||
}
|
||||
}
|
||||
29
packages/functions/src/application-types.d.ts
vendored
Normal file
29
packages/functions/src/application-types.d.ts
vendored
Normal 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
|
||||
}
|
||||
11
packages/functions/src/config.ts
Normal file
11
packages/functions/src/config.ts
Normal 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))
|
||||
19
packages/functions/src/functions/auth/get-me.function.ts
Normal file
19
packages/functions/src/functions/auth/get-me.function.ts
Normal 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,
|
||||
}),
|
||||
})
|
||||
@@ -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
Reference in New Issue
Block a user