578 lines
18 KiB
TypeScript
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} />
|
|
</>
|
|
)
|
|
}
|