chore: perauset customer project
This commit is contained in:
577
apps/app/src/pages/_authenticated.tasks.index.tsx
Normal file
577
apps/app/src/pages/_authenticated.tasks.index.tsx
Normal file
@@ -0,0 +1,577 @@
|
||||
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} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user