chore: perauset customer project

This commit is contained in:
e2e
2026-07-11 08:54:43 +02:00
commit 49d05dfa0b
293 changed files with 28421 additions and 0 deletions

View File

@@ -0,0 +1,305 @@
import { useState, useRef, useEffect } from 'react'
import {
Drawer,
Stack,
Group,
Text,
TextInput,
ActionIcon,
ScrollArea,
Paper,
Avatar,
Loader,
Box,
} from '@pikku/mantine/core'
import { asI18n } from '@pikku/react'
import { m } from '@/i18n/messages'
import { Send, Bot } from 'lucide-react'
import { useSessionUser } from '@/lib/auth-context'
import { usePikkuRPC } from '@pikku/react'
import type { PikkuRPC } from '@perauset/functions-sdk/pikku/pikku-rpc.gen'
interface ChatMessage {
role: 'user' | 'assistant'
content: string
}
interface AIChatDrawerProps {
opened: boolean
onClose: () => void
}
export function AIChatDrawer({ opened, onClose }: AIChatDrawerProps) {
const rpc = usePikkuRPC<PikkuRPC>()
const userName = useSessionUser()?.name ?? ''
const [messages, setMessages] = useState<ChatMessage[]>([])
const [input, setInput] = useState('')
const [loading, setLoading] = useState(false)
const [threadId, setThreadId] = useState<string | null>(null)
const viewportRef = useRef<HTMLDivElement>(null)
const inputRef = useRef<HTMLInputElement>(null)
const userInitial = userName?.[0]?.toUpperCase() ?? 'U'
// Auto-scroll to bottom when messages change
useEffect(() => {
if (viewportRef.current) {
viewportRef.current.scrollTo({
top: viewportRef.current.scrollHeight,
behavior: 'smooth',
})
}
}, [messages, loading])
// Auto-focus input when drawer opens
useEffect(() => {
if (opened) {
setTimeout(() => {
inputRef.current?.focus()
}, 200)
}
}, [opened])
async function sendMessage(message: string) {
if (!message.trim() || loading) return
const userMessage = message.trim()
setInput('')
setLoading(true)
setMessages((prev) => [...prev, { role: 'user', content: userMessage }])
try {
const currentThreadId = threadId || crypto.randomUUID()
if (!threadId) setThreadId(currentThreadId)
// TODO: confirm agent RPC — runs the `perauset-router` agent to completion.
const res = await rpc.agent.run('perauset-router' as any, {
message: userMessage,
threadId: currentThreadId,
resourceId: currentThreadId,
})
const result = res?.result as
| string
| { text?: string; message?: string }
| undefined
const assistantMessage =
typeof result === 'string'
? result
: result?.text || result?.message || JSON.stringify(result)
setMessages((prev) => [
...prev,
{ role: 'assistant', content: assistantMessage },
])
} catch (err) {
const msg = err instanceof Error ? err.message : 'Request failed'
setMessages((prev) => [
...prev,
{ role: 'assistant', content: `Error: ${msg}` },
])
} finally {
setLoading(false)
}
}
function handleKeyDown(e: React.KeyboardEvent<HTMLInputElement>) {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
sendMessage(input)
}
}
return (
<Drawer
opened={opened}
onClose={onClose}
position="right"
size={480}
title={
<Group gap="xs">
<ActionIcon
variant="light"
color="teal"
size="md"
radius="xl"
style={{ pointerEvents: 'none' }}
>
<Bot size={18} />
</ActionIcon>
<Text
fw={700}
size="lg"
style={{ fontFamily: '"Cormorant", serif', color: '#2A7B88' }}
>
{m.ai_chat_title()}
</Text>
</Group>
}
styles={{
content: {
display: 'flex',
flexDirection: 'column',
},
body: {
flex: 1,
display: 'flex',
flexDirection: 'column',
overflow: 'hidden',
padding: 0,
},
}}
>
{/* Messages area */}
<ScrollArea style={{ flex: 1 }} viewportRef={viewportRef} px="md" pt="md">
{messages.length === 0 && (
<Stack align="center" justify="center" gap="sm" py="xl" mt="xl">
<ActionIcon
variant="light"
color="teal"
size={64}
radius="xl"
style={{ pointerEvents: 'none' }}
>
<Bot size={36} />
</ActionIcon>
<Text
size="lg"
fw={600}
c="dimmed"
ta="center"
style={{ fontFamily: '"Cormorant", serif' }}
>
{m.ai_chat_greeting()}
</Text>
<Text size="sm" c="dimmed" ta="center" maw={300}>
{m.ai_chat_subtitle()}
</Text>
</Stack>
)}
<Stack gap="md" pb="md">
{messages.map((msg, i) => (
<Group
key={i}
justify={msg.role === 'user' ? 'flex-end' : 'flex-start'}
align="flex-end"
gap="xs"
wrap="nowrap"
>
{msg.role === 'assistant' && (
<Avatar
size="sm"
color="teal"
radius="xl"
style={{ flexShrink: 0 }}
>
<Bot size={16} />
</Avatar>
)}
<Paper
px="sm"
py="xs"
radius="lg"
style={{
maxWidth: '80%',
backgroundColor: msg.role === 'user' ? '#2A7B88' : '#F5F0E8',
color: msg.role === 'user' ? '#ffffff' : '#3D2B1F',
borderBottomRightRadius: msg.role === 'user' ? 4 : undefined,
borderBottomLeftRadius:
msg.role === 'assistant' ? 4 : undefined,
wordBreak: 'break-word',
}}
>
<Text
size="sm"
style={{
whiteSpace: 'pre-wrap',
lineHeight: 1.5,
}}
>
{asI18n(msg.content)}
</Text>
</Paper>
{msg.role === 'user' && (
<Avatar
size="sm"
color="teal"
radius="xl"
style={{ flexShrink: 0 }}
>
{userInitial}
</Avatar>
)}
</Group>
))}
{/* Typing indicator */}
{loading && (
<Group justify="flex-start" align="flex-end" gap="xs" wrap="nowrap">
<Avatar size="sm" color="teal" radius="xl" style={{ flexShrink: 0 }}>
<Bot size={16} />
</Avatar>
<Paper
px="sm"
py="xs"
radius="lg"
style={{
backgroundColor: '#F5F0E8',
borderBottomLeftRadius: 4,
}}
>
<Group gap={4}>
<Loader size="xs" type="dots" color="teal" />
</Group>
</Paper>
</Group>
)}
</Stack>
</ScrollArea>
{/* Input bar */}
<Box
px="md"
py="sm"
style={{
borderTop: '1px solid rgba(61, 43, 31, 0.08)',
backgroundColor: '#ffffff',
}}
>
<Group gap="xs" wrap="nowrap">
<TextInput
ref={inputRef}
value={input}
onChange={(e) => setInput(e.currentTarget.value)}
onKeyDown={handleKeyDown}
placeholder={m.ai_chat_input_placeholder()}
disabled={loading}
radius="xl"
size="md"
style={{ flex: 1 }}
styles={{
input: {
border: '1px solid rgba(61, 43, 31, 0.15)',
'&:focus': {
borderColor: '#2A7B88',
},
},
}}
/>
<ActionIcon
variant="filled"
color="teal"
size="lg"
radius="xl"
onClick={() => sendMessage(input)}
disabled={!input.trim() || loading}
aria-label={asI18n('Send message')}
>
<Send size={18} />
</ActionIcon>
</Group>
</Box>
</Drawer>
)
}

View File

@@ -0,0 +1,205 @@
import { useState } from 'react'
import {
Card,
Stack,
TextInput,
Textarea,
Select,
Group,
Button,
Switch,
NumberInput,
Collapse,
Title,
} from '@pikku/mantine/core'
import { useDisclosure } from '@mantine/hooks'
import { notifications } from '@mantine/notifications'
import { m } from '@/i18n/messages'
import { useQueryClient } from '@tanstack/react-query'
import { Plus } from 'lucide-react'
import { usePikkuMutation } from '@perauset/functions-sdk/pikku/api.gen'
type Category =
| 'housekeeping'
| 'kitchen'
| 'maintenance'
| 'operations'
| 'retreat'
| 'transport'
const CATEGORIES: { value: Category; label: string }[] = [
{ value: 'housekeeping', label: 'Housekeeping' },
{ value: 'kitchen', label: 'Kitchen' },
{ value: 'maintenance', label: 'Maintenance' },
{ value: 'operations', label: 'Operations' },
{ value: 'retreat', label: 'Retreat' },
{ value: 'transport', label: 'Transport' },
]
export function CreateTemplateForm() {
const queryClient = useQueryClient()
const [opened, { toggle }] = useDisclosure(false)
const [title, setTitle] = useState('')
const [description, setDescription] = useState('')
const [category, setCategory] = useState<Category | null>(null)
const [isClaimable, setIsClaimable] = useState(false)
const [defaultLocation, setDefaultLocation] = useState('')
const [estimatedMinutes, setEstimatedMinutes] = useState<number | string>('')
const [recurrenceCron, setRecurrenceCron] = useState('')
const resetForm = () => {
setTitle('')
setDescription('')
setCategory(null)
setIsClaimable(false)
setDefaultLocation('')
setEstimatedMinutes('')
setRecurrenceCron('')
}
const mutation = usePikkuMutation('createTaskTemplate', {
onSuccess: () => {
notifications.show({
title: m.templates_created(),
message: `"${title}" template has been created.`,
color: 'teal',
})
resetForm()
toggle()
queryClient.invalidateQueries({
predicate: (q) =>
typeof q.queryKey[0] === 'string' &&
(q.queryKey[0] as string).toLowerCase().includes('template'),
})
},
onError: (err: Error) => {
notifications.show({
title: m.common_error(),
message: err.message ?? 'Something went wrong',
color: 'red',
})
},
})
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
if (!title.trim() || !category) {
notifications.show({
title: m.common_validation(),
message: m.templates_title_category_required(),
color: 'orange',
})
return
}
const body = {
title: title.trim(),
category,
...(description.trim() ? { description: description.trim() } : {}),
...(isClaimable ? { isClaimable: true } : {}),
...(defaultLocation.trim() ? { defaultLocation: defaultLocation.trim() } : {}),
...(typeof estimatedMinutes === 'number' && estimatedMinutes > 0
? { estimatedMinutes }
: {}),
...(recurrenceCron.trim() ? { recurrenceCron: recurrenceCron.trim() } : {}),
}
mutation.mutate(body)
}
return (
<>
<Button variant="light" leftSection={<Plus size={16} />} onClick={toggle}>
{opened ? m.templates_hide_form() : m.templates_create()}
</Button>
<Collapse expanded={opened}>
<Card
padding="lg"
radius="md"
style={{
backgroundColor: '#ffffff',
boxShadow:
'0 2px 12px rgba(61, 43, 31, 0.08), 0 1px 4px rgba(61, 43, 31, 0.04)',
maxWidth: 640,
}}
>
<Title
order={4}
mb="md"
style={{ fontFamily: '"Cormorant", serif', color: '#3D2B1F' }}
>
{m.templates_new()}
</Title>
<form onSubmit={handleSubmit}>
<Stack gap="md">
<TextInput
label={m.templates_field_title()}
placeholder={m.templates_field_title_placeholder()}
value={title}
onChange={(e) => setTitle(e.currentTarget.value)}
required
/>
<Textarea
label={m.templates_field_description()}
placeholder={m.templates_field_description_placeholder()}
value={description}
onChange={(e) => setDescription(e.currentTarget.value)}
minRows={2}
/>
<Select
label={m.templates_field_category()}
placeholder={m.templates_field_category_placeholder()}
data={CATEGORIES}
value={category}
onChange={(v) => setCategory(v as Category | null)}
required
/>
<Switch
label={m.templates_field_claimable()}
checked={isClaimable}
onChange={(e) => setIsClaimable(e.currentTarget.checked)}
/>
<TextInput
label={m.templates_field_default_location()}
placeholder={m.templates_field_default_location_placeholder()}
value={defaultLocation}
onChange={(e) => setDefaultLocation(e.currentTarget.value)}
/>
<NumberInput
label={m.templates_field_estimated_minutes()}
placeholder={m.templates_field_estimated_minutes_placeholder()}
min={1}
value={estimatedMinutes}
onChange={setEstimatedMinutes}
/>
<TextInput
label={m.templates_field_recurrence()}
placeholder={m.templates_field_recurrence_placeholder()}
description={m.templates_recurrence_hint()}
value={recurrenceCron}
onChange={(e) => setRecurrenceCron(e.currentTarget.value)}
/>
<Group justify="flex-end" mt="sm">
<Button variant="default" onClick={toggle}>
{m.common_cancel()}
</Button>
<Button type="submit" loading={mutation.isPending}>
{m.templates_create()}
</Button>
</Group>
</Stack>
</form>
</Card>
</Collapse>
</>
)
}

