Files
perauset-e2e-mrfzptpr/apps/app/src/pages/_authenticated.tasks.index.tsx
2026-07-11 08:35:17 +02:00

578 lines
18 KiB
TypeScript

import { createFileRoute, Link } from '@tanstack/react-router'
import {
Title,
Text,
Card,
Group,
Stack,
Badge,
Button,
SimpleGrid,
Accordion,
Anchor,
Box,
Drawer,
Select,
TextInput,
Textarea,
} from '@pikku/mantine/core'
import { MapPin, Clock, Plus, Repeat, ShieldCheck } from 'lucide-react'
import { useQueryClient } from '@tanstack/react-query'
import { useDisclosure } from '@mantine/hooks'
import { notifications } from '@mantine/notifications'
import { useState } from 'react'
import { asI18n } from '@pikku/react'
import { usePikkuQuery, usePikkuMutation } from '@perauset/functions-sdk/pikku/api.gen'
import { useLocale } from '@/i18n/config'
import { m } from '@/i18n/messages'
import { TaskActions } from '@/components/task-actions'
export const Route = createFileRoute('/_authenticated/tasks/')({
component: TasksPage,
})
const STATUS_CONFIG: Record<string, { color: string; order: number }> = {
open: { color: 'teal', order: 0 },
assigned: { color: 'blue', order: 1 },
in_progress: { color: 'orange', order: 2 },
blocked: { color: 'red', order: 3 },
planned: { color: 'gray', order: 4 },
completed: { color: 'green', order: 5 },
cancelled: { color: 'gray', order: 6 },
}
const CATEGORY_COLORS: Record<string, string> = {
housekeeping: 'pink',
kitchen: 'yellow',
maintenance: 'orange',
operations: 'blue',
retreat: 'violet',
transport: 'cyan',
}
const KANBAN_STATUSES = ['open', 'assigned', 'in_progress', 'blocked']
const CATEGORIES = [
{ value: 'housekeeping', label: m.tasks_category_housekeeping() },
{ value: 'kitchen', label: m.tasks_category_kitchen() },
{ value: 'maintenance', label: m.tasks_category_maintenance() },
{ value: 'operations', label: m.tasks_category_operations() },
{ value: 'retreat', label: m.tasks_category_retreat() },
{ value: 'transport', label: m.tasks_category_transport() },
]
const ROLE_OPTIONS = [
{ value: 'admin', label: m.tasks_role_admin() },
{ value: 'coordinator', label: m.tasks_role_coordinator() },
{ value: 'facilitator', label: m.tasks_role_facilitator() },
{ value: 'boat_coordinator', label: m.tasks_role_boat_coordinator() },
{ value: 'kitchen_lead', label: m.tasks_role_kitchen_lead() },
{ value: 'staff', label: m.tasks_role_staff() },
{ value: 'volunteer', label: m.tasks_role_volunteer() },
{ value: 'community_member', label: m.tasks_role_community_member() },
]
const STATUS_LABEL: Record<string, () => string> = {
open: () => m.tasks_status_open(),
assigned: () => m.tasks_status_assigned(),
in_progress: () => m.tasks_status_in_progress(),
blocked: () => m.tasks_status_blocked(),
planned: () => m.tasks_status_planned(),
completed: () => m.tasks_status_completed(),
cancelled: () => m.tasks_status_cancelled(),
}
interface TaskItem {
taskId: string
templateId: string | null
title: string
description: string | null
category: string
status: string
location: string | null
scheduledStartAt: string | Date | null
scheduledEndAt: string | Date | null
assignedRole: string | null
createdByUserId: string
}
function formatTime(val: string | Date | null): string | null {
if (!val) return null
const d = typeof val === 'string' ? new Date(val) : val
return d.toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
hour: 'numeric',
minute: '2-digit',
})
}
function statusLabel(status: string): string {
return STATUS_LABEL[status]?.() ?? status
}
function TaskCard({ task, onAssignRole }: { task: TaskItem; onAssignRole: (taskId: string) => void }) {
const cfg = STATUS_CONFIG[task.status] ?? { color: 'gray', order: 99 }
return (
<Card
padding="md"
radius="md"
className="card-hover"
style={{
backgroundColor: '#ffffff',
boxShadow: '0 2px 12px rgba(61, 43, 31, 0.08), 0 1px 4px rgba(61, 43, 31, 0.04)',
transition: 'transform 0.15s ease, box-shadow 0.15s ease',
}}
>
<Stack gap="xs">
<Group justify="space-between" align="flex-start" wrap="nowrap">
<Text
fw={600}
size="sm"
lineClamp={2}
style={{ fontFamily: '"Cormorant", serif', fontSize: 16 }}
>
{asI18n(task.title)}
</Text>
<Badge size="xs" color={cfg.color} variant="light" style={{ flexShrink: 0 }}>
{asI18n(statusLabel(task.status))}
</Badge>
</Group>
<Group gap="xs">
<Badge size="xs" variant="dot" color={CATEGORY_COLORS[task.category] ?? 'gray'}>
{asI18n(task.category)}
</Badge>
{task.assignedRole && (
<Badge
size="xs"
variant="light"
color="grape"
leftSection={<ShieldCheck size={10} />}
>
{asI18n(task.assignedRole.replace(/_/g, ' '))}
</Badge>
)}
</Group>
{task.location && (
<Group gap={4}>
<MapPin size={14} color="#888" />
<Text size="xs" c="dimmed">
{asI18n(task.location)}
</Text>
</Group>
)}
{task.scheduledStartAt && (
<Group gap={4}>
<Clock size={14} color="#888" />
<Text size="xs" c="dimmed">
{asI18n(
`${formatTime(task.scheduledStartAt)}${task.scheduledEndAt ? ` - ${formatTime(task.scheduledEndAt)}` : ''}`
)}
</Text>
</Group>
)}
<Group gap="xs">
<TaskActions taskId={task.taskId} status={task.status} />
{(task.status === 'open' || task.status === 'planned') && (
<Button size="xs" variant="light" color="grape" onClick={() => onAssignRole(task.taskId)}>
{m.tasks_assign_role()}
</Button>
)}
</Group>
</Stack>
</Card>
)
}
function CreateTaskDrawer({ opened, onClose }: { opened: boolean; onClose: () => void }) {
const queryClient = useQueryClient()
const [title, setTitle] = useState('')
const [description, setDescription] = useState('')
const [category, setCategory] = useState<string | null>(null)
const [location, setLocation] = useState('')
const [scheduledStartAt, setScheduledStartAt] = useState('')
const [scheduledEndAt, setScheduledEndAt] = useState('')
const [assignedRole, setAssignedRole] = useState<string | null>(null)
const assignToRole = usePikkuMutation('assignTaskToRole')
const mutation = usePikkuMutation('createTaskInstance', {
onSuccess: async (result) => {
if (assignedRole && result.taskId) {
await assignToRole.mutateAsync({ taskId: result.taskId, role: assignedRole })
}
queryClient.invalidateQueries({ queryKey: ['listTasks'] })
notifications.show({
title: m.tasks_created_title(),
message: m.tasks_created_message({ title }),
color: 'teal',
})
resetForm()
onClose()
},
onError: (err) => {
notifications.show({
title: m.tasks_error_title(),
message: err.message ?? m.tasks_error_generic(),
color: 'red',
})
},
})
const resetForm = () => {
setTitle('')
setDescription('')
setCategory(null)
setLocation('')
setScheduledStartAt('')
setScheduledEndAt('')
setAssignedRole(null)
}
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
if (!title.trim() || !category) {
notifications.show({
title: m.tasks_validation_title(),
message: m.tasks_validation_message(),
color: 'orange',
})
return
}
mutation.mutate({
title: title.trim(),
category: category as 'housekeeping' | 'kitchen' | 'maintenance' | 'operations' | 'retreat' | 'transport',
...(description.trim() ? { description: description.trim() } : {}),
...(location.trim() ? { location: location.trim() } : {}),
...(scheduledStartAt
? { scheduledStartAt: new Date(scheduledStartAt).toISOString() }
: {}),
...(scheduledEndAt
? { scheduledEndAt: new Date(scheduledEndAt).toISOString() }
: {}),
})
}
return (
<Drawer position="right" size="md" opened={opened} onClose={onClose} title={m.tasks_create_new_task()}>
<form onSubmit={handleSubmit}>
<Stack gap="md">
<TextInput
label={m.tasks_field_title()}
placeholder={m.tasks_title_placeholder()}
value={title}
onChange={(e) => setTitle(e.currentTarget.value)}
required
/>
<Textarea
label={m.tasks_field_description()}
placeholder={m.tasks_description_placeholder()}
value={description}
onChange={(e) => setDescription(e.currentTarget.value)}
minRows={3}
/>
<Select
label={m.tasks_category()}
placeholder={m.tasks_select_category()}
data={CATEGORIES}
value={category}
onChange={setCategory}
required
/>
<TextInput
label={m.tasks_location()}
placeholder={m.tasks_location_placeholder()}
value={location}
onChange={(e) => setLocation(e.currentTarget.value)}
/>
<Group grow>
<TextInput
label={m.tasks_scheduled_start()}
type="datetime-local"
value={scheduledStartAt}
onChange={(e) => setScheduledStartAt(e.currentTarget.value)}
/>
<TextInput
label={m.tasks_scheduled_end()}
type="datetime-local"
value={scheduledEndAt}
onChange={(e) => setScheduledEndAt(e.currentTarget.value)}
min={scheduledStartAt || undefined}
/>
</Group>
<Select
label={m.tasks_assign_to_role_optional()}
placeholder={m.tasks_select_role()}
data={ROLE_OPTIONS}
value={assignedRole}
onChange={setAssignedRole}
clearable
/>
<Group justify="flex-end" mt="sm">
<Button variant="subtle" color="gray" onClick={onClose}>
{m.tasks_cancel()}
</Button>
<Button type="submit" color="teal" loading={mutation.isPending || assignToRole.isPending}>
{m.tasks_create_task()}
</Button>
</Group>
</Stack>
</form>
</Drawer>
)
}
function TasksPage() {
useLocale()
const queryClient = useQueryClient()
const [roleDrawerOpened, { open: openRoleDrawer, close: closeRoleDrawer }] = useDisclosure(false)
const [createDrawerOpened, { open: openCreateDrawer, close: closeCreateDrawer }] = useDisclosure(false)
const [roleTaskId, setRoleTaskId] = useState<string | null>(null)
const [selectedRole, setSelectedRole] = useState<string | null>(null)
function handleOpenRoleDrawer(taskId: string) {
setRoleTaskId(taskId)
setSelectedRole(null)
openRoleDrawer()
}
const assignRoleMutation = usePikkuMutation('assignTaskToRole', {
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['listTasks'] })
notifications.show({
title: m.tasks_role_assigned_title(),
message: m.tasks_role_assigned_message(),
color: 'green',
})
closeRoleDrawer()
setRoleTaskId(null)
setSelectedRole(null)
},
onError: (err) => {
notifications.show({
title: m.tasks_error_title(),
message: err.message || m.tasks_error_assign_role(),
color: 'red',
})
},
})
const generateMutation = usePikkuMutation('generateRecurringTasks', {
onSuccess: (result) => {
queryClient.invalidateQueries({ queryKey: ['listTasks'] })
notifications.show({
title: m.tasks_generated_title(),
message: m.tasks_generated_message({ count: result.generated }),
color: 'green',
})
},
onError: (err) => {
notifications.show({
title: m.tasks_error_title(),
message: err.message || m.tasks_error_generate(),
color: 'red',
})
},
})
const { data, error: queryError } = usePikkuQuery('listTasks', { limit: 50, offset: 0 })
const tasks = (data?.items ?? []) as TaskItem[]
const error = queryError?.message ?? null
const grouped: Record<string, TaskItem[]> = {}
for (const status of KANBAN_STATUSES) {
grouped[status] = []
}
for (const task of tasks) {
const key = KANBAN_STATUSES.includes(task.status) ? task.status : 'open'
grouped[key]!.push(task)
}
return (
<>
<Stack gap="lg">
<Group justify="space-between" align="flex-end">
<div>
<Title order={2} style={{ fontFamily: '"Cormorant", serif', color: '#3D2B1F' }}>
{m.tasks_title()}
</Title>
<Text size="sm" c="dimmed" mt={4}>
{m.tasks_description()}
</Text>
</div>
<Group gap="xs">
<Button
variant="light"
color="teal"
size="xs"
leftSection={<Repeat size={14} />}
onClick={() => generateMutation.mutate({ date: new Date().toISOString().split('T')[0] })}
loading={generateMutation.isPending}
>
{m.tasks_generate_recurring()}
</Button>
<Anchor component={Link} to="/tasks/templates" size="sm" c="dimmed" style={{ textDecoration: 'none' }}>
{m.tasks_templates()}
</Anchor>
<Button variant="subtle" size="xs" onClick={openCreateDrawer} leftSection={<Plus size={14} />}>
{m.tasks_new_task()}
</Button>
</Group>
</Group>
{error && (
<Card padding="md" radius="md" style={{ backgroundColor: '#fff3f3' }}>
<Text size="sm" c="red">
{asI18n(error)}
</Text>
</Card>
)}
{/* Mobile: Accordion */}
<Box hiddenFrom="sm">
<Accordion
variant="separated"
radius="md"
multiple
defaultValue={KANBAN_STATUSES}
styles={{
item: {
backgroundColor: '#ffffff',
boxShadow: '0 2px 12px rgba(61, 43, 31, 0.08), 0 1px 4px rgba(61, 43, 31, 0.04)',
},
}}
>
{KANBAN_STATUSES.map((status) => {
const cfg = STATUS_CONFIG[status]!
const items = grouped[status]!
return (
<Accordion.Item key={status} value={status}>
<Accordion.Control>
<Group gap="xs">
<Badge size="sm" color={cfg.color} variant="light">
{items.length}
</Badge>
<Text fw={600} size="sm">
{asI18n(statusLabel(status))}
</Text>
</Group>
</Accordion.Control>
<Accordion.Panel>
{items.length === 0 ? (
<Text size="sm" c="dimmed" ta="center" py="md">
{m.tasks_no_status_tasks({ status: statusLabel(status).toLowerCase() })}
</Text>
) : (
<Stack gap="sm">
{items.map((task) => (
<TaskCard key={task.taskId} task={task} onAssignRole={handleOpenRoleDrawer} />
))}
</Stack>
)}
</Accordion.Panel>
</Accordion.Item>
)
})}
</Accordion>
</Box>
{/* Desktop: Kanban columns */}
<Box visibleFrom="sm">
<SimpleGrid cols={4} spacing="md">
{KANBAN_STATUSES.map((status) => {
const cfg = STATUS_CONFIG[status]!
const items = grouped[status]!
return (
<Stack key={status} gap="sm">
<Group gap="xs" mb={4}>
<Badge size="sm" color={cfg.color} variant="filled">
{items.length}
</Badge>
<Text
fw={600}
size="sm"
style={{ fontFamily: '"Cormorant", serif', fontSize: 16, color: '#3D2B1F' }}
>
{asI18n(statusLabel(status))}
</Text>
</Group>
{items.length === 0 ? (
<Card
padding="xl"
radius="md"
style={{
backgroundColor: 'rgba(245, 240, 232, 0.5)',
border: '2px dashed rgba(61, 43, 31, 0.1)',
}}
>
<Text size="sm" c="dimmed" ta="center">
{m.tasks_no_tasks()}
</Text>
</Card>
) : (
items.map((task) => (
<TaskCard key={task.taskId} task={task} onAssignRole={handleOpenRoleDrawer} />
))
)}
</Stack>
)
})}
</SimpleGrid>
</Box>
</Stack>
<Drawer
opened={roleDrawerOpened}
onClose={closeRoleDrawer}
title={m.tasks_assign_to_role()}
position="right"
size="md"
>
<Stack gap="md">
<Select
label={m.tasks_role()}
placeholder={m.tasks_select_role()}
data={ROLE_OPTIONS}
value={selectedRole}
onChange={setSelectedRole}
required
/>
<Group justify="flex-end" mt="md">
<Button variant="default" onClick={closeRoleDrawer}>
{m.tasks_cancel()}
</Button>
<Button
color="grape"
onClick={() => {
if (roleTaskId && selectedRole) {
assignRoleMutation.mutate({ taskId: roleTaskId, role: selectedRole })
}
}}
loading={assignRoleMutation.isPending}
disabled={!selectedRole}
>
{m.tasks_assign_role()}
</Button>
</Group>
</Stack>
</Drawer>
<CreateTaskDrawer opened={createDrawerOpened} onClose={closeCreateDrawer} />
</>
)
}