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 { HtmlEditor, ImageUpload, MediaResizing } from 'devextreme-react/html-editor' import { HtmlEditorToolbar } from '@/views/shared/HtmlEditorToolbarItems' import Toolbar, { Item as ToolbarItem } from 'devextreme-react/toolbar' import TagBox from 'devextreme-react/tag-box' import { AVATAR_URL } from '@/constants/app.constant' import { ROUTES_ENUM } from '@/routes/route.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 { useListFormGridDto } from './shared/hooks' import { Avatar, Rate } from '@/components/ui' import { getUsers } from '@/services/identity.service' import { getTenants } from '@/services/tenant.service' import { useLocalization } from '@/utils/hooks/useLocalization' import { usePermission } from '@/utils/hooks/usePermission' import { usePWA } from '@/utils/hooks/usePWA' import { useListFormColumns } from './useListFormColumns' import type { GridColumnData } from './GridColumnData' import { flattenGridColumns } from './shared/columns' import { useStoreState } from '@/store/store' import Input from '@/components/ui/Input' dayjs.extend(relativeTime) interface TodoBoardProps { listFormCode: string searchParams?: URLSearchParams isSubForm?: boolean level?: number refreshData?: () => Promise 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 } type LookupDisplayValues = Record> const TODO_STATUS_LANGUAGE_KEYS: Record = { backlog: 'App.StaticLookup.Backlog', 'ready to start': 'App.StaticLookup.ReadyToStart', 'in progress': 'App.StaticLookup.InProgress', 'code review': 'App.StaticLookup.CodeReview', testing: 'App.StaticLookup.Testing', staging: 'App.StaticLookup.Staging', } const fieldValue = (row: TodoRow, field?: string) => (field ? row[field] : undefined) const tenantIdValue = (row: TodoRow) => row.TenantId ?? row.tenantId const htmlToPlainText = (value: unknown) => { const html = String(value ?? '') if (!html) return '' if (typeof document === 'undefined') return html.replace(/<[^>]*>/g, ' ') const container = document.createElement('div') container.innerHTML = html return (container.textContent ?? '').replace(/\s+/g, ' ').trim() } 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('::App.ListFormTodoBoard.PriorityRating', { 0: rating }) : translate('::App.ListFormTodoBoard.NoPriority') } const TodoBoard = (props: TodoBoardProps) => { const { listFormCode, searchParams, isSubForm, gridDto: extGridDto } = props // Alt form olarak açıldığında tanım dışarıdan gelmez; kendisi yükler. const gridDto = useListFormGridDto(listFormCode, extGridDto) return gridDto ? ( ) : null } const TodoBoardContent = ({ listFormCode, searchParams, isSubForm, gridDto, }: TodoBoardProps & { gridDto: GridDto }) => { const options = gridDto.gridOptions.todoOptionDto as TodoOptionDto const editingOptions = gridDto.gridOptions.editingOptionDto const canAdd = editingOptions?.allowAdding === true && Boolean(gridDto.gridOptions.insertServiceAddress) const canUpdate = editingOptions?.allowUpdating === true && Boolean(gridDto.gridOptions.updateServiceAddress) const canDelete = editingOptions?.allowDeleting === true && Boolean(gridDto.gridOptions.deleteServiceAddress) const keyField = gridDto.gridOptions.keyFieldName ?? 'id' const userNameField = gridDto.columnFormats?.find((column) => column.fieldName?.toLowerCase() === 'username') ?.fieldName ?? 'UserName' const stateStoring = gridDto.gridOptions.stateStoringDto const currentUserName = useStoreState((state) => state.auth.user.userName) const gridRef = useRef | undefined>(undefined) const { createSelectDataSource } = useListFormCustomDataSource({ gridRef }) const { getBandedColumns, loadLookupDisplayValues } = useListFormColumns({ gridDto, listFormCode, isSubForm, gridRef, }) const getBandedColumnsRef = useRef(getBandedColumns) const loadLookupDisplayValuesRef = useRef(loadLookupDisplayValues) getBandedColumnsRef.current = getBandedColumns loadLookupDisplayValuesRef.current = loadLookupDisplayValues const listFormColumnsCacheRef = useRef<{ gridDto?: GridDto listFormCode: string columns: GridColumnData[] } | undefined>(undefined) if ( !listFormColumnsCacheRef.current || listFormColumnsCacheRef.current.gridDto !== gridDto || listFormColumnsCacheRef.current.listFormCode !== listFormCode ) { listFormColumnsCacheRef.current = { gridDto, listFormCode, columns: flattenGridColumns(getBandedColumnsRef.current()), } } const listFormColumns = listFormColumnsCacheRef.current.columns 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 [tenantNames, setTenantNames] = useState>(new Map()) const [usersLoading, setUsersLoading] = useState(false) const [lookupDisplayValues, setLookupDisplayValues] = useState({}) const columnConfigRef = useRef({ columns: [], retired: [] }) const dragActiveRef = useRef(false) const { translate } = useLocalization() const { checkPermission } = usePermission() const isPwaMode = usePWA() const uncategorized = translate('::App.ListFormTodoBoard.Uncategorized') const lookupValuesForField = useCallback( (field?: string) => { if (!field) return undefined const dataField = listFormColumns.find( (column) => column.dataField?.toLowerCase() === field.toLowerCase(), )?.dataField return dataField ? lookupDisplayValues[dataField] : undefined }, [listFormColumns, lookupDisplayValues], ) const lookupText = useCallback( (field: string | undefined, value: unknown) => lookupValuesForField(field)?.get(String(value)) ?? String(value ?? ''), [lookupValuesForField], ) const statusText = useCallback( (status: string) => { const languageKey = TODO_STATUS_LANGUAGE_KEYS[status.trim().toLocaleLowerCase()] return languageKey ? translate(`::${languageKey}`) : lookupText(options.statusExpr, status) }, [lookupText, options.statusExpr, translate], ) 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 } }, []) useEffect(() => { let active = true setLookupDisplayValues({}) loadLookupDisplayValuesRef .current(listFormColumns) .then((values) => { if (active) setLookupDisplayValues(values) }) .catch((error) => console.error('TodoBoard lookup load error:', error)) return () => { active = false } }, [listFormColumns]) useEffect(() => { let cancelled = false getTenants(0, 1000) .then((response) => { if (cancelled) return setTenantNames( new Map( (response.data?.items ?? []) .filter((tenant) => Boolean(tenant.id && tenant.name)) .map((tenant) => [String(tenant.id).toLowerCase(), tenant.name!] as const), ), ) }) .catch((error) => console.error('Todo tenants load error:', error)) 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[] } const refreshData = async () => { if (!store) return const loadedRows = await loadRows(store) if (!selected) return const refreshed = loadedRows.find((row) => row[keyField] === selected[keyField]) if (refreshed) { setSelected(refreshed) setDraft({ ...refreshed }) } else { setSelected(undefined) } } const openFormManager = () => { window.open( ROUTES_ENUM.protected.saas.listFormManagement.edit.replace(':listFormCode', listFormCode), isPwaMode ? '_self' : '_blank', ) } const renderToolbar = (showBackToBoard = false) => ( {showBackToBoard && ( setSelected(undefined), }} /> )} {checkPermission('App.Listforms.Listform.Update') && ( )} ) 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 lookupValues = lookupValuesForField(options.assigneeExpr) const avatarUrls = new Map(userOptions.map((option) => [option.value, option.avatarUrl])) const configuredOptions = lookupValues ? Array.from(lookupValues, ([value, label]) => ({ value, label, avatarUrl: avatarUrls.get(value), })) : userOptions const knownUsers = new Set(configuredOptions.map((option) => option.value)) return [ ...configuredOptions, ...assigneeSuggestions .filter((value) => !knownUsers.has(value)) .map((value) => ({ value, label: value })), ] }, [assigneeSuggestions, lookupValuesForField, options.assigneeExpr, 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 setColumnConfig(undefined) if (!stateStoring?.enabled) { const emptyConfig = { columns: [], retired: [] } columnConfigRef.current = emptyConfig setColumnConfig(emptyConfig) return () => { cancelled = true } } customLoadStateRef .current() .then((stored) => { if (cancelled) return const config: StoredBoardColumns = Array.isArray(stored?.columns) ? { columns: stored.columns.map(String), retired: Array.isArray(stored?.retired) ? stored.retired.map(String) : [], } : { columns: [], retired: [] } columnConfigRef.current = config setColumnConfig(config) }) .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 (!canUpdate) return 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 = () => { if (!canAdd) return const nextName = newStatusName.trim() if (!nextName) return if (statuses.some((status) => status.toLocaleLowerCase() === nextName.toLocaleLowerCase())) { setStatusError(translate('::App.ListFormTodoBoard.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) => { if (!canDelete) return const itemCount = itemsForStatus(status).length if (itemCount) { setStatusNotice({ status, message: translate('::App.ListFormTodoBoard.ColumnNotEmptyMessage', { 0: itemCount }), }) return } if ( !window.confirm( translate('::App.ListFormTodoBoard.ConfirmDeleteColumn', { 0: statusText(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) => { if (!canUpdate) return const nextName = statusName.trim() if (!nextName || nextName === status) { setEditingStatus(undefined) setStatusError('') return } if ( statuses.some( (item) => item !== status && item.toLocaleLowerCase() === nextName.toLocaleLowerCase(), ) ) { setStatusError(translate('::App.ListFormTodoBoard.DuplicateColumnName')) return } const affectedRows = itemsForStatus(status) if (affectedRows.length && !gridDto.gridOptions.updateServiceAddress) { setStatusError(translate('::App.ListFormTodoBoard.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('::App.ListFormTodoBoard.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 || !canUpdate ) 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 (!canAdd || !title || !store || !options.titleExpr || !options.statusExpr) return const values: Record = { [options.titleExpr]: title, [options.statusExpr]: status, [userNameField]: currentUserName, } 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 (!canDelete || !store) return if ( !window.confirm( translate('::App.ListFormTodoBoard.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 (exitAfterSave = false) => { if (!canUpdate || !selected || !store) 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)), ) setDraft(updated) setSelected(exitAfterSave ? undefined : updated) } finally { setSaving(false) } } const updateTodoInline = async (row: TodoRow, values: Record) => { if (!canUpdate) return 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('::App.ListFormTodoBoard.Loading')}
) if (selected) { 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' const selectedTenantId = tenantIdValue(selected) const selectedTenantName = selectedTenantId ? tenantNames.get(String(selectedTenantId).toLowerCase()) : undefined return (
{renderToolbar(true)}
{editingOptions?.allowUpdating === true && !gridDto.gridOptions.updateServiceAddress && ( {translate('::App.ListFormTodoBoard.EditRequiresUpdateService')} )}
{options.titleExpr && (
setDraft((current) => ({ ...current, [options.titleExpr!]: event.target.value, })) } />
)} {options.descriptionExpr && (
setDraft((current) => ({ ...current, [options.descriptionExpr!]: event.value, })) } >
)}
{options.assigneeExpr && (
{ if (!event.event) return const nextValue = (event.value ?? []).join(', ') setDraft((current) => String(current[options.assigneeExpr!] ?? '') === nextValue ? current : { ...current, [options.assigneeExpr!]: nextValue }, ) }} />
)} {options.tagExpr && (
{ 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 }, ) }} />
)}
{(canUpdate || canDelete) && (
{canDelete && ( )}
{canUpdate && (
)}
)}
) } return (
{renderToolbar()}
{statuses.map((status) => { const items = itemsForStatus(status) const isAdding = newTodoStatus === status return (
{ 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'), ) }} >
{ event.stopPropagation() event.dataTransfer.effectAllowed = 'move' dragActiveRef.current = true setDraggedKey(undefined) setDraggedColumn(status) }} onDragEnd={clearDragState} > {editingStatus === status ? (
{ event.preventDefault() renameStatus(status) }} > { setStatusName(event.target.value) setStatusError('') }} onKeyDown={(event) => { if (event.key === 'Escape') { setEditingStatus(undefined) setStatusError('') } }} />
) : ( <>

{statusText(status)}

{canUpdate && ( )} )} {items.length} {canAdd && ( )} {canDelete && ( )}
{editingStatus === status && statusError && (

{statusError}

)} {statusNotice?.status === status && (
{statusNotice.message}
)}
{isAdding && (
setNewTodoTitle(event.target.value)} onKeyDown={(event) => { if (event.key === 'Enter') addTodo(status) if (event.key === 'Escape') setNewTodoStatus(undefined) }} />
)}
{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 tenantId = tenantIdValue(row) const tenantName = tenantId ? tenantNames.get(String(tenantId).toLowerCase()) : undefined const isDragged = draggedKey === key const hiddenAssigneeLabels = assignees .slice(2) .map((name) => assigneeOptionMap.get(name)?.label ?? name) .join(', ') return ( {draggedKey !== undefined && todoDropTarget?.status === status && todoDropTarget.index === index && (
{' '} {translate('::App.Platform.DropHere')}
)}
{ 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)} >

{String(fieldValue(row, options.titleExpr) ?? '')}

{canDelete && ( )}
{tenantName && (
{tenantName}
)} {fieldValue(row, options.descriptionExpr) && (

{htmlToPlainText(fieldValue(row, options.descriptionExpr))}

)} {due && (
{dayjs(due).fromNow()}
)} {options.priorityExpr && (
event.stopPropagation()} > updateTodoInline(row, { [options.priorityExpr!]: value }) } /> {priorityLabel(priority, translate)}
)}
{tags.slice(0, 3).map((tag) => ( {lookupText(options.tagExpr, tag)} ))} {tags.length > 3 && ( +{tags.length - 3} )}
{assignees.slice(0, 2).map((name) => { const assignee = assigneeOptionMap.get(name) const label = assignee?.label ?? name return ( {initials(label)} ) })} {assignees.length > 2 && ( +{assignees.length - 2} )}
{options.completedExpr && ( )}
) })} {draggedKey !== undefined && todoDropTarget?.status === status && todoDropTarget.index === items.length && (
{' '} {translate('::App.Platform.DropHere')}
)} {!items.length && draggedKey === undefined && (
)}
) })} {canAdd && (

{translate('::App.ListFormTodoBoard.NewColumn')}

0
{isAddingStatus ? (
{ event.preventDefault() addStatus() }} > { setNewStatusName(event.target.value) setStatusError('') }} onKeyDown={(event) => { if (event.key === 'Escape') { setIsAddingStatus(false) setNewStatusName('') setStatusError('') } }} /> {statusError &&

{statusError}

}
) : ( )}
)}
) } export default TodoBoard