View File

@@ -0,0 +1,30 @@
import { ActionIcon, Tooltip } from '@pikku/mantine/core'
import { asI18n } from '@pikku/react'
import { Languages } from 'lucide-react'
import { useLocale, supportedLocales, type Locale } from '@/i18n/config'
export function LocaleSwitcher() {
const { locale, setLocale } = useLocale()
const toggleLocale = () => {
// Toggle between the supported locales (currently `en` and `de`).
const idx = supportedLocales.indexOf(locale)
const next = supportedLocales[(idx + 1) % supportedLocales.length] as Locale
setLocale(next)
}
return (
<Tooltip label={asI18n(locale.toUpperCase())}>
<ActionIcon
variant="subtle"
color="gray"
size="lg"
radius="xl"
onClick={toggleLocale}
aria-label={asI18n('Switch language')}
>
<Languages size={20} />
</ActionIcon>
</Tooltip>
)
}

View File

@@ -0,0 +1,282 @@
import { useCallback } from 'react'
import {
ActionIcon,
Indicator,
Popover,
Drawer,
Stack,
Text,
Group,
Box,
UnstyledButton,
Anchor,
Divider,
} from '@pikku/mantine/core'
import { useDisclosure, useMediaQuery } from '@mantine/hooks'
import { asI18n } from '@pikku/react'
import { m } from '@/i18n/messages'
import { Bell, BellOff } from 'lucide-react'
import { useNavigate } from '@tanstack/react-router'
import { useQueryClient } from '@tanstack/react-query'
import { usePikkuQuery, usePikkuMutation } from '@perauset/functions-sdk/pikku/api.gen'
interface NotificationItem {
notificationId: string
type: string
title: string
body: string | null
entityType: string | null
entityId: string | null
readAt: string | Date | null
createdAt: string | Date
}
function entityLink(
entityType: string | null,
entityId: string | null,
): string | null {
if (!entityType || !entityId) return null
switch (entityType) {
case 'task':
return '/tasks'
case 'stay':
return '/stays'
case 'boat_trip':
return '/boats'
case 'retreat':
return '/retreats'
default:
return null
}
}
function formatTimestamp(val: string | Date): string {
const d = typeof val === 'string' ? new Date(val) : val
const now = new Date()
const diffMs = now.getTime() - d.getTime()
const diffMin = Math.floor(diffMs / 60000)
if (diffMin < 1) return 'Just now'
if (diffMin < 60) return `${diffMin}m ago`
const diffHr = Math.floor(diffMin / 60)
if (diffHr < 24) return `${diffHr}h ago`
const diffDays = Math.floor(diffHr / 24)
if (diffDays < 7) return `${diffDays}d ago`
return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric' })
}
function NotificationEntry({
n,
onRead,
}: {
n: NotificationItem
onRead: (id: string, link: string | null) => void
}) {
const isRead = !!n.readAt
const link = entityLink(n.entityType, n.entityId)
return (
<UnstyledButton
onClick={() => onRead(n.notificationId, link)}
style={{
display: 'block',
width: '100%',
padding: '10px 12px',
borderRadius: 8,
backgroundColor: isRead ? 'transparent' : 'rgba(42, 123, 136, 0.04)',
transition: 'background-color 0.15s ease',
}}
>
<Group gap="xs" align="flex-start" wrap="nowrap">
<Box
style={{
width: 8,
height: 8,
borderRadius: '50%',
backgroundColor: isRead ? 'transparent' : '#2A7B88',
flexShrink: 0,
marginTop: 6,
}}
/>
<Box style={{ flex: 1, minWidth: 0 }}>
<Group justify="space-between" gap="xs" wrap="nowrap">
<Text size="sm" fw={isRead ? 400 : 600} lineClamp={1}>
{asI18n(n.title)}
</Text>
<Text size="xs" c="dimmed" style={{ flexShrink: 0 }}>
{asI18n(formatTimestamp(n.createdAt))}
</Text>
</Group>
{n.body && (
<Text size="xs" c="dimmed" lineClamp={1} mt={2}>
{asI18n(n.body)}
</Text>
)}
</Box>
</Group>
</UnstyledButton>
)
}
export function NotificationBell() {
const navigate = useNavigate()
const queryClient = useQueryClient()
const isMobile = useMediaQuery('(max-width: 768px)')
const [popoverOpened, { toggle: togglePopover, close: closePopover }] =
useDisclosure()
const [drawerOpened, { toggle: toggleDrawer, close: closeDrawer }] =
useDisclosure()
const { data } = usePikkuQuery(
'listNotifications',
{ limit: 5, offset: 0 },
{ refetchInterval: 30_000 },
)
const items = (data?.items ?? []) as NotificationItem[]
const unreadCount = items.filter((n) => !n.readAt).length
const markReadMutation = usePikkuMutation('markNotificationRead', {
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['listNotifications'] })
},
})
const handleRead = useCallback(
(notificationId: string, link: string | null) => {
// Optimistic update
queryClient.setQueryData<{ items?: NotificationItem[] }>(
['listNotifications', { limit: 5, offset: 0 }],
(old) => {
if (!old?.items) return old
return {
...old,
items: old.items.map((n) =>
n.notificationId === notificationId
? { ...n, readAt: new Date().toISOString() }
: n,
),
}
},
)
markReadMutation.mutate({ notificationId })
if (link) {
closePopover()
closeDrawer()
navigate({ to: link })
}
},
[queryClient, markReadMutation, closePopover, closeDrawer, navigate],
)
const handleToggle = () => {
if (isMobile) {
toggleDrawer()
} else {
togglePopover()
}
}
const bellButton = (
<Indicator
size={16}
label={unreadCount > 0 ? asI18n(String(unreadCount)) : undefined}
disabled={unreadCount === 0}
color="red"
offset={4}
processing={unreadCount > 0}
>
<ActionIcon
variant="subtle"
color="gray"
size="lg"
onClick={handleToggle}
aria-label={asI18n('Notifications')}
>
<Bell size={20} />
</ActionIcon>
</Indicator>
)
const content = (
<Stack gap={0}>
<Group justify="space-between" px="xs" py="xs">
<Text fw={600} size="sm" style={{ fontFamily: '"Cormorant", serif' }}>
{m.notifications_title()}
</Text>
{unreadCount > 0 && (
<Text size="xs" c="teal" fw={500}>
{m.notifications_unread_count({ count: unreadCount })}
</Text>
)}
</Group>
<Divider />
{items.length === 0 ? (
<Stack align="center" gap="xs" py="lg">
<BellOff size={28} color="#aaa" />
<Text size="sm" c="dimmed">
{m.notifications_empty()}
</Text>
</Stack>
) : (
<Stack gap={2} p={4}>
{items.map((n) => (
<NotificationEntry key={n.notificationId} n={n} onRead={handleRead} />
))}
</Stack>
)}
<Divider />
<Box py="xs" px="xs" style={{ textAlign: 'center' }}>
<Anchor
component="button"
type="button"
size="sm"
onClick={() => {
closePopover()
closeDrawer()
navigate({ to: '/notifications' })
}}
>
{m.notifications_view_all()}
</Anchor>
</Box>
</Stack>
)
if (isMobile) {
return (
<>
{bellButton}
<Drawer
opened={drawerOpened}
onClose={closeDrawer}
position="bottom"
size="60%"
title={undefined}
styles={{
content: { borderTopLeftRadius: 16, borderTopRightRadius: 16 },
}}
>
{content}
</Drawer>
</>
)
}
return (
<Popover
opened={popoverOpened}
onChange={(o) => {
if (!o) closePopover()
}}
width={360}
position="bottom-end"
shadow="lg"
radius="md"
>
<Popover.Target>{bellButton}</Popover.Target>
<Popover.Dropdown p={0}>{content}</Popover.Dropdown>
</Popover>
)
}

