cheekylist/frontend/src/pages/Checklist.tsx
2025-07-28 19:54:33 +02:00

431 lines
No EOL
13 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { useState } from 'react'
import { useParams, Link } from 'react-router-dom'
import { useSSE } from '../hooks/useSSE'
import ChecklistItem from '../components/ChecklistItem'
import { EditableTitle } from '../components/EditableTitle'
import { exportChecklistToJSON } from '../hooks/useLocalStorage'
import type { ChecklistItem as ChecklistItemType } from '../types'
import { Flex, Box, Text, Button, TextField, Heading, Badge, Card, IconButton } from '@radix-ui/themes'
import { ArrowLeftIcon, DownloadIcon, ChevronDownIcon, ChevronUpIcon } from '@radix-ui/react-icons'
interface ItemGroup {
title: string
items: ChecklistItemType[]
isCollapsible?: boolean
isCollapsed?: boolean
onToggleCollapse?: () => void
}
export default function Checklist() {
const { uuid } = useParams<{ uuid: string }>()
const { items, checkListName, isConnected, error } = useSSE(uuid || '')
const [newItemContent, setNewItemContent] = useState('')
const [isAddingItem, setIsAddingItem] = useState(false)
const [completedItemsCollapsed, setCompletedItemsCollapsed] = useState(false)
const [isExporting, setIsExporting] = useState(false)
const buildItemTree = (items: ChecklistItemType[]): ChecklistItemType[] => {
const itemMap = new Map<number, ChecklistItemType>()
const rootItems: ChecklistItemType[] = []
// Create a map of all items
items.forEach(item => {
itemMap.set(item.id, { ...item, children: [] })
})
// Build the tree structure
items.forEach(item => {
if (item.parent_id === null) {
rootItems.push(itemMap.get(item.id)!)
} else {
const parent = itemMap.get(item.parent_id)
if (parent) {
if (!parent.children) parent.children = []
parent.children.push(itemMap.get(item.id)!)
}
}
})
return rootItems
}
const categorizeItems = (items: ChecklistItemType[]): ItemGroup[] => {
const now = new Date()
// Helper function to check if an item can be completed
const canComplete = (item: ChecklistItemType): boolean => {
// Check dependencies
const dependenciesCompleted = item.dependencies?.every(depId => {
const depItem = items.find(i => i.id === depId)
return depItem?.checked
}) ?? true
// Check date constraints
const notBeforeDate = item.not_before ? new Date(item.not_before) : null
const notAfterDate = item.not_after ? new Date(item.not_after) : null
const dateConstraintsMet = (!notBeforeDate || now >= notBeforeDate) && (!notAfterDate || now <= notAfterDate)
return dependenciesCompleted && dateConstraintsMet
}
// Helper function to check if an item is locked by constraints
const isLockedByConstraints = (item: ChecklistItemType): boolean => {
if (item.checked) return false
// Check dependencies
const dependenciesCompleted = item.dependencies?.every(depId => {
const depItem = items.find(i => i.id === depId)
return depItem?.checked
}) ?? true
// Check date constraints
const notBeforeDate = item.not_before ? new Date(item.not_before) : null
const notAfterDate = item.not_after ? new Date(item.not_after) : null
const dateConstraintsMet = (!notBeforeDate || now >= notBeforeDate) && (!notAfterDate || now <= notAfterDate)
return !dependenciesCompleted || !dateConstraintsMet
}
const completedItems = items.filter(item => item.checked)
const availableItems = items.filter(item => !item.checked && canComplete(item))
const lockedItems = items.filter(item => !item.checked && isLockedByConstraints(item))
const groups: ItemGroup[] = []
// Completed items (collapsible)
if (completedItems.length > 0) {
groups.push({
title: `Completed (${completedItems.length})`,
items: completedItems,
isCollapsible: true,
isCollapsed: completedItemsCollapsed,
onToggleCollapse: () => setCompletedItemsCollapsed(!completedItemsCollapsed)
})
}
// Available items (can be completed)
if (availableItems.length > 0) {
groups.push({
title: `Available (${availableItems.length})`,
items: availableItems
})
}
// Locked items (cannot be completed due to constraints)
if (lockedItems.length > 0) {
groups.push({
title: `Locked by Constraints (${lockedItems.length})`,
items: lockedItems
})
}
return groups
}
const addItem = async (content: string, parentId?: number) => {
if (!content.trim() || !uuid) return
setIsAddingItem(true)
try {
const response = await fetch(`/api/checklists/${uuid}/items`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
content: content.trim(),
parent_id: parentId || null
}),
})
if (!response.ok) {
throw new Error('Failed to add item')
}
} catch (error) {
console.error('Error adding item:', error)
alert('Failed to add item')
} finally {
setIsAddingItem(false)
}
}
const updateItem = async (id: number, updates: Partial<ChecklistItemType>) => {
if (!uuid) return
try {
const response = await fetch(`/api/checklists/${uuid}/items/${id}`, {
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(updates),
})
if (!response.ok) {
throw new Error('Failed to update item')
}
} catch (error) {
console.error('Error updating item:', error)
throw error
}
}
const deleteItem = async (id: number) => {
if (!uuid) return
try {
const response = await fetch(`/api/checklists/${uuid}/items/${id}`, {
method: 'DELETE',
})
if (!response.ok) {
throw new Error('Failed to delete item')
}
} catch (error) {
console.error('Error deleting item:', error)
throw error
}
}
const lockItem = async (id: number, user: string) => {
if (!uuid) return
try {
const response = await fetch(`/api/checklists/${uuid}/items/${id}/lock`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ user }),
})
if (!response.ok) {
throw new Error('Failed to lock item')
}
} catch (error) {
console.error('Error locking item:', error)
throw error
}
}
const updateChecklistName = async (newName: string) => {
if (!uuid) throw new Error('No checklist UUID')
const response = await fetch(`/api/checklists/${uuid}/name`, {
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ name: newName }),
})
if (!response.ok) {
throw new Error('Failed to update checklist name')
}
}
const handleAddItem = async () => {
await addItem(newItemContent)
setNewItemContent('')
}
const handleKeyPress = (e: React.KeyboardEvent) => {
if (e.key === 'Enter') {
handleAddItem()
}
}
const handleExportChecklist = async () => {
if (!uuid || !checkListName) return
setIsExporting(true)
try {
await exportChecklistToJSON(uuid, checkListName)
} catch {
alert('Failed to export checklist')
} finally {
setIsExporting(false)
}
}
const renderGroupedItems = (allItems: ChecklistItemType[]) => {
const itemTree = buildItemTree(allItems)
if (itemTree.length === 0) {
return (
<Flex direction="column" align="center" justify="center" py="9">
<Text size="9" color="gray" mb="3">📝</Text>
<Text size="3" weight="medium" color="gray">No items yet</Text>
<Text size="2" color="gray" mt="1">Add your first item above to get started!</Text>
</Flex>
)
}
const groups = categorizeItems(allItems)
return (
<Flex direction="column" gap="5">
{groups.map((group, groupIndex) => (
<Box key={groupIndex}>
<Flex align="center" justify="between" pb="2" className="border-b border-gray-200 dark:border-gray-700">
<Heading
size="2"
color={group.title.includes('Completed') ? "green" : group.title.includes('Available') ? "blue" : "orange"}
>
{group.title}
</Heading>
{group.isCollapsible && (
<IconButton
size="1"
variant="ghost"
onClick={group.onToggleCollapse}
>
{group.isCollapsed ? <ChevronDownIcon /> : <ChevronUpIcon />}
</IconButton>
)}
</Flex>
{(!group.isCollapsible || !group.isCollapsed) && (
<Box
mt="3"
style={{
opacity: group.title.includes('Completed') ? 0.75 : 1
}}
>
<Flex direction="column" gap="2">
{group.items.map(item => {
const findItemWithChildren = (items: ChecklistItemType[], targetId: number): ChecklistItemType | null => {
for (const item of items) {
if (item.id === targetId) {
return item
}
if (item.children) {
const found = findItemWithChildren(item.children, targetId)
if (found) return found
}
}
return null
}
const itemWithChildren = findItemWithChildren(itemTree, item.id)
return (
<ChecklistItem
key={item.id}
item={item}
onUpdate={updateItem}
onDelete={deleteItem}
onLock={lockItem}
children={itemWithChildren?.children || []}
allItems={allItems}
/>
)
})}
</Flex>
</Box>
)}
</Box>
))}
</Flex>
)
}
if (!uuid) {
return (
<Flex align="center" justify="center" style={{ minHeight: '100vh' }}>
<Text size="4" color="red" weight="medium">Invalid checklist ID</Text>
</Flex>
)
}
return (
<Box style={{ minHeight: '100vh' }} className="bg-gray-50 dark:bg-gray-900">
<Box className="bg-white dark:bg-gray-800 shadow-sm border-b border-gray-200 dark:border-gray-700">
<Box style={{ maxWidth: '1024px' }} mx="auto" p="3">
<Flex direction={{ initial: 'column', sm: 'row' }} align="center" justify="between" gap="3">
<Flex align="center" gap="3">
<Link to="/">
<Button variant="ghost" size="2">
<ArrowLeftIcon />
Back
</Button>
</Link>
<EditableTitle
title={checkListName}
onSave={updateChecklistName}
className="text-base"
/>
</Flex>
<Flex align="center" gap="2">
<Button
onClick={handleExportChecklist}
disabled={isExporting || !isConnected}
size="2"
color="violet"
>
<DownloadIcon />
{isExporting ? 'Exporting...' : 'Export'}
</Button>
<Badge
color={isConnected ? "green" : "red"}
variant="surface"
size="2"
>
<Box
width="8px"
height="8px"
className={`rounded-full ${isConnected ? 'bg-green-500' : 'bg-red-500'}`}
/>
{isConnected ? 'Connected' : 'Disconnected'}
</Badge>
</Flex>
</Flex>
</Box>
</Box>
{error && (
<Box style={{ maxWidth: '1024px' }} mx="auto" px="2" py="2">
<Card size="2" style={{ backgroundColor: 'var(--red-a3)', borderColor: 'var(--red-a6)' }}>
<Flex align="center" gap="2">
<Text color="red"></Text>
<Text color="red" weight="medium">{error}</Text>
</Flex>
</Card>
</Box>
)}
<Box style={{ maxWidth: '1024px' }} mx="auto" px="3" py="4">
<Box mb="4">
<Flex direction={{ initial: 'column', sm: 'row' }} gap="2">
<Box style={{ flex: 1 }}>
<TextField.Root
size="3"
placeholder="Add a new item..."
value={newItemContent}
onChange={(e) => setNewItemContent(e.target.value)}
onKeyDown={handleKeyPress}
disabled={isAddingItem || !isConnected}
/>
</Box>
<Button
onClick={handleAddItem}
disabled={isAddingItem || !newItemContent.trim() || !isConnected}
size="3"
>
{isAddingItem ? 'Adding...' : 'Add'}
</Button>
</Flex>
</Box>
<Box>
{renderGroupedItems(items)}
</Box>
</Box>
</Box>
)
}