chore: perauset customer project
This commit is contained in:
305
apps/app/src/components/ai-chat.tsx
Normal file
305
apps/app/src/components/ai-chat.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
205
apps/app/src/components/create-template-form.tsx
Normal file
205
apps/app/src/components/create-template-form.tsx
Normal 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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
30
apps/app/src/components/locale-switcher.tsx
Normal file
30
apps/app/src/components/locale-switcher.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
282
apps/app/src/components/notification-bell.tsx
Normal file
282
apps/app/src/components/notification-bell.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
140
apps/app/src/components/notification-row.tsx
Normal file
140
apps/app/src/components/notification-row.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
419
apps/app/src/components/stays-tabs.tsx
Normal file
419
apps/app/src/components/stays-tabs.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
208
apps/app/src/components/task-actions.tsx
Normal file
208
apps/app/src/components/task-actions.tsx
Normal 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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
65
apps/app/src/components/user-select.tsx
Normal file
65
apps/app/src/components/user-select.tsx
Normal 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>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user