View File

@@ -0,0 +1,140 @@
import { Group, Text, Box, UnstyledButton } from '@pikku/mantine/core'
import { useNavigate } from '@tanstack/react-router'
import { asI18n } from '@pikku/react'
import { Bell, Check } from 'lucide-react'
import { useQueryClient } from '@tanstack/react-query'
import { usePikkuMutation } from '@perauset/functions-sdk/pikku/api.gen'
interface NotificationItem {
notificationId: string
type: string
title: string
body: string | null
entityType: string | null
entityId: string | null
readAt: string | Date | null
createdAt: string | Date
}
function entityLink(
entityType: string | null,
entityId: string | null,
): string | null {
if (!entityType || !entityId) return null
switch (entityType) {
case 'task':
return '/tasks'
case 'stay':
return '/stays'
case 'boat_trip':
return '/boats'
case 'retreat':
return '/retreats'
default:
return null
}
}
function formatTimestamp(val: string | Date): string {
const d = typeof val === 'string' ? new Date(val) : val
const now = new Date()
const diffMs = now.getTime() - d.getTime()
const diffMin = Math.floor(diffMs / 60000)
if (diffMin < 1) return 'Just now'
if (diffMin < 60) return `${diffMin}m ago`
const diffHr = Math.floor(diffMin / 60)
if (diffHr < 24) return `${diffHr}h ago`
const diffDays = Math.floor(diffHr / 24)
if (diffDays < 7) return `${diffDays}d ago`
return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric' })
}
export function NotificationRow({
notification,
}: {
notification: NotificationItem
}) {
const navigate = useNavigate()
const queryClient = useQueryClient()
const isRead = !!notification.readAt
const markReadMutation = usePikkuMutation('markNotificationRead', {
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['listNotifications'] })
},
})
const handleClick = () => {
if (!isRead) {
markReadMutation.mutate({ notificationId: notification.notificationId })
}
const link = entityLink(notification.entityType, notification.entityId)
if (link) {
navigate({ to: link })
}
}
return (
<UnstyledButton
onClick={handleClick}
style={{
display: 'block',
width: '100%',
padding: '12px 16px',
borderBottom: '1px solid rgba(61, 43, 31, 0.06)',
backgroundColor: isRead ? 'transparent' : 'rgba(42, 123, 136, 0.04)',
transition: 'background-color 0.15s ease',
}}
>
<Group gap="sm" align="flex-start" wrap="nowrap">
<Box
style={{
width: 32,
height: 32,
borderRadius: '50%',
backgroundColor: isRead
? 'rgba(61, 43, 31, 0.06)'
: 'rgba(42, 123, 136, 0.1)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
flexShrink: 0,
marginTop: 2,
}}
>
{isRead ? (
<Check size={16} color="#aaa" />
) : (
<Bell size={16} color="#2A7B88" />
)}
</Box>
<Box style={{ flex: 1, minWidth: 0 }}>
<Group justify="space-between" gap="xs" wrap="nowrap">
<Text
size="sm"
fw={isRead ? 400 : 600}
lineClamp={1}
style={{ color: '#3D2B1F' }}
>
{asI18n(notification.title)}
</Text>
<Text
size="xs"
c="dimmed"
style={{ flexShrink: 0, whiteSpace: 'nowrap' }}
>
{asI18n(formatTimestamp(notification.createdAt))}
</Text>
</Group>
{notification.body && (
<Text size="xs" c="dimmed" lineClamp={2} mt={2}>
{asI18n(notification.body)}
</Text>
)}
</Box>
</Group>
</UnstyledButton>
)
}

