import type { GridDto, TodoOptionDto } from '@/proxy/form/models' import type { IdentityUserDto } from '@/proxy/admin/models' import type { DataGridRef } from 'devextreme-react/data-grid' import type CustomStore from 'devextreme/data/custom_store' import TagBox from 'devextreme-react/tag-box' import { AVATAR_URL } from '@/constants/app.constant' import dayjs from 'dayjs' import relativeTime from 'dayjs/plugin/relativeTime' import { Fragment, useCallback, useEffect, useMemo, useRef, useState } from 'react' import { FaArrowLeft, FaCheckCircle, FaCheck, FaClock, FaGripVertical, FaPencilAlt, FaPlus, FaRegCircle, FaTag, FaTimes, FaTrash, } from 'react-icons/fa' import { useListFormCustomDataSource } from './useListFormCustomDataSource' import { useListFormStateStoring } from './useListFormStateStoring' import { Avatar, Rate } from '@/components/ui' import { getUsers } from '@/services/identity.service' import { useLocalization } from '@/utils/hooks/useLocalization' dayjs.extend(relativeTime) interface TodoBoardProps { listFormCode: string searchParams?: URLSearchParams isSubForm?: boolean gridDto: GridDto } type TodoRow = Record interface DropTarget { status: string index: number } interface StoredBoardColumns { columns: string[] retired: string[] } interface StatusNotice { status: string message: string } interface UserOption { value: string label: string avatarUrl?: string } const fieldValue = (row: TodoRow, field?: string) => (field ? row[field] : undefined) const textList = (value: unknown) => Array.isArray(value) ? value.map(String) : String(value ?? '') .split(/[,;|]/) .map((item) => item.trim()) .filter(Boolean) const isCompleted = (value: unknown) => value === true || value === 1 || ['true', 'done', 'completed'].includes(String(value).toLowerCase()) const initials = (name: string) => name .split(/\s+/) .map((part) => part[0]) .join('') .slice(0, 2) .toUpperCase() const priorityClass = (priority: unknown) => { switch (priorityRating(priority)) { case 5: return 'bg-red-100 text-red-700 dark:bg-red-950 dark:text-red-200' case 4: return 'bg-orange-100 text-orange-700 dark:bg-orange-950 dark:text-orange-200' case 3: return 'bg-amber-100 text-amber-700 dark:bg-amber-950 dark:text-amber-200' case 2: case 1: return 'bg-emerald-100 text-emerald-700 dark:bg-emerald-950 dark:text-emerald-200' default: return 'bg-slate-100 text-slate-700 dark:bg-slate-700 dark:text-slate-100' } } const priorityRating = (priority: unknown) => { const numeric = Number(priority) if (Number.isFinite(numeric) && numeric >= 0 && numeric <= 5) return numeric switch (String(priority ?? '').toLowerCase()) { case 'urgent': case 'critical': return 5 case 'high': return 4 case 'medium': return 3 case 'low': return 1 default: return 0 } } const priorityLabel = ( priority: unknown, translate: (key: string, params?: Record) => string, ) => { const rating = priorityRating(priority) return rating ? translate('::ListForms.TodoBoard.PriorityRating', { 0: rating }) : translate('::ListForms.TodoBoard.NoPriority') } const TodoBoard = ({ listFormCode, searchParams, gridDto }: TodoBoardProps) => { const options = gridDto.gridOptions.todoOptionDto as TodoOptionDto const keyField = gridDto.gridOptions.keyFieldName ?? 'id' const stateStoring = gridDto.gridOptions.stateStoringDto const gridRef = useRef>() const { createSelectDataSource } = useListFormCustomDataSource({ gridRef }) const [store, setStore] = useState>() const [rows, setRows] = useState([]) const [statuses, setStatuses] = useState([]) const [selected, setSelected] = useState() const [draft, setDraft] = useState({}) const [saving, setSaving] = useState(false) const [loading, setLoading] = useState(true) const [draggedKey, setDraggedKey] = useState() const [draggedColumn, setDraggedColumn] = useState() const [todoDropTarget, setTodoDropTarget] = useState() const [columnDropTarget, setColumnDropTarget] = useState() const [newTodoStatus, setNewTodoStatus] = useState() const [newTodoTitle, setNewTodoTitle] = useState('') const [editingStatus, setEditingStatus] = useState() const [statusName, setStatusName] = useState('') const [isAddingStatus, setIsAddingStatus] = useState(false) const [newStatusName, setNewStatusName] = useState('') const [statusError, setStatusError] = useState('') const [statusNotice, setStatusNotice] = useState() const [renamingStatus, setRenamingStatus] = useState(false) const [columnConfig, setColumnConfig] = useState() const [userOptions, setUserOptions] = useState([]) const [usersLoading, setUsersLoading] = useState(false) const columnConfigRef = useRef({ columns: [], retired: [] }) const dragActiveRef = useRef(false) const { translate } = useLocalization() const uncategorized = translate('::ListForms.TodoBoard.Uncategorized') const clearDragState = useCallback(() => { dragActiveRef.current = false setDraggedKey(undefined) setDraggedColumn(undefined) setTodoDropTarget(undefined) setColumnDropTarget(undefined) }, []) const { customLoadState, customSaveState } = useListFormStateStoring({ listFormCode, storageKey: stateStoring?.enabled ? stateStoring.storageKey || listFormCode : undefined, filterPrefix: 'todo', saveDebounceMs: stateStoring?.savingTimeout, showSaveToast: false, }) const customLoadStateRef = useRef(customLoadState) const customSaveStateRef = useRef(customSaveState) customLoadStateRef.current = customLoadState customSaveStateRef.current = customSaveState useEffect(() => { const finishDrag = () => window.setTimeout(clearDragState, 0) const cancelWithEscape = (event: KeyboardEvent) => { if (event.key === 'Escape' && dragActiveRef.current) finishDrag() } window.addEventListener('dragend', finishDrag, true) window.addEventListener('drop', finishDrag, true) window.addEventListener('keydown', cancelWithEscape, true) return () => { window.removeEventListener('dragend', finishDrag, true) window.removeEventListener('drop', finishDrag, true) window.removeEventListener('keydown', cancelWithEscape, true) } }, [clearDragState]) useEffect(() => { let cancelled = false setUsersLoading(true) getUsers(0, 1000) .then((response) => { if (cancelled) return const users = response.data?.items ?? [] setUserOptions( users .filter((user: IdentityUserDto) => Boolean(user.userName)) .map((user: IdentityUserDto) => { const fullName = [user.name, user.surname].filter(Boolean).join(' ') return { value: user.userName!, label: fullName || user.userName!, avatarUrl: AVATAR_URL(user.id, user.tenantId), } }), ) }) .catch((error) => console.error('Todo assignee users load error:', error)) .finally(() => { if (!cancelled) setUsersLoading(false) }) return () => { cancelled = true } }, []) const loadRows = async (dataSource: CustomStore) => { const result: any = await dataSource.load({ skip: 0, take: 1000, requireTotalCount: true }) const loadedRows = Array.isArray(result) ? result : (result?.data ?? []) setRows(loadedRows) return loadedRows as TodoRow[] } useEffect(() => { const dataSource = createSelectDataSource( gridDto.gridOptions, listFormCode, searchParams, 'todo', ) setStore(dataSource) setLoading(true) loadRows(dataSource).finally(() => setLoading(false)) }, [createSelectDataSource, gridDto, listFormCode, searchParams]) const availableStatuses = useMemo(() => { const configured = textList(options.statusOrder) const actual = rows.map((row) => String(fieldValue(row, options.statusExpr) ?? uncategorized)) return [...new Set([...configured, ...actual])] }, [options.statusExpr, options.statusOrder, rows, uncategorized]) const assigneeSuggestions = useMemo( () => [...new Set(rows.flatMap((row) => textList(fieldValue(row, options.assigneeExpr))))], [options.assigneeExpr, rows], ) const assigneeOptions = useMemo(() => { const knownUsers = new Set(userOptions.map((option) => option.value)) return [ ...userOptions, ...assigneeSuggestions .filter((value) => !knownUsers.has(value)) .map((value) => ({ value, label: value })), ] }, [assigneeSuggestions, userOptions]) const assigneeOptionMap = useMemo( () => new Map(assigneeOptions.map((option) => [option.value, option])), [assigneeOptions], ) const tagSuggestions = useMemo( () => [...new Set(rows.flatMap((row) => textList(fieldValue(row, options.tagExpr))))], [options.tagExpr, rows], ) const draftAssignees = useMemo( () => textList(fieldValue(draft, options.assigneeExpr)), [draft, options.assigneeExpr], ) const draftTags = useMemo( () => textList(fieldValue(draft, options.tagExpr)), [draft, options.tagExpr], ) const saveColumnConfig = useCallback( (columns: string[], retired = columnConfigRef.current.retired) => { const next = { columns, retired } columnConfigRef.current = next setColumnConfig(next) if (stateStoring?.enabled) { customSaveStateRef .current(next) .catch((error) => console.error('Todo board state save error:', error)) } }, [stateStoring?.enabled], ) useEffect(() => { let cancelled = false const legacyStorageKey = `todo-board:${listFormCode}:column-order` let legacyConfig: StoredBoardColumns = { columns: [], retired: [] } try { const legacy = JSON.parse(localStorage.getItem(legacyStorageKey) ?? '{}') legacyConfig = Array.isArray(legacy) ? { columns: legacy.map(String), retired: [] } : { columns: Array.isArray(legacy?.columns) ? legacy.columns.map(String) : [], retired: Array.isArray(legacy?.retired) ? legacy.retired.map(String) : [], } } catch { legacyConfig = { columns: [], retired: [] } } setColumnConfig(undefined) if (!stateStoring?.enabled) { const emptyConfig = { columns: [], retired: [] } columnConfigRef.current = emptyConfig setColumnConfig(emptyConfig) localStorage.removeItem(legacyStorageKey) return () => { cancelled = true } } customLoadStateRef .current() .then((stored) => { if (cancelled) return const hasDatabaseState = Array.isArray(stored?.columns) const config: StoredBoardColumns = hasDatabaseState ? { columns: stored.columns.map(String), retired: Array.isArray(stored?.retired) ? stored.retired.map(String) : [], } : legacyConfig columnConfigRef.current = config setColumnConfig(config) if (!hasDatabaseState && legacyConfig.columns.length) { customSaveStateRef .current(config) .then(() => localStorage.removeItem(legacyStorageKey)) .catch((error) => console.error('Todo board state migration error:', error)) } else { localStorage.removeItem(legacyStorageKey) } }) .catch((error) => { if (cancelled) return console.error('Todo board state load error:', error) const emptyConfig = { columns: [], retired: [] } columnConfigRef.current = emptyConfig setColumnConfig(emptyConfig) }) return () => { cancelled = true } }, [listFormCode, stateStoring?.enabled, stateStoring?.storageKey]) useEffect(() => { if (!columnConfig) return const { columns: saved, retired } = columnConfig const visibleAvailable = availableStatuses.filter((status) => !retired.includes(status)) const merged = [...saved, ...visibleAvailable.filter((status) => !saved.includes(status))] setStatuses((current) => JSON.stringify(current) === JSON.stringify(merged) ? current : merged, ) }, [availableStatuses, columnConfig]) const itemsForStatus = (status: string) => { const items = rows.filter( (row) => String(fieldValue(row, options.statusExpr) ?? uncategorized) === status, ) if (!options.orderExpr) return items return items.sort( (left, right) => Number(fieldValue(left, options.orderExpr) ?? 0) - Number(fieldValue(right, options.orderExpr) ?? 0), ) } const reorderColumn = (targetStatus: string) => { if (!draggedColumn || draggedColumn === targetStatus) return setStatuses((current) => { const next = current.filter((status) => status !== draggedColumn) next.splice(current.indexOf(targetStatus), 0, draggedColumn) saveColumnConfig(next) return next }) setDraggedColumn(undefined) setColumnDropTarget(undefined) } const addStatus = () => { const nextName = newStatusName.trim() if (!nextName) return if (statuses.some((status) => status.toLocaleLowerCase() === nextName.toLocaleLowerCase())) { setStatusError(translate('::ListForms.TodoBoard.DuplicateColumnName')) return } const next = [...statuses, nextName] setStatuses(next) saveColumnConfig( next, columnConfigRef.current.retired.filter((status) => status !== nextName), ) setNewStatusName('') setStatusError('') setIsAddingStatus(false) } const deleteStatus = (status: string) => { const itemCount = itemsForStatus(status).length if (itemCount) { setStatusNotice({ status, message: translate('::ListForms.TodoBoard.ColumnNotEmptyMessage', { 0: itemCount }), }) return } if (!window.confirm(translate('::ListForms.TodoBoard.ConfirmDeleteColumn', { 0: status }))) return const next = statuses.filter((item) => item !== status) const retired = [...new Set([...columnConfigRef.current.retired, status])] setStatuses(next) saveColumnConfig(next, retired) setStatusNotice(undefined) if (editingStatus === status) setEditingStatus(undefined) if (newTodoStatus === status) setNewTodoStatus(undefined) } const renameStatus = async (status: string) => { const nextName = statusName.trim() if (!nextName || nextName === status) { setEditingStatus(undefined) setStatusError('') return } if ( statuses.some( (item) => item !== status && item.toLocaleLowerCase() === nextName.toLocaleLowerCase(), ) ) { setStatusError(translate('::ListForms.TodoBoard.DuplicateColumnName')) return } const affectedRows = itemsForStatus(status) if (affectedRows.length && !gridDto.gridOptions.updateServiceAddress) { setStatusError(translate('::ListForms.TodoBoard.RenameRequiresUpdateService')) return } const nextStatuses = statuses.map((item) => (item === status ? nextName : item)) const config = columnConfigRef.current const retired = [...new Set([...config.retired.filter((item) => item !== nextName), status])] setRenamingStatus(true) setStatuses(nextStatuses) saveColumnConfig(nextStatuses, retired) setRows((current) => current.map((row) => String(fieldValue(row, options.statusExpr) ?? uncategorized) === status ? { ...row, [options.statusExpr!]: nextName } : row, ), ) try { if (store && options.statusExpr && gridDto.gridOptions.updateServiceAddress) { await Promise.all( affectedRows.map((row) => store.update(row[keyField], { [options.statusExpr!]: nextName }), ), ) await loadRows(store) } setEditingStatus(undefined) setStatusError('') } catch { setStatuses(statuses) saveColumnConfig(statuses, config.retired) setRows((current) => current.map((row) => String(fieldValue(row, options.statusExpr) ?? uncategorized) === nextName ? { ...row, [options.statusExpr!]: status } : row, ), ) setStatusError(translate('::ListForms.TodoBoard.ColumnNameSaveFailed')) } finally { setRenamingStatus(false) } } const moveTodo = async (status: string, targetIndex: number, transferredKey?: string) => { const effectiveKey = draggedKey ?? rows.find((row) => String(row[keyField]) === transferredKey)?.[keyField] if (effectiveKey === undefined || !store || !options.statusExpr || !options.allowDragging) return const moved = rows.find((row) => row[keyField] === effectiveKey) if (!moved) return const sourceStatus = String(fieldValue(moved, options.statusExpr) ?? uncategorized) const targetItems = itemsForStatus(status).filter((row) => row[keyField] !== effectiveKey) targetItems.splice(Math.min(targetIndex, targetItems.length), 0, moved) const sourceItems = sourceStatus === status ? [] : itemsForStatus(sourceStatus).filter((row) => row[keyField] !== effectiveKey) const updates = new Map>() targetItems.forEach((row, index) => { const values: Record = { [options.statusExpr!]: status } if (options.orderExpr) values[options.orderExpr] = index updates.set(row[keyField], values) }) if (options.orderExpr) { sourceItems.forEach((row, index) => updates.set(row[keyField], { [options.orderExpr!]: index }), ) } const updated = rows.map((row) => updates.has(row[keyField]) ? { ...row, ...updates.get(row[keyField]) } : row, ) const targetKeys = new Set(targetItems.map((row) => row[keyField])) setRows( options.orderExpr ? updated : [...updated.filter((row) => !targetKeys.has(row[keyField])), ...targetItems], ) setDraggedKey(undefined) setTodoDropTarget(undefined) if (gridDto.gridOptions.updateServiceAddress) { await Promise.allSettled([...updates].map(([key, values]) => store.update(key, values))) await loadRows(store) } } const addTodo = async (status: string) => { const title = newTodoTitle.trim() if (!title || !store || !options.titleExpr || !options.statusExpr) return const values: Record = { [options.titleExpr]: title, [options.statusExpr]: status, } if (options.completedExpr) values[options.completedExpr] = false if (options.orderExpr) values[options.orderExpr] = itemsForStatus(status).length const inserted: any = await store.insert(values) const loadedRows = await loadRows(store) setNewTodoTitle('') setNewTodoStatus(undefined) const created = loadedRows.find( (row) => (inserted?.[keyField] !== undefined && row[keyField] === inserted[keyField]) || (row[options.titleExpr!] === title && row[options.statusExpr!] === status), ) if (created) openTodo(created) } const deleteTodo = async (row: TodoRow) => { if (!store || !gridDto.gridOptions.deleteServiceAddress) return if ( !window.confirm( translate('::ListForms.TodoBoard.ConfirmDeleteTodo', { 0: String(fieldValue(row, options.titleExpr) ?? ''), }), ) ) return await store.remove(row[keyField]) setRows((current) => current.filter((item) => item[keyField] !== row[keyField])) if (selected?.[keyField] === row[keyField]) setSelected(undefined) } const openTodo = (row: TodoRow) => { setSelected(row) setDraft({ ...row }) } const saveTodo = async () => { if (!selected || !store || !gridDto.gridOptions.updateServiceAddress) return const mappedFields = [ options.titleExpr, options.statusExpr, options.descriptionExpr, options.dueDateExpr, options.tagExpr, options.assigneeExpr, options.priorityExpr, options.completedExpr, options.orderExpr, ].filter(Boolean) as string[] const values = Object.fromEntries(mappedFields.map((field) => [field, draft[field]])) setSaving(true) try { await store.update(selected[keyField], values) const updated = { ...selected, ...values } setRows((current) => current.map((row) => (row[keyField] === selected[keyField] ? updated : row)), ) setSelected(updated) setDraft(updated) } finally { setSaving(false) } } const updateTodoInline = async (row: TodoRow, values: Record) => { const updated = { ...row, ...values } setRows((current) => current.map((item) => (item[keyField] === row[keyField] ? updated : item))) if (selected?.[keyField] === row[keyField]) { setSelected(updated) setDraft((current) => ({ ...current, ...values })) } if (store && gridDto.gridOptions.updateServiceAddress) { await store.update(row[keyField], values) } } if (loading) return (
{translate('::ListForms.TodoBoard.Loading')}
) if (selected) { const canEdit = Boolean(gridDto.gridOptions.updateServiceAddress) const inputClass = 'w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm outline-none transition focus:border-blue-500 focus:ring-2 focus:ring-blue-500/20 disabled:bg-gray-100 dark:border-gray-600 dark:bg-gray-800 dark:disabled:bg-gray-900' const labelClass = 'mb-1.5 block text-xs font-semibold uppercase tracking-wide text-gray-500' return (
{!canEdit && ( {translate('::ListForms.TodoBoard.EditRequiresUpdateService')} )} {gridDto.gridOptions.deleteServiceAddress && ( )}
{options.titleExpr && (
setDraft((current) => ({ ...current, [options.titleExpr!]: event.target.value, })) } />
)} {options.descriptionExpr && (