chore: germantax customer project
This commit is contained in:
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;
|
||||
}
|
||||
Reference in New Issue
Block a user