View File

@@ -0,0 +1,419 @@
import { useState } from 'react'
import { Tabs, Text, Group, ActionIcon, Tooltip } from '@pikku/mantine/core'
import { Check, X, LogIn, LogOut, Ban } from 'lucide-react'
import { useQueryClient } from '@tanstack/react-query'
import { notifications } from '@mantine/notifications'
import { usePikkuMutation } from '@perauset/functions-sdk/pikku/api.gen'
import { DataTable, type Column } from '@perauset/components'
import { StatusBadge } from '@perauset/components'
import { usePermission } from '@/lib/permissions'
import { m } from '@/i18n/messages'
import { asI18n } from '@pikku/react'
interface Stay {
stayId: string
userId: string
userDisplayName?: string | null
stayType: string
startAt: string | Date
endAt: string | Date
status: string
}
interface StayRequest {
requestId: string
userId: string
userDisplayName?: string | null
requestType: string
requestedStartAt: string | Date
requestedEndAt: string | Date
status: string
notes: string | null
}
function formatDate(dateStr: string | Date): string {
try {
return new Date(dateStr).toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric',
})
} catch {
return String(dateStr)
}
}
function formatType(type: string): string {
return type.replace(/_/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase())
}
function StayRequestActions({ requestId }: { requestId: string }) {
const queryClient = useQueryClient()
const approveMutation = usePikkuMutation('approveStayRequest', {
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['listStayRequests'] })
queryClient.invalidateQueries({ queryKey: ['listStays'] })
notifications.show({
title: m.stays_request_approved(),
message: m.stays_request_approved_message(),
color: 'green',
})
},
onError: (err) => {
notifications.show({
title: m.common_error(),
message: err.message ? asI18n(err.message) : m.stays_approve_failed(),
color: 'red',
})
},
})
const rejectMutation = usePikkuMutation('rejectStayRequest', {
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['listStayRequests'] })
queryClient.invalidateQueries({ queryKey: ['listStays'] })
notifications.show({
title: m.stays_request_rejected(),
message: m.stays_request_rejected_message(),
color: 'orange',
})
},
onError: (err) => {
notifications.show({
title: m.common_error(),
message: err.message ? asI18n(err.message) : m.stays_reject_failed(),
color: 'red',
})
},
})
return (
<Group gap="xs">
<ActionIcon
variant="light"
color="green"
size="sm"
onClick={() => approveMutation.mutate({ requestId })}
loading={approveMutation.isPending}
aria-label={m.stays_approve()}
>
<Check size={14} />
</ActionIcon>
<ActionIcon
variant="light"
color="red"
size="sm"
onClick={() => rejectMutation.mutate({ requestId, reviewNotes: '' })}
loading={rejectMutation.isPending}
aria-label={m.stays_reject()}
>
<X size={14} />
</ActionIcon>
</Group>
)
}
function StayActions({ stayId, status }: { stayId: string; status: string }) {
const queryClient = useQueryClient()
const checkInMutation = usePikkuMutation('checkInStay', {
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['listStays'] })
notifications.show({
title: m.stays_checked_in(),
message: m.stays_checked_in_message(),
color: 'green',
})
},
onError: (err) => {
notifications.show({
title: m.common_error(),
message: err.message ? asI18n(err.message) : m.stays_check_in_failed(),
color: 'red',
})
},
})
const checkOutMutation = usePikkuMutation('checkOutStay', {
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['listStays'] })
notifications.show({
title: m.stays_checked_out(),
message: m.stays_checked_out_message(),
color: 'green',
})
},
onError: (err) => {
notifications.show({
title: m.common_error(),
message: err.message ? asI18n(err.message) : m.stays_check_out_failed(),
color: 'red',
})
},
})
const cancelMutation = usePikkuMutation('cancelStay', {
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['listStays'] })
notifications.show({
title: m.stays_stay_cancelled(),
message: m.stays_stay_cancelled_message(),
color: 'orange',
})
},
onError: (err) => {
notifications.show({
title: m.common_error(),
message: err.message ? asI18n(err.message) : m.stays_cancel_failed(),
color: 'red',
})
},
})
const canCheckIn = status === 'approved' || status === 'confirmed'
const canCheckOut = status === 'checked_in'
const canCancel = status === 'approved' || status === 'confirmed' || status === 'checked_in'
if (!canCheckIn && !canCheckOut && !canCancel) return null
return (
<Group gap="xs">
{canCheckIn && (
<Tooltip label={m.stays_check_in()}>
<ActionIcon
variant="light"
color="teal"
size="sm"
onClick={() => checkInMutation.mutate({ stayId })}
loading={checkInMutation.isPending}
>
<LogIn size={14} />
</ActionIcon>
</Tooltip>
)}
{canCheckOut && (
<Tooltip label={m.stays_check_out()}>
<ActionIcon
variant="light"
color="blue"
size="sm"
onClick={() => checkOutMutation.mutate({ stayId })}
loading={checkOutMutation.isPending}
>
<LogOut size={14} />
</ActionIcon>
</Tooltip>
)}
{canCancel && (
<Tooltip label={m.stays_cancel_stay()}>
<ActionIcon
variant="light"
color="red"
size="sm"
onClick={() => cancelMutation.mutate({ stayId })}
loading={cancelMutation.isPending}
>
<Ban size={14} />
</ActionIcon>
</Tooltip>
)}
</Group>
)
}
export function StaysTabs({
stays,
stayRequests,
}: {
stays: Stay[]
stayRequests: StayRequest[]
}) {
const [activeTab, setActiveTab] = useState(() => {
if (typeof window !== 'undefined') {
const hash = window.location.hash.slice(1)
if (['active', 'checked_in', 'requests'].includes(hash)) return hash
}
return 'active'
})
const canReview = usePermission('stays.review')
const canManage = usePermission('stays.manage')
const stayColumns: Column<Stay>[] = [
{
key: 'userId',
label: m.stays_user(),
render: (row) => (
<Text size="sm" truncate>
{asI18n(row.userDisplayName || 'Guest')}
</Text>
),
},
{
key: 'stayType',
label: m.stays_type(),
render: (row) => <Text size="sm">{asI18n(formatType(row.stayType))}</Text>,
},
{
key: 'dates',
label: m.stays_dates(),
render: (row) => (
<Text size="sm">
{asI18n(`${formatDate(row.startAt)} - ${formatDate(row.endAt)}`)}
</Text>
),
},
{
key: 'status',
label: m.stays_status(),
render: (row) => <StatusBadge status={row.status} />,
},
...(canManage
? [
{
key: 'actions',
label: m.stays_actions(),
render: (row: Stay) => (
<StayActions stayId={row.stayId} status={row.status} />
),
},
]
: []),
]
const requestColumns: Column<StayRequest>[] = [
{
key: 'userId',
label: m.stays_user(),
render: (row) => (
<Text size="sm" truncate>
{asI18n(row.userDisplayName || 'Guest')}
</Text>
),
},
{
key: 'requestType',
label: m.stays_type(),
render: (row) => <Text size="sm">{asI18n(formatType(row.requestType))}</Text>,
},
{
key: 'dates',
label: m.stays_requested_dates(),
render: (row) => (
<Text size="sm">
{asI18n(
`${formatDate(row.requestedStartAt)} - ${formatDate(row.requestedEndAt)}`,
)}
</Text>
),
},
{
key: 'status',
label: m.stays_status(),
render: (row) => <StatusBadge status={row.status} />,
},
...(canReview
? [
{
key: 'actions',
label: m.stays_actions(),
render: (row: StayRequest) =>
row.status === 'submitted' || row.status === 'under_review' ? (
<StayRequestActions requestId={row.requestId} />
) : null,
},
]
: []),
]
const checkedInStays = stays.filter((s) => s.status === 'checked_in')
const checkedInColumns: Column<Stay>[] = [
{
key: 'userId',
label: m.stays_guest(),
render: (row) => (
<Text size="sm" truncate>
{asI18n(row.userDisplayName || 'Guest')}
</Text>
),
},
{
key: 'stayType',
label: m.stays_type(),
render: (row) => <Text size="sm">{asI18n(formatType(row.stayType))}</Text>,
},
{
key: 'startAt',
label: m.stays_check_in_date(),
render: (row) => <Text size="sm">{asI18n(formatDate(row.startAt))}</Text>,
},
{
key: 'endAt',
label: m.stays_expected_checkout(),
render: (row) => <Text size="sm">{asI18n(formatDate(row.endAt))}</Text>,
},
{
key: 'status',
label: m.stays_status(),
render: (row) => <StatusBadge status={row.status} />,
},
...(canManage
? [
{
key: 'actions',
label: m.stays_actions(),
render: (row: Stay) => (
<StayActions stayId={row.stayId} status={row.status} />
),
},
]
: []),
]
return (
<Tabs
value={activeTab}
onChange={(val) => {
setActiveTab(val || 'active')
window.history.replaceState(null, '', `#${val || 'active'}`)
}}
>
<Tabs.List>
<Tabs.Tab value="active">{m.stays_active_stays()}</Tabs.Tab>
<Tabs.Tab value="checked_in">
{asI18n(`${m.stays_checked_in()} (${checkedInStays.length})`)}
</Tabs.Tab>
<Tabs.Tab value="requests">{m.stays_requests()}</Tabs.Tab>
</Tabs.List>
<Tabs.Panel value="active" pt="md">
<DataTable
data={stays}
columns={stayColumns}
rowKey={(row) => row.stayId}
emptyMessage={m.stays_no_active_stays()}
/>
</Tabs.Panel>
<Tabs.Panel value="checked_in" pt="md">
<DataTable
data={checkedInStays}
columns={checkedInColumns}
rowKey={(row) => row.stayId}
emptyMessage={m.stays_no_checked_in()}
/>
</Tabs.Panel>
<Tabs.Panel value="requests" pt="md">
<DataTable
data={stayRequests}
columns={requestColumns}
rowKey={(row) => row.requestId}
emptyMessage={m.stays_no_requests()}
/>
</Tabs.Panel>
</Tabs>
)
}

View File

@@ -0,0 +1,208 @@
import { useState } from 'react'
import { Button, Group, Modal, Textarea } from '@pikku/mantine/core'
import { useDisclosure } from '@mantine/hooks'
import { notifications } from '@mantine/notifications'
import { m } from '@/i18n/messages'
import { useQueryClient } from '@tanstack/react-query'
import { usePikkuMutation } from '@perauset/functions-sdk/pikku/api.gen'
interface TaskActionsProps {
taskId: string
status: string
}
export function TaskActions({ taskId, status }: TaskActionsProps) {
const queryClient = useQueryClient()
const [blockOpened, { open: openBlock, close: closeBlock }] = useDisclosure()
const [blockReason, setBlockReason] = useState('')
const [completeOpened, { open: openComplete, close: closeComplete }] =
useDisclosure()
const [completeNotes, setCompleteNotes] = useState('')
// Tasks appear under several listing queries (listTasks, listMyTasks, …);
// invalidate any task-related query after a mutation.
const invalidateTasks = () => {
queryClient.invalidateQueries({
predicate: (q) =>
typeof q.queryKey[0] === 'string' &&
(q.queryKey[0] as string).toLowerCase().includes('task'),
})
}
const onError = (err: Error) => {
notifications.show({
title: m.common_error(),
message: err.message ?? 'Something went wrong',
color: 'red',
})
}
const claimMutation = usePikkuMutation('claimTask', {
onSuccess: () => {
notifications.show({
title: m.common_success(),
message: m.tasks_claimed(),
color: 'teal',
})
invalidateTasks()
},
onError,
})
const startMutation = usePikkuMutation('startTask', {
onSuccess: () => {
notifications.show({
title: m.common_success(),
message: m.tasks_started(),
color: 'teal',
})
invalidateTasks()
},
onError,
})
const completeMutation = usePikkuMutation('completeTask', {
onSuccess: () => {
notifications.show({
title: m.common_success(),
message: m.tasks_completed(),
color: 'teal',
})
closeComplete()
setCompleteNotes('')
invalidateTasks()
},
onError,
})
const blockMutation = usePikkuMutation('markTaskBlocked', {
onSuccess: () => {
notifications.show({
title: m.common_success(),
message: m.tasks_blocked(),
color: 'teal',
})
closeBlock()
setBlockReason('')
invalidateTasks()
},
onError,
})
return (
<>
<Group gap="xs" wrap="nowrap">
{status === 'open' && (
<Button
size="xs"
variant="filled"
color="teal"
loading={claimMutation.isPending}
onClick={() => claimMutation.mutate({ taskId })}
>
{m.tasks_claim()}
</Button>
)}
{status === 'assigned' && (
<Button
size="xs"
variant="filled"
color="blue"
loading={startMutation.isPending}
onClick={() => startMutation.mutate({ taskId })}
>
{m.tasks_start()}
</Button>
)}
{status === 'in_progress' && (
<Button
size="xs"
variant="filled"
color="green"
loading={completeMutation.isPending}
onClick={openComplete}
>
{m.tasks_complete()}
</Button>
)}
{(status === 'open' ||
status === 'assigned' ||
status === 'in_progress') && (
<Button
size="xs"
variant="light"
color="red"
loading={blockMutation.isPending}
onClick={openBlock}
>
{m.tasks_block()}
</Button>
)}
</Group>
<Modal
opened={blockOpened}
onClose={closeBlock}
title={m.tasks_block_title()}
centered
>
<Textarea
label={m.tasks_reason()}
placeholder={m.tasks_reason_placeholder()}
value={blockReason}
onChange={(e) => setBlockReason(e.currentTarget.value)}
minRows={3}
mb="md"
/>
<Group justify="flex-end">
<Button variant="default" onClick={closeBlock}>
{m.common_cancel()}
</Button>
<Button
color="red"
loading={blockMutation.isPending}
onClick={() => {
if (blockReason.trim())
blockMutation.mutate({ taskId, reason: blockReason })
}}
>
{m.tasks_mark_blocked()}
</Button>
</Group>
</Modal>
<Modal
opened={completeOpened}
onClose={closeComplete}
title={m.tasks_complete_title()}
centered
>
<Textarea
label={m.tasks_notes_optional()}
placeholder={m.tasks_notes_placeholder()}
value={completeNotes}
onChange={(e) => setCompleteNotes(e.currentTarget.value)}
minRows={3}
mb="md"
/>
<Group justify="flex-end">
<Button variant="default" onClick={closeComplete}>
{m.common_cancel()}
</Button>
<Button
color="green"
loading={completeMutation.isPending}
onClick={() =>
completeMutation.mutate({
taskId,
...(completeNotes ? { notes: completeNotes } : {}),
})
}
>
{m.tasks_complete()}
</Button>
</Group>
</Modal>
</>
)
}

