418 lines
No EOL
15 KiB
TypeScript
418 lines
No EOL
15 KiB
TypeScript
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>
|
||
)
|
||
}
|