cheekylist/frontend/src/pages/Checklist.tsx
2025-07-25 16:40:33 +02:00

418 lines
No EOL
15 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 type { ChecklistItem as ChecklistItemType } from '../types'
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 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 handleAddItem = async () => {
await addItem(newItemContent)
setNewItemContent('')
}
const handleKeyPress = (e: React.KeyboardEvent) => {
if (e.key === 'Enter') {
handleAddItem()
}
}
const renderGroupedItems = (allItems: ChecklistItemType[]) => {
const itemTree = buildItemTree(allItems)
if (itemTree.length === 0) {
return (
<div className="text-center py-10">
<div className="text-gray-400 dark:text-gray-500 text-5xl mb-3">📝</div>
<p className="text-gray-600 dark:text-gray-300 text-base font-medium">No items yet</p>
<p className="text-gray-500 dark:text-gray-400 mt-1">Add your first item above to get started!</p>
</div>
)
}
const groups = categorizeItems(allItems)
return (
<div className="space-y-6">
{groups.map((group, groupIndex) => (
<div key={groupIndex} className="space-y-3">
{/* Group Header */}
<div className="flex items-center justify-between border-b border-gray-200 dark:border-gray-700 pb-2">
<h3 className={`text-sm font-semibold flex items-center gap-2 ${
group.title.includes('Completed')
? 'text-green-600 dark:text-green-400'
: group.title.includes('Available')
? 'text-blue-600 dark:text-blue-400'
: 'text-orange-600 dark:text-orange-400'
}`}>
{group.title.includes('Completed') && (
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clipRule="evenodd" />
</svg>
)}
{group.title.includes('Available') && (
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M6 2a1 1 0 00-1 1v1H4a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V6a2 2 0 00-2-2h-1V3a1 1 0 10-2 0v1H7V3a1 1 0 00-1-1zm0 5a1 1 0 000 2h8a1 1 0 100-2H6z" clipRule="evenodd" />
</svg>
)}
{group.title.includes('Locked') && (
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M5 9V7a5 5 0 0110 0v2a2 2 0 012 2v5a2 2 0 01-2 2H5a2 2 0 01-2-2v-5a2 2 0 012-2zm8-2v2H7V7a3 3 0 016 0z" clipRule="evenodd" />
</svg>
)}
{group.title}
</h3>
{group.isCollapsible && (
<button
onClick={group.onToggleCollapse}
className="flex items-center gap-1 text-xs text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300 transition-colors"
>
{group.isCollapsed ? (
<>
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
Show
</>
) : (
<>
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 15l7-7 7 7" />
</svg>
Hide
</>
)}
</button>
)}
</div>
{/* Group Items */}
{(!group.isCollapsible || !group.isCollapsed) && (
<div className={`space-y-2 ${
group.title.includes('Completed') ? 'opacity-75' : ''
}`}>
{group.items.map(item => {
// Find the item with its children from the tree
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}
/>
)
})}
</div>
)}
</div>
))}
</div>
)
}
if (!uuid) {
return (
<div className="flex items-center justify-center min-h-screen">
<div className="text-red-600 dark:text-red-400 text-lg font-medium">Invalid checklist ID</div>
</div>
)
}
return (
<div className="min-h-screen bg-gray-50 dark:bg-gray-900">
{/* Header */}
<div className="bg-white dark:bg-gray-800 shadow-sm border-b border-gray-200 dark:border-gray-700">
<div className="max-w-4xl mx-auto p-1">
<div className="flex flex-col sm:flex-row items-center justify-between">
<div className="flex items-center gap-2">
<Link to="/">
<button
className="flex items-center gap-1 text-gray-600 dark:text-gray-300 hover:text-gray-900 dark:hover:text-white transition-colors duration-200"
>
<span className="text-base"></span>
<span className="text-base">Back</span>
</button>
</Link>
<h1 className="text-base font-bold text-gray-900 dark:text-white">{checkListName}</h1>
</div>
<div className="flex items-center gap-2">
{isConnected ? (
<span className="flex items-center gap-2 text-green-600 dark:text-green-400 bg-green-50 dark:bg-green-900/30 px-2 py-0.5 rounded-full text-sm font-medium">
<span className="w-2 h-2 bg-green-500 rounded-full"></span>
Connected
</span>
) : (
<span className="flex items-center gap-2 text-red-600 dark:text-red-400 bg-red-50 dark:bg-red-900/30 px-2 py-0.5 rounded-full text-sm font-medium">
<span className="w-2 h-2 bg-red-500 rounded-full"></span>
Disconnected
</span>
)}
</div>
</div>
</div>
</div>
{/* Error Message */}
{error && (
<div className="max-w-4xl mx-auto px-2 py-2">
<div className="bg-red-50 dark:bg-red-900/30 border border-red-200 dark:border-red-800 rounded-lg p-2">
<div className="flex items-center gap-2">
<span className="text-red-600 dark:text-red-400"></span>
<span className="text-red-800 dark:text-red-200 font-medium">{error}</span>
</div>
</div>
</div>
)}
{/* Main Content */}
<div className="max-w-4xl mx-auto px-2 py-4">
{/* Add Item Section */}
<div className=" shadow-sm p-1 mb-1">
<div className="flex flex-col sm:flex-row gap-2">
<input
type="text"
placeholder="Add a new item..."
value={newItemContent}
onChange={(e) => setNewItemContent(e.target.value)}
onKeyDown={handleKeyPress}
disabled={isAddingItem || !isConnected}
className="flex-1 px-3 py-1 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent disabled:bg-gray-100 dark:disabled:bg-gray-700 disabled:text-gray-500 dark:disabled:text-gray-400 bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-500 dark:placeholder-gray-400"
/>
<button
onClick={handleAddItem}
disabled={isAddingItem || !newItemContent.trim() || !isConnected}
className="px-4 py-1 bg-blue-600 dark:bg-blue-500 text-white font-medium rounded-lg hover:bg-blue-700 dark:hover:bg-blue-600 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 dark:focus:ring-offset-gray-800 disabled:opacity-50 disabled:cursor-not-allowed transition-colors duration-200"
>
{isAddingItem ? 'Adding...' : 'Add'}
</button>
</div>
</div>
{/* Items Section */}
<div className="p-1">
{renderGroupedItems(items)}
</div>
</div>
</div>
)
}