View File

@@ -0,0 +1,65 @@
import { Select, Group, Avatar, Text } from '@pikku/mantine/core'
import { asI18n } from '@pikku/react'
import { m } from '@/i18n/messages'
import { usePikkuQuery } from '@perauset/functions-sdk/pikku/api.gen'
interface UserSelectProps {
label?: string
placeholder?: string
value: string | null
onChange: (value: string | null) => void
required?: boolean
searchable?: boolean
clearable?: boolean
}
export function UserSelect({
label,
placeholder,
value,
onChange,
required,
searchable = true,
clearable,
}: UserSelectProps) {
const { data } = usePikkuQuery('listUsers', { limit: 200, offset: 0 })
const users = data?.items ?? []
const options = users.map((u) => ({
value: u.userId,
label: u.displayName ?? u.name ?? u.email,
}))
return (
<Select
label={label ? asI18n(label) : m.user_select_label()}
placeholder={placeholder ? asI18n(placeholder) : undefined}
data={options}
value={value}
onChange={onChange}
required={required}
searchable={searchable}
clearable={clearable}
renderOption={({ option }) => {
const user = users.find((u) => u.userId === option.value)
if (!user) return <Text size="sm">{asI18n(option.label)}</Text>
return (
<Group gap="sm" wrap="nowrap">
<Avatar src={user.avatarUrl} size="sm" radius="xl" color="teal">
{(user.displayName?.[0] || user.name?.[0] || user.email[0]).toUpperCase()}
</Avatar>
<div>
<Text size="sm" fw={500}>
{asI18n(option.label)}
</Text>
<Text size="xs" c="dimmed">
{asI18n(user.email)}
</Text>
</div>
</Group>
)
}}
/>
)
}