reinit
This commit is contained in:
commit
ad8c238e78
53 changed files with 10091 additions and 0 deletions
431
frontend/src/pages/Checklist.tsx
Normal file
431
frontend/src/pages/Checklist.tsx
Normal file
|
@ -0,0 +1,431 @@
|
|||
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>
|
||||
)
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue