sozsoft-platform/ui/src/views/list/TodoBoard.tsx
2026-07-16 17:01:18 +03:00

1364 lines
57 KiB
TypeScript

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<string, any>
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, string | number>) => 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<DataGridRef<any, any>>()
const { createSelectDataSource } = useListFormCustomDataSource({ gridRef })
const [store, setStore] = useState<CustomStore<any, any>>()
const [rows, setRows] = useState<TodoRow[]>([])
const [statuses, setStatuses] = useState<string[]>([])
const [selected, setSelected] = useState<TodoRow>()
const [draft, setDraft] = useState<TodoRow>({})
const [saving, setSaving] = useState(false)
const [loading, setLoading] = useState(true)
const [draggedKey, setDraggedKey] = useState<unknown>()
const [draggedColumn, setDraggedColumn] = useState<string>()
const [todoDropTarget, setTodoDropTarget] = useState<DropTarget>()
const [columnDropTarget, setColumnDropTarget] = useState<string>()
const [newTodoStatus, setNewTodoStatus] = useState<string>()
const [newTodoTitle, setNewTodoTitle] = useState('')
const [editingStatus, setEditingStatus] = useState<string>()
const [statusName, setStatusName] = useState('')
const [isAddingStatus, setIsAddingStatus] = useState(false)
const [newStatusName, setNewStatusName] = useState('')
const [statusError, setStatusError] = useState('')
const [statusNotice, setStatusNotice] = useState<StatusNotice>()
const [renamingStatus, setRenamingStatus] = useState(false)
const [columnConfig, setColumnConfig] = useState<StoredBoardColumns>()
const [userOptions, setUserOptions] = useState<UserOption[]>([])
const [usersLoading, setUsersLoading] = useState(false)
const columnConfigRef = useRef<StoredBoardColumns>({ 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<any, any>) => {
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<UserOption[]>(() => {
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<unknown, Record<string, any>>()
targetItems.forEach((row, index) => {
const values: Record<string, any> = { [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<string, any> = {
[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<string, any>) => {
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 (
<div className="flex min-h-64 items-center justify-center text-sm">
{translate('::ListForms.TodoBoard.Loading')}
</div>
)
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 (
<section className="min-h-[420px] rounded-xl py-5 shadow-sm">
<div className="mb-6 flex items-center justify-between border-b border-gray-200 pb-4 dark:border-gray-700">
<button
type="button"
className="inline-flex items-center gap-2 text-sm text-gray-500 hover:text-blue-500"
onClick={() => setSelected(undefined)}
>
<FaArrowLeft /> {translate('::ListForms.TodoBoard.BackToBoard')}
</button>
<div className="flex items-center gap-2">
{!canEdit && (
<span className="text-xs text-amber-600">
{translate('::ListForms.TodoBoard.EditRequiresUpdateService')}
</span>
)}
{gridDto.gridOptions.deleteServiceAddress && (
<button
type="button"
className="rounded-lg p-2 text-gray-400 hover:bg-red-50 hover:text-red-500 dark:hover:bg-red-950"
title={translate('::ListForms.TodoBoard.DeleteTodo')}
onClick={() => deleteTodo(selected)}
>
<FaTrash />
</button>
)}
</div>
</div>
<div className="grid gap-5 lg:grid-cols-3">
<div className="space-y-5 lg:col-span-2">
{options.titleExpr && (
<div>
<label className={labelClass}>{translate('::ListForms.TodoBoard.Title')}</label>
<input
className={`${inputClass} text-lg font-semibold`}
disabled={!canEdit}
value={String(draft[options.titleExpr] ?? '')}
onChange={(event) =>
setDraft((current) => ({
...current,
[options.titleExpr!]: event.target.value,
}))
}
/>
</div>
)}
{options.descriptionExpr && (
<div>
<label className={labelClass}>
{translate('::ListForms.TodoBoard.Description')}
</label>
<textarea
className={`${inputClass} min-h-36 resize-y`}
disabled={!canEdit}
placeholder={translate('::ListForms.TodoBoard.DescriptionPlaceholder')}
value={String(draft[options.descriptionExpr] ?? '')}
onChange={(event) =>
setDraft((current) => ({
...current,
[options.descriptionExpr!]: event.target.value,
}))
}
/>
</div>
)}
<div className="grid gap-4 sm:grid-cols-2">
{options.assigneeExpr && (
<div>
<label className={labelClass}>
{translate('::ListForms.TodoBoard.Assignees')}
</label>
<TagBox
searchEnabled
showClearButton
showSelectionControls
disabled={!canEdit || usersLoading}
displayExpr="label"
items={assigneeOptions}
valueExpr="value"
value={draftAssignees}
applyValueMode="instantly"
placeholder={
usersLoading
? translate('::ListForms.TodoBoard.UsersLoading')
: translate('::ListForms.TodoBoard.SelectUser')
}
onValueChanged={(event) => {
if (!event.event) return
const nextValue = (event.value ?? []).join(', ')
setDraft((current) =>
String(current[options.assigneeExpr!] ?? '') === nextValue
? current
: { ...current, [options.assigneeExpr!]: nextValue },
)
}}
/>
</div>
)}
{options.tagExpr && (
<div>
<label className={labelClass}>{translate('::ListForms.TodoBoard.Tags')}</label>
<TagBox
acceptCustomValue
searchEnabled
showClearButton
showSelectionControls
disabled={!canEdit}
items={tagSuggestions}
value={draftTags}
applyValueMode="instantly"
placeholder={translate('::ListForms.TodoBoard.TagsPlaceholder')}
onCustomItemCreating={(event) => {
event.customItem = event.text?.trim()
}}
onValueChanged={(event) => {
if (!event.event) return
const nextValue = (event.value ?? []).join(', ')
setDraft((current) =>
String(current[options.tagExpr!] ?? '') === nextValue
? current
: { ...current, [options.tagExpr!]: nextValue },
)
}}
/>
</div>
)}
</div>
</div>
<aside className="space-y-4 rounded-xl bg-gray-50 p-4 dark:bg-gray-800/60">
{options.statusExpr && (
<div>
<label className={labelClass}>{translate('::ListForms.TodoBoard.Status')}</label>
<select
className={inputClass}
disabled={!canEdit}
value={String(draft[options.statusExpr] ?? '')}
onChange={(event) =>
setDraft((current) => ({
...current,
[options.statusExpr!]: event.target.value,
}))
}
>
{statuses.map((status) => (
<option key={status} value={status}>
{status}
</option>
))}
</select>
</div>
)}
{options.dueDateExpr && (
<div>
<label className={labelClass}>{translate('::ListForms.TodoBoard.DueDate')}</label>
<input
type="datetime-local"
className={inputClass}
disabled={!canEdit}
value={
draft[options.dueDateExpr]
? dayjs(draft[options.dueDateExpr]).format('YYYY-MM-DDTHH:mm')
: ''
}
onChange={(event) =>
setDraft((current) => ({
...current,
[options.dueDateExpr!]: event.target.value || null,
}))
}
/>
</div>
)}
{options.priorityExpr && (
<div>
<label className={labelClass}>{translate('::ListForms.TodoBoard.Priority')}</label>
<div className="rounded-lg border border-gray-300 bg-white px-3 py-2 dark:border-gray-600 dark:bg-gray-800">
<Rate
allowClear
color="yellow"
disabled={!canEdit}
gap={7}
size={24}
tooltips={[
translate('::ListForms.TodoBoard.PriorityVeryLow'),
translate('::ListForms.TodoBoard.PriorityLow'),
translate('::ListForms.TodoBoard.PriorityMedium'),
translate('::ListForms.TodoBoard.PriorityHigh'),
translate('::ListForms.TodoBoard.PriorityUrgent'),
]}
value={priorityRating(draft[options.priorityExpr])}
onChange={(value) =>
setDraft((current) => ({
...current,
[options.priorityExpr!]: value,
}))
}
/>
<div className="mt-1 text-xs text-gray-500">
{priorityLabel(draft[options.priorityExpr], translate)}
</div>
</div>
</div>
)}
{options.completedExpr && (
<label className="flex cursor-pointer items-center gap-3 rounded-lg border border-gray-200 bg-white p-3 text-sm font-medium dark:border-gray-700 dark:bg-gray-800">
<input
type="checkbox"
className="h-4 w-4 accent-emerald-500"
disabled={!canEdit}
checked={isCompleted(draft[options.completedExpr])}
onChange={(event) =>
setDraft((current) => ({
...current,
[options.completedExpr!]: event.target.checked,
}))
}
/>
{translate('::ListForms.TodoBoard.Completed')}
</label>
)}
</aside>
</div>
{canEdit && (
<div className="mt-6 flex justify-end gap-2 border-t border-gray-200 pt-4 dark:border-gray-700">
<button
type="button"
className="rounded-lg border border-gray-300 px-4 py-2 text-sm hover:bg-gray-50 dark:border-gray-600 dark:hover:bg-gray-800"
onClick={() => setDraft({ ...selected })}
>
{translate('::ListForms.TodoBoard.UndoChanges')}
</button>
<button
type="button"
className="rounded-lg bg-blue-600 px-5 py-2 text-sm font-semibold text-white hover:bg-blue-700 disabled:opacity-50"
disabled={saving || !String(draft[options.titleExpr ?? ''] ?? '').trim()}
onClick={saveTodo}
>
{saving
? translate('::Saving')
: translate('::Save')}
</button>
</div>
)}
</section>
)
}
return (
<div className="pb-3">
<div className="overflow-x-auto">
<div className="flex min-h-[420px] min-w-max gap-3 pt-3">
{statuses.map((status) => {
const items = itemsForStatus(status)
const isAdding = newTodoStatus === status
return (
<section
key={status}
className={`group w-[310px] rounded-xl border-2 p-2 transition-all ${draggedColumn !== undefined && columnDropTarget === status ? 'border-violet-400 bg-violet-50 shadow-lg dark:bg-violet-950/30' : draggedKey !== undefined && todoDropTarget?.status === status ? 'border-blue-400 bg-blue-50/70 shadow-lg dark:bg-blue-950/20' : 'border-transparent bg-gray-50 dark:bg-gray-900/60'} ${draggedColumn === status ? 'opacity-50' : ''}`}
onDragOver={(event) => {
event.preventDefault()
if (dragActiveRef.current && draggedColumn) {
setColumnDropTarget(status)
return
}
if (dragActiveRef.current && draggedKey !== undefined)
setTodoDropTarget({ status, index: items.length })
}}
onDrop={(event) => {
event.preventDefault()
if (draggedColumn) reorderColumn(status)
else
moveTodo(
status,
todoDropTarget?.status === status ? todoDropTarget.index : items.length,
event.dataTransfer.getData('text/todo-key'),
)
}}
>
<header className="mb-2 flex min-h-9 items-center gap-2 px-1">
<span
draggable
className="cursor-grab rounded p-1 text-gray-400 hover:bg-gray-200 hover:text-gray-600 active:cursor-grabbing dark:hover:bg-gray-700"
title={translate('::ListForms.TodoBoard.ReorderColumn')}
onDragStart={(event) => {
event.stopPropagation()
event.dataTransfer.effectAllowed = 'move'
dragActiveRef.current = true
setDraggedKey(undefined)
setDraggedColumn(status)
}}
onDragEnd={clearDragState}
>
<FaGripVertical />
</span>
{editingStatus === status ? (
<form
className="flex min-w-0 flex-1 items-center gap-1"
onSubmit={(event) => {
event.preventDefault()
renameStatus(status)
}}
>
<input
autoFocus
className="min-w-0 flex-1 rounded-md border border-blue-400 bg-white px-2 py-1 text-sm font-semibold outline-none ring-2 ring-blue-500/20 dark:bg-gray-800"
value={statusName}
disabled={renamingStatus}
aria-label={translate('::ListForms.TodoBoard.ColumnName')}
onChange={(event) => {
setStatusName(event.target.value)
setStatusError('')
}}
onKeyDown={(event) => {
if (event.key === 'Escape') {
setEditingStatus(undefined)
setStatusError('')
}
}}
/>
<button
type="submit"
className="rounded p-1.5 text-emerald-600 hover:bg-emerald-100 disabled:opacity-40 dark:hover:bg-emerald-950"
disabled={renamingStatus || !statusName.trim()}
title={translate('::Save')}
>
<FaCheck />
</button>
<button
type="button"
className="rounded p-1.5 text-gray-400 hover:bg-gray-200 dark:hover:bg-gray-700"
title={translate('::Cancel')}
onClick={() => {
setEditingStatus(undefined)
setStatusError('')
}}
>
<FaTimes />
</button>
</form>
) : (
<>
<h3 className="min-w-0 truncate font-semibold" title={status}>
{status}
</h3>
<button
type="button"
className="rounded p-1 text-gray-400 opacity-0 transition hover:bg-gray-200 hover:text-blue-600 group-hover:opacity-100 dark:hover:bg-gray-700"
title={translate('::ListForms.TodoBoard.RenameColumn')}
onClick={() => {
setEditingStatus(status)
setStatusName(status)
setStatusError('')
}}
>
<FaPencilAlt className="text-xs" />
</button>
</>
)}
<span className="ml-auto rounded-full bg-gray-200 px-2 py-0.5 text-xs dark:bg-gray-700">
{items.length}
</span>
{gridDto.gridOptions.insertServiceAddress && (
<button
type="button"
draggable={false}
className="rounded p-1 text-gray-400 hover:bg-blue-100 hover:text-blue-600 dark:hover:bg-blue-950"
title={translate('::ListForms.TodoBoard.NewTodo')}
onClick={() => {
setNewTodoStatus(status)
setNewTodoTitle('')
}}
>
<FaPlus />
</button>
)}
<button
type="button"
draggable={false}
aria-disabled={items.length > 0}
className={`rounded p-1 transition ${items.length ? 'cursor-not-allowed text-gray-300 dark:text-gray-600' : 'text-gray-400 hover:bg-red-100 hover:text-red-600 dark:hover:bg-red-950'}`}
title={
items.length
? translate('::ListForms.TodoBoard.ColumnWithTodosCannotBeDeleted')
: translate('::ListForms.TodoBoard.DeleteColumn')
}
onClick={() => deleteStatus(status)}
>
<FaTrash className="text-xs" />
</button>
</header>
{editingStatus === status && statusError && (
<p className="mb-2 px-1 text-xs text-red-500">{statusError}</p>
)}
{statusNotice?.status === status && (
<div className="mb-2 flex items-start justify-between gap-2 rounded-lg bg-amber-50 px-2.5 py-2 text-xs text-amber-700 dark:bg-amber-950/40 dark:text-amber-200">
<span>{statusNotice.message}</span>
<button
type="button"
className="shrink-0 rounded p-0.5 hover:bg-amber-100 dark:hover:bg-amber-900"
title={translate('::ListForms.TodoBoard.Close')}
onClick={() => setStatusNotice(undefined)}
>
<FaTimes />
</button>
</div>
)}
<div className="h-1 overflow-hidden rounded-full bg-gray-200 dark:bg-gray-700">
<div
className="h-full bg-blue-500"
style={{ width: `${Math.min(100, items.length * 18)}%` }}
/>
</div>
{isAdding && (
<div className="mt-3 rounded border border-blue-300 bg-white p-2 dark:border-blue-700 dark:bg-gray-800">
<div className="flex gap-1">
<input
autoFocus
value={newTodoTitle}
className="min-w-0 flex-1 rounded border border-gray-300 bg-transparent px-2 py-1 text-sm outline-none focus:border-blue-500 dark:border-gray-600"
placeholder={translate('::ListForms.TodoBoard.TodoTitlePlaceholder')}
onChange={(event) => setNewTodoTitle(event.target.value)}
onKeyDown={(event) => {
if (event.key === 'Enter') addTodo(status)
if (event.key === 'Escape') setNewTodoStatus(undefined)
}}
/>
<button
type="button"
className="rounded bg-blue-600 px-2 text-white disabled:opacity-40"
disabled={!newTodoTitle.trim()}
onClick={() => addTodo(status)}
>
<FaPlus />
</button>
<button
type="button"
className="rounded px-2 text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700"
onClick={() => setNewTodoStatus(undefined)}
>
<FaTimes />
</button>
</div>
</div>
)}
<div className="mt-3 space-y-2">
{items.map((row, index) => {
const key = row[keyField]
const tags = textList(fieldValue(row, options.tagExpr))
const assignees = textList(fieldValue(row, options.assigneeExpr))
const due = fieldValue(row, options.dueDateExpr)
const completed = isCompleted(fieldValue(row, options.completedExpr))
const priority = fieldValue(row, options.priorityExpr)
const isDragged = draggedKey === key
const hiddenAssigneeLabels = assignees
.slice(2)
.map((name) => assigneeOptionMap.get(name)?.label ?? name)
.join(', ')
return (
<Fragment key={String(key)}>
{draggedKey !== undefined &&
todoDropTarget?.status === status &&
todoDropTarget.index === index && (
<div className="flex h-8 items-center gap-2 rounded-lg border-2 border-dashed border-blue-500 bg-blue-100 px-3 text-xs font-semibold text-blue-700 dark:bg-blue-950/60 dark:text-blue-200">
<FaArrowLeft className="rotate-[-90deg]" />{' '}
{translate('::ListForms.TodoBoard.DropHere')}
</div>
)}
<article
draggable={Boolean(options.allowDragging)}
className={`group/card cursor-pointer rounded border bg-white p-3 shadow-sm transition hover:-translate-y-0.5 hover:shadow-md dark:bg-gray-800 ${isDragged ? 'scale-95 border-blue-400 opacity-35' : completed ? 'border-emerald-300 opacity-75 dark:border-emerald-800' : 'border-gray-200 dark:border-gray-700'}`}
onDragStart={(event) => {
event.stopPropagation()
event.dataTransfer.effectAllowed = 'move'
event.dataTransfer.setData('text/todo-key', String(key))
dragActiveRef.current = true
setDraggedColumn(undefined)
setDraggedKey(key)
}}
onDragEnd={clearDragState}
onDragOver={(event) => {
event.preventDefault()
event.stopPropagation()
if (!dragActiveRef.current) return
const bounds = event.currentTarget.getBoundingClientRect()
const dropAfter = event.clientY > bounds.top + bounds.height / 2
setTodoDropTarget({ status, index: index + (dropAfter ? 1 : 0) })
}}
onDrop={(event) => {
event.stopPropagation()
moveTodo(
status,
todoDropTarget?.status === status ? todoDropTarget.index : index,
event.dataTransfer.getData('text/todo-key'),
)
}}
onClick={() => openTodo(row)}
>
<div className="flex items-start gap-2">
<h4 className="font-semibold leading-5">
<span className={completed ? 'line-through text-gray-400' : ''}>
{String(fieldValue(row, options.titleExpr) ?? '')}
</span>
</h4>
<div className="ml-auto flex items-center gap-1">
{gridDto.gridOptions.deleteServiceAddress && (
<button
type="button"
className="hidden rounded p-1 text-gray-400 hover:bg-red-50 hover:text-red-500 group-hover/card:block dark:hover:bg-red-950"
title={translate('::ListForms.TodoBoard.DeleteTodo')}
onClick={(event) => {
event.stopPropagation()
deleteTodo(row)
}}
>
<FaTrash />
</button>
)}
</div>
</div>
{fieldValue(row, options.descriptionExpr) && (
<p className="mt-1 line-clamp-2 text-xs leading-5 text-gray-500 dark:text-gray-400">
{String(fieldValue(row, options.descriptionExpr))}
</p>
)}
{due && (
<div className="mt-1 flex items-center gap-1 text-xs text-gray-500">
<FaClock />
{dayjs(due).fromNow()}
</div>
)}
{options.priorityExpr && (
<div
draggable={false}
className={`mt-2 inline-flex items-center gap-2 rounded-full px-2 py-1 ${priorityClass(priority)}`}
title={translate('::ListForms.TodoBoard.ChangePriority')}
onClick={(event) => event.stopPropagation()}
>
<Rate
allowClear
color="yellow"
gap={2}
size={14}
value={priorityRating(priority)}
onChange={(value) =>
updateTodoInline(row, { [options.priorityExpr!]: value })
}
/>
<span className="text-[10px] font-semibold">
{priorityLabel(priority, translate)}
</span>
</div>
)}
<div className="mt-3 flex items-center gap-2">
<div className="flex min-w-0 flex-1 flex-wrap items-center gap-1.5">
{tags.slice(0, 3).map((tag) => (
<span
key={tag}
className="inline-flex items-center gap-1 rounded-full bg-blue-50 px-2 py-0.5 text-[11px] text-blue-700 dark:bg-blue-950 dark:text-blue-200"
>
<FaTag />
{tag}
</span>
))}
{tags.length > 3 && (
<span className="text-[11px] text-gray-400">
+{tags.length - 3}
</span>
)}
</div>
<div className="flex shrink-0 items-center gap-1">
{assignees.slice(0, 2).map((name) => {
const assignee = assigneeOptionMap.get(name)
const label = assignee?.label ?? name
return (
<Avatar
key={name}
alt={label}
className="shrink-0 bg-indigo-500 text-[8px] font-bold text-white"
shape="circle"
size={20}
src={assignee?.avatarUrl}
title={label}
>
{initials(label)}
</Avatar>
)
})}
{assignees.length > 2 && (
<Avatar
className="bg-gray-200 text-[9px] font-semibold text-gray-600 dark:bg-gray-700 dark:text-gray-200"
shape="circle"
size={20}
title={hiddenAssigneeLabels}
>
+{assignees.length - 2}
</Avatar>
)}
</div>
{options.completedExpr && (
<button
type="button"
draggable={false}
className={`ml-1 shrink-0 rounded-full p-1 text-lg transition ${completed ? 'text-emerald-500 hover:bg-emerald-50 dark:hover:bg-emerald-950' : 'text-gray-400 hover:bg-gray-100 hover:text-emerald-500 dark:hover:bg-gray-700'}`}
title={
completed
? translate('::ListForms.TodoBoard.ReopenTodo')
: translate('::ListForms.TodoBoard.CompleteTodo')
}
onClick={(event) => {
event.stopPropagation()
updateTodoInline(row, { [options.completedExpr!]: !completed })
}}
>
{completed ? <FaCheckCircle /> : <FaRegCircle />}
</button>
)}
</div>
</article>
</Fragment>
)
})}
{draggedKey !== undefined &&
todoDropTarget?.status === status &&
todoDropTarget.index === items.length && (
<div className="flex h-12 items-center justify-center gap-2 rounded-lg border-2 border-dashed border-blue-500 bg-blue-100 text-xs font-semibold text-blue-700 dark:bg-blue-950/60 dark:text-blue-200">
<FaArrowLeft className="rotate-[-90deg]" />{' '}
{translate('::ListForms.TodoBoard.DropHere')}
</div>
)}
{!items.length && draggedKey === undefined && (
<div className="flex min-h-24 items-center justify-center rounded-lg border-2 border-dashed border-gray-200 px-4 text-center text-xs text-gray-400 dark:border-gray-700"></div>
)}
</div>
</section>
)
})}
<section className="group w-[310px] shrink-0 rounded-xl border-2 border-transparent bg-gray-50 p-2 dark:bg-gray-900/60">
<header className="mb-2 flex min-h-9 items-center gap-2 px-1">
<FaGripVertical className="text-gray-300 dark:text-gray-700" />
<h3 className="font-semibold text-gray-500 dark:text-gray-400">
{translate('::ListForms.TodoBoard.NewColumn')}
</h3>
<span className="ml-auto rounded-full bg-gray-200 px-2 py-0.5 text-xs text-gray-500 dark:bg-gray-700 dark:text-gray-400">
0
</span>
<button
type="button"
className="rounded p-1 text-gray-400 hover:bg-blue-100 hover:text-blue-600 dark:hover:bg-blue-950"
title={translate('::ListForms.TodoBoard.AddNewColumn')}
onClick={() => {
setIsAddingStatus(true)
setStatusError('')
}}
>
<FaPlus />
</button>
</header>
<div className="h-1 overflow-hidden rounded-full bg-gray-200 dark:bg-gray-700" />
<div className="mt-3">
{isAddingStatus ? (
<form
className="rounded-lg border border-blue-300 bg-white p-3 shadow-sm dark:border-blue-800 dark:bg-gray-800"
onSubmit={(event) => {
event.preventDefault()
addStatus()
}}
>
<label className="mb-2 block text-sm font-semibold" htmlFor="new-kanban-name">
{translate('::ListForms.TodoBoard.NewColumnName')}
</label>
<input
autoFocus
id="new-kanban-name"
className="w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm outline-none focus:border-blue-500 focus:ring-2 focus:ring-blue-500/20 dark:border-gray-600 dark:bg-gray-800"
placeholder={translate('::ListForms.TodoBoard.NewColumnPlaceholder')}
value={newStatusName}
onChange={(event) => {
setNewStatusName(event.target.value)
setStatusError('')
}}
onKeyDown={(event) => {
if (event.key === 'Escape') {
setIsAddingStatus(false)
setNewStatusName('')
setStatusError('')
}
}}
/>
{statusError && <p className="mt-2 text-xs text-red-500">{statusError}</p>}
<div className="mt-3 flex gap-2">
<button
type="submit"
className="rounded-lg bg-blue-600 px-3 py-2 text-xs font-semibold text-white hover:bg-blue-700 disabled:opacity-40"
disabled={!newStatusName.trim()}
>
{translate('::ListForms.TodoBoard.AddColumn')}
</button>
<button
type="button"
className="rounded-lg px-3 py-2 text-xs font-semibold text-gray-500 hover:bg-gray-200 dark:hover:bg-gray-800"
onClick={() => {
setIsAddingStatus(false)
setNewStatusName('')
setStatusError('')
}}
>
{translate('::Cancel')}
</button>
</div>
</form>
) : (
<button
type="button"
className="flex min-h-24 w-full items-center justify-center gap-2 rounded-lg border-2 border-dashed border-gray-200 px-4 text-sm font-medium text-gray-400 transition hover:border-blue-400 hover:bg-blue-50 hover:text-blue-600 dark:border-gray-700 dark:hover:bg-blue-950/20"
onClick={() => {
setIsAddingStatus(true)
setStatusError('')
}}
>
<FaPlus /> {translate('::ListForms.TodoBoard.AddNewColumn')}
</button>
)}
</div>
</section>
</div>
</div>
</div>
)
}
export default TodoBoard