sozsoft-platform/ui/src/views/list/TodoBoard.tsx

2128 lines
91 KiB
TypeScript
Raw Normal View History

2026-07-16 14:01:18 +00:00
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'
2026-09-24 08:42:44 +00:00
import { HtmlEditor } from 'devextreme-react/html-editor'
import { htmlEditorDefaults } from '@/views/shared/htmlEditorOptions'
2026-07-30 14:37:15 +00:00
import Toolbar, { Item as ToolbarItem } from 'devextreme-react/toolbar'
2026-07-16 14:01:18 +00:00
import TagBox from 'devextreme-react/tag-box'
import { AVATAR_URL } from '@/constants/app.constant'
2026-07-30 14:37:15 +00:00
import { ROUTES_ENUM } from '@/routes/route.constant'
2026-07-16 14:01:18 +00:00
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,
FaSave,
2026-07-16 14:01:18 +00:00
FaTag,
FaTimes,
FaTrash,
} from 'react-icons/fa'
import { useListFormCustomDataSource } from './useListFormCustomDataSource'
import { useListFormStateStoring } from './useListFormStateStoring'
2026-09-17 09:01:14 +00:00
import { useListFormGridDto } from './shared/hooks'
2026-08-21 19:41:01 +00:00
import { Avatar, Dialog, Rate } from '@/components/ui'
2026-07-16 14:01:18 +00:00
import { getUsers } from '@/services/identity.service'
2026-07-30 14:37:15 +00:00
import { getTenants } from '@/services/tenant.service'
2026-07-16 14:01:18 +00:00
import { useLocalization } from '@/utils/hooks/useLocalization'
2026-07-30 14:37:15 +00:00
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'
2026-08-20 12:01:10 +00:00
import Input from '@/components/ui/Input'
import Select from '@/components/ui/Select'
2026-07-16 14:01:18 +00:00
dayjs.extend(relativeTime)
interface TodoBoardProps {
listFormCode: string
searchParams?: URLSearchParams
isSubForm?: boolean
level?: number
refreshData?: () => Promise<void>
gridDto?: GridDto
2026-07-16 14:01:18 +00:00
}
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
}
type LookupDisplayValues = Record<string, Map<string, string>>
type DetailTab = 'description' | 'timesheet' | 'subtask'
type JsonRow = Record<string, any>
const TODO_STATUS_LANGUAGE_KEYS: Record<string, string> = {
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',
}
2026-07-16 14:01:18 +00:00
const fieldValue = (row: TodoRow, field?: string) => (field ? row[field] : undefined)
2026-07-30 14:37:15 +00:00
const tenantIdValue = (row: TodoRow) => row.TenantId ?? row.tenantId
2026-07-17 20:23:43 +00:00
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()
}
2026-07-16 14:01:18 +00:00
const textList = (value: unknown) =>
Array.isArray(value)
? value.map(String)
: String(value ?? '')
.split(/[,;|]/)
.map((item) => item.trim())
.filter(Boolean)
const parseJsonRows = (value: unknown): JsonRow[] => {
if (Array.isArray(value)) return value.filter((item) => item && typeof item === 'object')
const text = String(value ?? '').trim()
if (!text) return []
try {
const parsed = JSON.parse(text)
return Array.isArray(parsed) ? parsed.filter((item) => item && typeof item === 'object') : []
} catch {
return []
}
}
const hasInvalidJson = (value: unknown) => {
if (Array.isArray(value)) return false
const text = String(value ?? '').trim()
if (!text) return false
try {
return !Array.isArray(JSON.parse(text))
} catch {
return true
}
}
const createRowId = () => `${Date.now().toString(36)}${Math.random().toString(36).slice(2, 8)}`
const formatDuration = (value: unknown) => {
const minutes = Math.max(0, Math.round((Number(value) || 0) * 60))
return `${String(Math.floor(minutes / 60)).padStart(2, '0')}:${String(minutes % 60).padStart(2, '0')}`
}
const timeToDuration = (value: string) => {
const [hours, minutes] = value.split(':')
const total = (Number(hours) || 0) * 60 + (Number(minutes) || 0)
return Math.round(total) / 60
}
2026-07-16 14:01:18 +00:00
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('::App.ListFormTodoBoard.PriorityRating', { 0: rating })
: translate('::App.ListFormTodoBoard.NoPriority')
2026-07-16 14:01:18 +00:00
}
const TodoBoard = (props: TodoBoardProps) => {
const { listFormCode, 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 ? (
<TodoBoardContent {...props} gridDto={gridDto} />
) : null
}
const TodoBoardContent = ({
listFormCode,
searchParams,
isSubForm,
gridDto,
}: TodoBoardProps & { gridDto: GridDto }) => {
2026-07-16 14:01:18 +00:00
const options = gridDto.gridOptions.todoOptionDto as TodoOptionDto
2026-07-30 14:37:15 +00:00
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)
2026-07-16 14:01:18 +00:00
const keyField = gridDto.gridOptions.keyFieldName ?? 'id'
const userNameField =
gridDto.columnFormats?.find((column) => column.fieldName?.toLowerCase() === 'username')
?.fieldName ?? 'UserName'
2026-07-16 14:01:18 +00:00
const stateStoring = gridDto.gridOptions.stateStoringDto
const currentUserName = useStoreState((state) => state.auth.user.userName)
const gridRef = useRef<DataGridRef<any, any> | undefined>(undefined)
2026-09-17 09:01:14 +00:00
// Pano sabit bir yukseklik kaplar, tek scroll kendi icinde olur; sayfa kendisi kaymaz.
// 220px payi ust menu (64px) + liste basligi + toolbar + PageContainer'in her sayfaya
// ekledigi Footer (bkz. components/template/PageContainer.tsx) icin yer birakir; sayfa
// hala kayiyorsa bu deger arttirilir, pano kucuk kaliyorsa azaltilir. Sabit yukseklik
// yalnizca ekran tanimindan gelirse onceliklidir; alt form olarak acildiginda pano kendi
// icerigi kadar yer kaplar, cunku yuksekligi sahibi kapsayici belirler.
const boardHeight =
gridDto.gridOptions.height > 0
? gridDto.gridOptions.height
: isSubForm
? undefined
2026-09-17 09:01:14 +00:00
: 'calc(100dvh - 210px)'
2026-07-16 14:01:18 +00:00
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
2026-07-16 14:01:18 +00:00
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[]>([])
2026-07-30 14:37:15 +00:00
const [tenantNames, setTenantNames] = useState<Map<string, string>>(new Map())
2026-07-16 14:01:18 +00:00
const [usersLoading, setUsersLoading] = useState(false)
const [lookupDisplayValues, setLookupDisplayValues] = useState<LookupDisplayValues>({})
const [detailTab, setDetailTab] = useState<DetailTab>('description')
const focusRowRef = useRef<{ field?: string; index: number } | undefined>(undefined)
2026-07-16 14:01:18 +00:00
const columnConfigRef = useRef<StoredBoardColumns>({ columns: [], retired: [] })
const dragActiveRef = useRef(false)
const { translate } = useLocalization()
2026-07-30 14:37:15 +00:00
const { checkPermission } = usePermission()
const isPwaMode = usePWA()
const uncategorized = translate('::App.ListFormTodoBoard.Uncategorized')
2026-07-16 14:01:18 +00:00
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],
)
2026-07-16 14:01:18 +00:00
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])
2026-07-30 14:37:15 +00:00
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
}
}, [])
2026-07-16 14:01:18 +00:00
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[]
}
2026-07-30 14:37:15 +00:00
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) => (
<Toolbar className="list-view-toolbar-host mt-1">
{showBackToBoard && (
<ToolbarItem
location="before"
widget="dxButton"
locateInMenu="auto"
showText="always"
options={{
icon: 'back',
text: translate('::App.ListFormTodoBoard.BackToBoard'),
hint: translate('::App.ListFormTodoBoard.BackToBoard'),
2026-07-30 14:37:15 +00:00
onClick: () => setSelected(undefined),
}}
/>
)}
2026-08-21 19:41:01 +00:00
{canAdd && (
<ToolbarItem
location="after"
widget="dxButton"
locateInMenu="auto"
showText="always"
options={{
icon: 'plus',
text: translate('::App.ListFormTodoBoard.AddNewColumn'),
hint: translate('::App.ListFormTodoBoard.AddNewColumn'),
onClick: () => {
setIsAddingStatus(true)
setStatusError('')
},
}}
/>
)}
2026-07-30 14:37:15 +00:00
<ToolbarItem
location="after"
widget="dxButton"
locateInMenu="auto"
showText="always"
options={{
icon: 'refresh',
text: translate('::App.Platform.Refresh'),
hint: translate('::App.Platform.Refresh'),
2026-07-30 14:37:15 +00:00
onClick: refreshData,
}}
/>
{checkPermission('App.Listforms.Listform.Update') && (
<ToolbarItem
location="after"
widget="dxButton"
locateInMenu="auto"
showText="always"
options={{
icon: 'preferences',
text: translate('::App.Platform.Manage'),
hint: translate('::App.Platform.Manage'),
2026-07-30 14:37:15 +00:00
onClick: openFormManager,
}}
/>
)}
</Toolbar>
)
2026-07-16 14:01:18 +00:00
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 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))
2026-07-16 14:01:18 +00:00
return [
...configuredOptions,
2026-07-16 14:01:18 +00:00
...assigneeSuggestions
.filter((value) => !knownUsers.has(value))
.map((value) => ({ value, label: value })),
]
}, [assigneeSuggestions, lookupValuesForField, options.assigneeExpr, userOptions])
2026-07-16 14:01:18 +00:00
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 focusNewRow = useCallback(
(field: string | undefined, index: number) => (element: HTMLInputElement | null) => {
const target = focusRowRef.current
if (!element || !target || target.field !== field || target.index !== index) return
focusRowRef.current = undefined
element.focus()
element.scrollIntoView({ block: 'nearest' })
},
[],
)
const setJsonRows = useCallback((field: string | undefined, rows: JsonRow[]) => {
if (!field) return
setDraft((current) => ({ ...current, [field]: rows.length ? JSON.stringify(rows) : null }))
}, [])
const appendJsonRow = useCallback(
(field: string | undefined, rows: JsonRow[], row: JsonRow) => {
focusRowRef.current = { field, index: rows.length }
setJsonRows(field, [...rows, row])
},
[setJsonRows],
)
const updateJsonRow = useCallback(
(field: string | undefined, rows: JsonRow[], index: number, values: JsonRow) =>
setJsonRows(
field,
rows.map((row, item) => (item === index ? { ...row, ...values } : row)),
),
[setJsonRows],
)
2026-07-16 14:01:18 +00:00
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)
2026-07-16 14:01:18 +00:00
? {
columns: stored.columns.map(String),
retired: Array.isArray(stored?.retired) ? stored.retired.map(String) : [],
}
: { columns: [], retired: [] }
2026-07-16 14:01:18 +00:00
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])
2026-09-17 09:01:14 +00:00
// Sutun basina O(n) filtre yerine tum satirlar tek geciste gruplanir; pano n satir ve
// m sutundan olustugunda maliyet O(n*m) yerine O(n) olur.
const rowsByStatus = useMemo(() => {
const grouped = new Map<string, TodoRow[]>()
rows.forEach((row) => {
const status = String(fieldValue(row, options.statusExpr) ?? uncategorized)
const list = grouped.get(status)
if (list) list.push(row)
else grouped.set(status, [row])
})
if (options.orderExpr) {
grouped.forEach((list) =>
list.sort(
(left, right) =>
Number(fieldValue(left, options.orderExpr) ?? 0) -
Number(fieldValue(right, options.orderExpr) ?? 0),
),
)
}
return grouped
}, [rows, options.statusExpr, options.orderExpr, uncategorized])
const itemsForStatus = useCallback(
(status: string) => rowsByStatus.get(status) ?? [],
[rowsByStatus],
)
2026-07-16 14:01:18 +00:00
const reorderColumn = (targetStatus: string) => {
2026-07-30 14:37:15 +00:00
if (!canUpdate) return
2026-07-16 14:01:18 +00:00
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)
}
2026-08-21 19:41:01 +00:00
const closeAddStatus = () => {
setIsAddingStatus(false)
setNewStatusName('')
setStatusError('')
}
2026-07-16 14:01:18 +00:00
const addStatus = () => {
2026-07-30 14:37:15 +00:00
if (!canAdd) return
2026-07-16 14:01:18 +00:00
const nextName = newStatusName.trim()
if (!nextName) return
if (statuses.some((status) => status.toLocaleLowerCase() === nextName.toLocaleLowerCase())) {
setStatusError(translate('::App.ListFormTodoBoard.DuplicateColumnName'))
2026-07-16 14:01:18 +00:00
return
}
const next = [...statuses, nextName]
setStatuses(next)
saveColumnConfig(
next,
columnConfigRef.current.retired.filter((status) => status !== nextName),
)
setNewStatusName('')
setStatusError('')
setIsAddingStatus(false)
}
const deleteStatus = (status: string) => {
2026-07-30 14:37:15 +00:00
if (!canDelete) return
2026-07-16 14:01:18 +00:00
const itemCount = itemsForStatus(status).length
if (itemCount) {
setStatusNotice({
status,
message: translate('::App.ListFormTodoBoard.ColumnNotEmptyMessage', { 0: itemCount }),
2026-07-16 14:01:18 +00:00
})
return
}
if (
!window.confirm(
translate('::App.ListFormTodoBoard.ConfirmDeleteColumn', { 0: statusText(status) }),
)
)
2026-07-16 14:01:18 +00:00
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) => {
2026-07-30 14:37:15 +00:00
if (!canUpdate) return
2026-07-16 14:01:18 +00:00
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'))
2026-07-16 14:01:18 +00:00
return
}
const affectedRows = itemsForStatus(status)
if (affectedRows.length && !gridDto.gridOptions.updateServiceAddress) {
setStatusError(translate('::App.ListFormTodoBoard.RenameRequiresUpdateService'))
2026-07-16 14:01:18 +00:00
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'))
2026-07-16 14:01:18 +00:00
} finally {
setRenamingStatus(false)
}
}
const moveTodo = async (status: string, targetIndex: number, transferredKey?: string) => {
const effectiveKey =
draggedKey ?? rows.find((row) => String(row[keyField]) === transferredKey)?.[keyField]
2026-07-30 14:37:15 +00:00
if (
effectiveKey === undefined ||
!store ||
!options.statusExpr ||
!options.allowDragging ||
!canUpdate
)
2026-07-16 14:01:18 +00:00
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()
2026-07-30 14:37:15 +00:00
if (!canAdd || !title || !store || !options.titleExpr || !options.statusExpr) return
2026-07-16 14:01:18 +00:00
const values: Record<string, any> = {
[options.titleExpr]: title,
[options.statusExpr]: status,
[userNameField]: currentUserName,
2026-07-16 14:01:18 +00:00
}
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) => {
2026-07-30 14:37:15 +00:00
if (!canDelete || !store) return
2026-07-16 14:01:18 +00:00
if (
!window.confirm(
translate('::App.ListFormTodoBoard.ConfirmDeleteTodo', {
2026-07-16 14:01:18 +00:00
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 })
setDetailTab('description')
2026-07-16 14:01:18 +00:00
}
2026-07-30 14:37:15 +00:00
const saveTodo = async (exitAfterSave = false) => {
if (!canUpdate || !selected || !store) return
2026-07-16 14:01:18 +00:00
const mappedFields = [
options.titleExpr,
options.statusExpr,
options.descriptionExpr,
options.dueDateExpr,
options.tagExpr,
options.assigneeExpr,
options.priorityExpr,
options.completedExpr,
options.orderExpr,
options.timesheetExpr,
options.subTaskExpr,
2026-07-16 14:01:18 +00:00
].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)
2026-07-30 14:37:15 +00:00
setSelected(exitAfterSave ? undefined : updated)
2026-07-16 14:01:18 +00:00
} finally {
setSaving(false)
}
}
const updateTodoInline = async (row: TodoRow, values: Record<string, any>) => {
2026-07-30 14:37:15 +00:00
if (!canUpdate) return
2026-07-16 14:01:18 +00:00
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('::App.ListFormTodoBoard.Loading')}
2026-07-16 14:01:18 +00:00
</div>
)
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 cellInputClass =
'w-full rounded-md border border-gray-300 bg-white px-2 py-1.5 text-sm text-gray-700 outline-none transition focus:border-blue-500 focus:ring-1 focus:ring-blue-500/20 disabled:bg-gray-100 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100 dark:disabled:bg-gray-900'
const stickyHeadCellClass =
'sticky top-0 z-10 border-b border-gray-200 bg-white px-3 py-2 text-left text-[11px] font-semibold uppercase tracking-wide text-gray-500 dark:border-gray-700 dark:bg-gray-900'
const stickyFootCellClass =
'sticky bottom-0 z-10 border-t border-gray-200 bg-gray-50 px-3 py-2.5 dark:border-gray-700 dark:bg-gray-800/80'
2026-07-30 14:37:15 +00:00
const selectedTenantId = tenantIdValue(selected)
const selectedTenantName = selectedTenantId
? tenantNames.get(String(selectedTenantId).toLowerCase())
: undefined
const detailTabs = [
options.descriptionExpr
? {
key: 'description' as DetailTab,
label: translate('::App.Listform.ListformField.Description'),
}
: undefined,
options.timesheetExpr
? {
key: 'timesheet' as DetailTab,
label: translate('::App.ListFormTodoBoard.Timesheets'),
}
: undefined,
options.subTaskExpr
? { key: 'subtask' as DetailTab, label: translate('::App.ListFormTodoBoard.SubTasks') }
: undefined,
].filter(Boolean) as Array<{ key: DetailTab; label: string }>
const activeTab = detailTabs.some((tab) => tab.key === detailTab) ? detailTab : detailTabs[0]?.key
const timesheetRows = parseJsonRows(fieldValue(draft, options.timesheetExpr))
const subTaskRows = parseJsonRows(fieldValue(draft, options.subTaskExpr))
const timesheetTotal = timesheetRows.reduce(
(total, row) => total + (Number(row.duration) || 0),
0,
)
2026-07-16 14:01:18 +00:00
return (
<section className="min-h-[420px] rounded-xl">
2026-07-30 14:37:15 +00:00
{renderToolbar(true)}
<div className="mb-5 flex flex-wrap items-center justify-end gap-2 border-b border-gray-200 pb-3 dark:border-gray-700">
{editingOptions?.allowUpdating === true && !gridDto.gridOptions.updateServiceAddress && (
<span className="mr-auto text-xs text-amber-600">
{translate('::App.ListFormTodoBoard.EditRequiresUpdateService')}
</span>
)}
{options.statusExpr && (
<div className="flex flex-wrap items-center justify-end gap-1.5">
{statuses.map((status) => {
const isActiveStatus = String(draft[options.statusExpr!] ?? '') === status
return (
<button
key={status}
type="button"
disabled={!canUpdate}
title={statusText(status)}
className={`rounded-full border px-4 py-1.5 text-xs font-semibold transition disabled:cursor-not-allowed disabled:opacity-60 ${isActiveStatus ? 'border-emerald-500 bg-emerald-500 text-white shadow-sm' : 'border-gray-300 bg-white text-gray-600 hover:border-emerald-400 hover:text-emerald-600 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-300'}`}
onClick={() =>
setDraft((current) => ({ ...current, [options.statusExpr!]: status }))
}
>
{statusText(status)}
</button>
)
})}
</div>
)}
2026-07-16 14:01:18 +00:00
</div>
<div className="grid items-stretch gap-5 lg:grid-cols-3">
<div className="flex flex-col gap-5 lg:col-span-2">
2026-07-16 14:01:18 +00:00
{options.titleExpr && (
<div>
<label className={labelClass}>{translate('::App.Listform.ListformField.Title')}</label>
2026-08-20 12:01:10 +00:00
<Input
unstyle
2026-07-16 14:01:18 +00:00
className={`${inputClass} text-lg font-semibold`}
2026-07-30 14:37:15 +00:00
disabled={!canUpdate}
2026-07-16 14:01:18 +00:00
value={String(draft[options.titleExpr] ?? '')}
onChange={(event) =>
setDraft((current) => ({
...current,
[options.titleExpr!]: event.target.value,
}))
}
/>
</div>
)}
{detailTabs.length > 0 && (
<div className="flex min-h-0 flex-1 flex-col">
<div className="flex flex-wrap gap-1 border-b border-gray-200 dark:border-gray-700">
{detailTabs.map((tab) => (
<button
key={tab.key}
type="button"
className={`-mb-px border-b-2 px-4 py-2 text-sm font-medium transition ${activeTab === tab.key ? 'border-blue-600 text-blue-600 dark:border-blue-400 dark:text-blue-400' : 'border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700 dark:text-gray-400 dark:hover:border-gray-600 dark:hover:text-gray-200'}`}
onClick={() => setDetailTab(tab.key)}
>
{tab.label}
</button>
))}
2026-07-16 14:01:18 +00:00
</div>
<div className="flex min-h-[260px] flex-1 flex-col pt-4">
{activeTab === 'description' && options.descriptionExpr && (
<HtmlEditor
2026-09-24 08:42:44 +00:00
{...htmlEditorDefaults}
height="100%"
placeholder={translate('::App.ListFormTodoBoard.DescriptionPlaceholder')}
readOnly={!canUpdate}
value={String(draft[options.descriptionExpr] ?? '')}
onValueChanged={(event) =>
setDraft((current) => ({
...current,
[options.descriptionExpr!]: event.value,
}))
}
2026-09-24 08:42:44 +00:00
/>
)}
{activeTab === 'timesheet' && options.timesheetExpr && (
<div className="flex min-h-0 flex-1 flex-col gap-2">
{hasInvalidJson(fieldValue(draft, options.timesheetExpr)) && (
<p className="text-xs text-amber-600">
{translate('::App.ListFormTodoBoard.InvalidJson')}
</p>
)}
<div className="min-h-0 flex-1 overflow-auto rounded-lg border border-gray-200 dark:border-gray-700 max-h-[34vh]">
<table className="w-full min-w-[640px] border-collapse">
<thead>
<tr className="border-b border-gray-200 dark:border-gray-700">
<th className={`${stickyHeadCellClass} w-36`}>
{translate('::App.ListFormTodoBoard.TimesheetDate')}
</th>
<th className={`${stickyHeadCellClass} w-56`}>
{translate('::App.ListFormTodoBoard.TimesheetEmployee')}
</th>
<th className={stickyHeadCellClass}>
{translate('::App.Listform.ListformField.Description')}
</th>
<th className={`${stickyHeadCellClass} w-32 text-right`}>
{translate('::App.ListFormTodoBoard.TimesheetDuration')}
</th>
<th className={`${stickyHeadCellClass} w-10`} />
</tr>
</thead>
<tbody>
{timesheetRows.map((row, index) => (
<tr
key={String(row.id ?? index)}
className="border-b border-gray-100 last:border-b-0 dark:border-gray-800"
>
<td className="px-1 py-1">
<Input
unstyle
type="date"
className={cellInputClass}
disabled={!canUpdate}
value={row.date ? dayjs(row.date).format('YYYY-MM-DD') : ''}
onChange={(event) =>
updateJsonRow(options.timesheetExpr, timesheetRows, index, {
date: event.target.value || null,
})
}
/>
</td>
<td className="px-1 py-1">
<Select
isClearable
size="sm"
isDisabled={!canUpdate}
menuPortalTarget={document.body}
options={assigneeOptions}
value={assigneeOptions.filter(
(option) => option.value === String(row.userName ?? ''),
)}
onChange={(option) =>
updateJsonRow(options.timesheetExpr, timesheetRows, index, {
userName: option?.value ?? '',
})
}
/>
</td>
<td className="px-1 py-1">
<Input
ref={focusNewRow(options.timesheetExpr, index)}
unstyle
className={cellInputClass}
disabled={!canUpdate}
value={String(row.description ?? '')}
onChange={(event) =>
updateJsonRow(options.timesheetExpr, timesheetRows, index, {
description: event.target.value,
})
}
/>
</td>
<td className="px-1 py-1">
<Input
unstyle
type="time"
className={`${cellInputClass} text-right`}
disabled={!canUpdate}
value={Number(row.duration) ? formatDuration(row.duration) : ''}
onChange={(event) =>
updateJsonRow(options.timesheetExpr, timesheetRows, index, {
duration: timeToDuration(event.target.value),
})
}
/>
</td>
<td className="px-1 py-1 text-center">
{canUpdate && (
<button
type="button"
className="rounded p-1.5 text-gray-400 hover:bg-red-50 hover:text-red-500 dark:hover:bg-red-950"
title={translate('::App.ListFormTodoBoard.DeleteRow')}
onClick={() =>
setJsonRows(
options.timesheetExpr,
timesheetRows.filter((_, item) => item !== index),
)
}
>
<FaTrash className="text-xs" />
</button>
)}
</td>
</tr>
))}
{!timesheetRows.length && (
<tr>
<td colSpan={5} className="px-3 py-6 text-center text-xs text-gray-400">
{translate('::App.ListFormTodoBoard.NoRows')}
</td>
</tr>
)}
</tbody>
{timesheetRows.length > 0 && (
<tfoot>
<tr className="border-t border-gray-200 dark:border-gray-700">
<td
className={`${stickyFootCellClass} text-right text-[11px] font-semibold uppercase tracking-wide text-gray-500`}
colSpan={3}
>
{translate('::App.ListFormTodoBoard.TimesheetTotal')}
</td>
<td className={`${stickyFootCellClass} text-right`}>
<span className="inline-block rounded-md bg-gray-200 px-2 py-1 text-sm font-semibold tabular-nums text-gray-800 dark:bg-gray-700 dark:text-gray-100">
{formatDuration(timesheetTotal)}
</span>
</td>
<td className={stickyFootCellClass} />
</tr>
</tfoot>
)}
</table>
</div>
{canUpdate && (
<button
type="button"
className="inline-flex w-fit items-center gap-2 self-start rounded-lg px-3 py-1.5 text-xs font-semibold text-blue-600 transition hover:bg-blue-50 dark:text-blue-400 dark:hover:bg-blue-950/40"
onClick={() =>
appendJsonRow(options.timesheetExpr, timesheetRows, {
id: createRowId(),
date: dayjs().format('YYYY-MM-DD'),
userName: currentUserName ?? '',
description: '',
duration: 0,
})
}
>
<FaPlus />
{translate('::App.ListFormTodoBoard.AddRow')}
</button>
)}
</div>
)}
{activeTab === 'subtask' && options.subTaskExpr && (
<div className="flex min-h-0 flex-1 flex-col gap-2">
{hasInvalidJson(fieldValue(draft, options.subTaskExpr)) && (
<p className="text-xs text-amber-600">
{translate('::App.ListFormTodoBoard.InvalidJson')}
</p>
)}
<div className="min-h-0 flex-1 overflow-auto rounded-lg border border-gray-200 dark:border-gray-700 max-h-[34vh]">
<table className="w-full min-w-[640px] border-collapse">
<thead>
<tr className="border-b border-gray-200 dark:border-gray-700">
<th className={`${stickyHeadCellClass} w-12 text-center`}>
<FaCheck className="mx-auto text-xs" />
</th>
<th className={stickyHeadCellClass}>
{translate('::App.ListFormTodoBoard.SubTaskTitle')}
</th>
<th className={`${stickyHeadCellClass} w-56`}>
{translate('::App.ListFormTodoBoard.SubTaskAssignee')}
</th>
<th className={`${stickyHeadCellClass} w-36`}>
{translate('::App.ListFormTodoBoard.SubTaskDueDate')}
</th>
<th className={`${stickyHeadCellClass} w-10`} />
</tr>
</thead>
<tbody>
{subTaskRows.map((row, index) => (
<tr
key={String(row.id ?? index)}
className="border-b border-gray-100 last:border-b-0 dark:border-gray-800"
>
<td className="px-3 py-1 text-center">
<Input
unstyle
type="checkbox"
className="h-4 w-4 accent-emerald-500"
disabled={!canUpdate}
checked={isCompleted(row.completed)}
onChange={(event) =>
updateJsonRow(options.subTaskExpr, subTaskRows, index, {
completed: event.target.checked,
})
}
/>
</td>
<td className="px-1 py-1">
<Input
ref={focusNewRow(options.subTaskExpr, index)}
unstyle
className={`${cellInputClass} ${isCompleted(row.completed) ? 'text-gray-400 line-through' : ''}`}
disabled={!canUpdate}
value={String(row.title ?? '')}
onChange={(event) =>
updateJsonRow(options.subTaskExpr, subTaskRows, index, {
title: event.target.value,
})
}
/>
</td>
<td className="px-1 py-1">
<Select
isClearable
size="sm"
isDisabled={!canUpdate}
menuPortalTarget={document.body}
options={assigneeOptions}
value={assigneeOptions.filter(
(option) => option.value === String(row.assignee ?? ''),
)}
onChange={(option) =>
updateJsonRow(options.subTaskExpr, subTaskRows, index, {
assignee: option?.value ?? '',
})
}
/>
</td>
<td className="px-1 py-1">
<Input
unstyle
type="date"
className={cellInputClass}
disabled={!canUpdate}
value={row.dueDate ? dayjs(row.dueDate).format('YYYY-MM-DD') : ''}
onChange={(event) =>
updateJsonRow(options.subTaskExpr, subTaskRows, index, {
dueDate: event.target.value || null,
})
}
/>
</td>
<td className="px-1 py-1 text-center">
{canUpdate && (
<button
type="button"
className="rounded p-1.5 text-gray-400 hover:bg-red-50 hover:text-red-500 dark:hover:bg-red-950"
title={translate('::App.ListFormTodoBoard.DeleteRow')}
onClick={() =>
setJsonRows(
options.subTaskExpr,
subTaskRows.filter((_, item) => item !== index),
)
}
>
<FaTrash className="text-xs" />
</button>
)}
</td>
</tr>
))}
{!subTaskRows.length && (
<tr>
<td colSpan={5} className="px-3 py-6 text-center text-xs text-gray-400">
{translate('::App.ListFormTodoBoard.NoRows')}
</td>
</tr>
)}
</tbody>
</table>
</div>
{canUpdate && (
<button
type="button"
className="inline-flex w-fit items-center gap-2 self-start rounded-lg px-3 py-1.5 text-xs font-semibold text-blue-600 transition hover:bg-blue-50 dark:text-blue-400 dark:hover:bg-blue-950/40"
onClick={() =>
appendJsonRow(options.subTaskExpr, subTaskRows, {
id: createRowId(),
title: '',
assignee: '',
dueDate: null,
completed: false,
})
}
>
<FaPlus />
{translate('::App.ListFormTodoBoard.AddRow')}
</button>
)}
</div>
)}
2026-07-16 14:01:18 +00:00
</div>
</div>
)}
2026-07-16 14:01:18 +00:00
</div>
<aside className="space-y-4 rounded-xl bg-gray-50 p-4 dark:bg-gray-800/60">
2026-07-30 14:37:15 +00:00
{selectedTenantId && (
<div>
<label className={labelClass}>Tenant</label>
2026-08-20 12:01:10 +00:00
<Input
unstyle
2026-07-30 14:37:15 +00:00
readOnly
className={`${inputClass} cursor-default`}
value={selectedTenantName ?? ''}
/>
</div>
)}
<div>
<label className={labelClass}>
{translate('::Abp.Mailing.Smtp.UserName')}
</label>
2026-08-20 12:01:10 +00:00
<Input
unstyle
readOnly
className={`${inputClass} cursor-default`}
value={String(draft[userNameField] ?? '')}
/>
</div>
{options.assigneeExpr && (
2026-07-16 14:01:18 +00:00
<div>
<label className={labelClass}>
{translate('::App.ListFormTodoBoard.Assignees')}
</label>
<TagBox
searchEnabled
showClearButton
showSelectionControls
disabled={!canUpdate || usersLoading}
displayExpr="label"
items={assigneeOptions}
valueExpr="value"
value={draftAssignees}
applyValueMode="instantly"
placeholder={
usersLoading
? translate('::App.ListFormTodoBoard.UsersLoading')
: translate('::App.ListFormTodoBoard.SelectUser')
2026-07-16 14:01:18 +00:00
}
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('::App.Listform.ListformField.Tags')}</label>
<TagBox
acceptCustomValue
searchEnabled
showClearButton
showSelectionControls
disabled={!canUpdate}
items={tagSuggestions}
value={draftTags}
applyValueMode="instantly"
placeholder={translate('::App.ListFormTodoBoard.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 },
)
}}
/>
2026-07-16 14:01:18 +00:00
</div>
)}
{options.dueDateExpr && (
<div>
<label className={labelClass}>{translate('::App.ListFormTodoBoard.DueDate')}</label>
2026-08-20 12:01:10 +00:00
<Input
unstyle
2026-07-16 14:01:18 +00:00
type="datetime-local"
className={inputClass}
2026-07-30 14:37:15 +00:00
disabled={!canUpdate}
2026-07-16 14:01:18 +00:00
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('::App.Listform.ListformField.Priority')}</label>
2026-07-16 14:01:18 +00:00
<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"
2026-07-30 14:37:15 +00:00
disabled={!canUpdate}
2026-07-16 14:01:18 +00:00
gap={7}
size={24}
tooltips={[
translate('::App.ListFormTodoBoard.PriorityVeryLow'),
translate('::App.ListFormTodoBoard.PriorityLow'),
translate('::App.ListFormTodoBoard.PriorityMedium'),
translate('::App.ListFormTodoBoard.PriorityHigh'),
translate('::App.Listform.ListformField.IsUrgent'),
2026-07-16 14:01:18 +00:00
]}
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">
2026-08-20 12:01:10 +00:00
<Input
unstyle
2026-07-16 14:01:18 +00:00
type="checkbox"
className="h-4 w-4 accent-emerald-500"
2026-07-30 14:37:15 +00:00
disabled={!canUpdate}
2026-07-16 14:01:18 +00:00
checked={isCompleted(draft[options.completedExpr])}
onChange={(event) =>
setDraft((current) => ({
...current,
[options.completedExpr!]: event.target.checked,
}))
}
/>
{translate('::App.Listform.ListformField.Completed')}
2026-07-16 14:01:18 +00:00
</label>
)}
</aside>
</div>
2026-07-30 14:37:15 +00:00
{(canUpdate || canDelete) && (
<div className="mt-6 flex items-center justify-between gap-3 border-t border-gray-200 pt-4 dark:border-gray-700">
<div>
{canDelete && (
<button
type="button"
className="inline-flex items-center gap-2 rounded-lg border border-red-300 px-4 py-2 text-sm font-semibold text-red-600 hover:bg-red-50 dark:border-red-800 dark:text-red-400 dark:hover:bg-red-950"
onClick={() => deleteTodo(selected)}
>
<FaTrash />
{translate('::App.Platform.Delete')}
2026-07-30 14:37:15 +00:00
</button>
)}
</div>
{canUpdate && (
<div className="flex items-center gap-2">
<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('::App.Platform.UndoChanges')}
2026-07-30 14:37:15 +00:00
</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('::App.Platform.Saving') : translate('::App.Platform.Save')}
2026-07-30 14:37:15 +00:00
</button>
<button
type="button"
className="rounded-lg bg-emerald-600 px-5 py-2 text-sm font-semibold text-white hover:bg-emerald-700 disabled:opacity-50"
disabled={saving || !String(draft[options.titleExpr ?? ''] ?? '').trim()}
onClick={() => saveTodo(true)}
>
{saving ? translate('::App.Platform.Saving') : translate('::App.ListFormTodoBoard.SaveAndExit')}
2026-07-30 14:37:15 +00:00
</button>
</div>
)}
2026-07-16 14:01:18 +00:00
</div>
)}
</section>
)
}
return (
<div className="flex flex-col pb-3">
2026-07-30 14:37:15 +00:00
{renderToolbar()}
2026-09-17 09:01:14 +00:00
<div className="min-h-full overflow-auto" style={{ height: boardHeight }}>
<div className="flex min-w-max">
2026-07-16 14:01:18 +00:00
{statuses.map((status) => {
const items = itemsForStatus(status)
const isAdding = newTodoStatus === status
2026-09-17 09:01:14 +00:00
const columnBorderClass =
draggedColumn !== undefined && columnDropTarget === status
? 'border-violet-400 shadow-lg'
: draggedKey !== undefined && todoDropTarget?.status === status
? 'border-blue-400 shadow-lg'
: 'border-transparent'
const columnBgClass =
draggedColumn !== undefined && columnDropTarget === status
? 'bg-violet-50 dark:bg-violet-950/30'
: draggedKey !== undefined && todoDropTarget?.status === status
? 'bg-blue-50/70 dark:bg-blue-950/20'
: 'bg-gray-50 dark:bg-gray-900/60'
// Sticky basligin arkasinda kayan kartlar gorunmemeli; bu yuzden opak bir renk
// kullanilir, columnBgClass'taki yari saydam tonlar burada kullanilmaz.
const columnHeaderBgClass =
draggedColumn !== undefined && columnDropTarget === status
? 'bg-violet-50 dark:bg-violet-950'
: draggedKey !== undefined && todoDropTarget?.status === status
? 'bg-blue-50 dark:bg-blue-950'
: 'bg-gray-50 dark:bg-gray-900'
2026-07-16 14:01:18 +00:00
return (
<section
key={status}
2026-09-17 09:01:14 +00:00
className={`group w-[310px] shrink-0 self-stretch rounded-xl border-2 p-2 transition-all ${columnBorderClass} ${columnBgClass} ${draggedColumn === status ? 'opacity-50' : ''}`}
2026-07-16 14:01:18 +00:00
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'),
)
}}
>
2026-09-17 09:01:14 +00:00
<div className={`sticky top-0 z-10 -mx-2 -mt-2 rounded-t-xl px-2 pb-1 pt-2 ${columnHeaderBgClass}`}>
2026-07-16 14:01:18 +00:00
<header className="mb-2 flex min-h-9 items-center gap-2 px-1">
<span
2026-07-30 14:37:15 +00:00
draggable={canUpdate}
className={`rounded p-1 text-gray-400 ${canUpdate ? 'cursor-grab hover:bg-gray-200 hover:text-gray-600 active:cursor-grabbing dark:hover:bg-gray-700' : 'cursor-default opacity-50'}`}
title={translate('::App.ListFormTodoBoard.ReorderColumn')}
2026-07-16 14:01:18 +00:00
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)
}}
>
2026-08-20 12:01:10 +00:00
<Input
unstyle
2026-07-16 14:01:18 +00:00
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('::App.ListFormTodoBoard.ColumnName')}
2026-07-16 14:01:18 +00:00
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('::App.Platform.Save')}
2026-07-16 14:01:18 +00:00
>
<FaCheck />
</button>
<button
type="button"
className="rounded p-1.5 text-gray-400 hover:bg-gray-200 dark:hover:bg-gray-700"
title={translate('::App.Platform.Cancel')}
2026-07-16 14:01:18 +00:00
onClick={() => {
setEditingStatus(undefined)
setStatusError('')
}}
>
<FaTimes />
</button>
</form>
) : (
<>
<h4 className="min-w-0 truncate font-sm" title={statusText(status)}>
{statusText(status)}
2026-07-17 20:23:43 +00:00
</h4>
2026-07-30 14:37:15 +00:00
{canUpdate && (
<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('::App.ListFormTodoBoard.RenameColumn')}
2026-07-30 14:37:15 +00:00
onClick={() => {
setEditingStatus(status)
setStatusName(status)
setStatusError('')
}}
>
<FaPencilAlt className="text-xs" />
</button>
)}
2026-07-16 14:01:18 +00:00
</>
)}
<span className="ml-auto rounded-full bg-gray-200 px-2 py-0.5 text-xs dark:bg-gray-700">
{items.length}
</span>
2026-07-30 14:37:15 +00:00
{canAdd && (
2026-07-16 14:01:18 +00:00
<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('::App.ListFormTodoBoard.NewTodo')}
2026-07-16 14:01:18 +00:00
onClick={() => {
setNewTodoStatus(status)
setNewTodoTitle('')
}}
>
<FaPlus />
</button>
)}
2026-07-30 14:37:15 +00:00
{canDelete && (
<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('::App.ListFormTodoBoard.ColumnWithTodosCannotBeDeleted')
: translate('::App.ListFormTodoBoard.DeleteColumn')
2026-07-30 14:37:15 +00:00
}
onClick={() => deleteStatus(status)}
>
<FaTrash className="text-xs" />
</button>
)}
2026-07-16 14:01:18 +00:00
</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('::App.Platform.Close')}
2026-07-16 14:01:18 +00:00
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">
2026-08-20 12:01:10 +00:00
<Input
unstyle
2026-07-16 14:01:18 +00:00
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('::App.ListFormTodoBoard.TodoTitlePlaceholder')}
2026-07-16 14:01:18 +00:00
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()}
title={translate('::App.Platform.Save')}
2026-07-16 14:01:18 +00:00
onClick={() => addTodo(status)}
>
<FaSave />
2026-07-16 14:01:18 +00:00
</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>
)}
2026-09-17 09:01:14 +00:00
</div>
<div className="mt-3 space-y-2">
2026-07-16 14:01:18 +00:00
{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)
2026-07-30 14:37:15 +00:00
const tenantId = tenantIdValue(row)
const tenantName = tenantId
? tenantNames.get(String(tenantId).toLowerCase())
: undefined
2026-07-16 14:01:18 +00:00
const isDragged = draggedKey === key
const cardSubTasks = parseJsonRows(fieldValue(row, options.subTaskExpr))
const cardSubTasksDone = cardSubTasks.filter((item) =>
isCompleted(item.completed),
).length
const cardTimesheetTotal = parseJsonRows(
fieldValue(row, options.timesheetExpr),
).reduce((total, item) => total + (Number(item.duration) || 0), 0)
2026-07-16 14:01:18 +00:00
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('::App.Platform.DropHere')}
2026-07-16 14:01:18 +00:00
</div>
)}
<article
2026-07-30 14:37:15 +00:00
draggable={Boolean(options.allowDragging && canUpdate)}
2026-07-16 14:01:18 +00:00
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">
2026-07-30 14:37:15 +00:00
{canDelete && (
2026-07-16 14:01:18 +00:00
<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('::App.ListFormTodoBoard.DeleteTodo')}
2026-07-16 14:01:18 +00:00
onClick={(event) => {
event.stopPropagation()
deleteTodo(row)
}}
>
<FaTrash />
</button>
)}
</div>
</div>
2026-07-30 14:37:15 +00:00
{tenantName && (
<div
className="mt-1 text-[11px] text-gray-500 dark:text-gray-400"
title={tenantName}
>
{tenantName}
</div>
)}
2026-07-16 14:01:18 +00:00
{fieldValue(row, options.descriptionExpr) && (
<p className="mt-1 line-clamp-2 text-xs leading-5 text-gray-500 dark:text-gray-400">
2026-07-17 20:23:43 +00:00
{htmlToPlainText(fieldValue(row, options.descriptionExpr))}
2026-07-16 14:01:18 +00:00
</p>
)}
{due && (
<div className="mt-1 flex items-center gap-1 text-xs text-gray-500">
<FaClock />
{dayjs(due).fromNow()}
</div>
)}
{(cardSubTasks.length > 0 || cardTimesheetTotal > 0) && (
<div className="mt-1 flex flex-wrap items-center gap-2 text-[11px] text-gray-500 dark:text-gray-400">
{cardSubTasks.length > 0 && (
<span
className="inline-flex items-center gap-1"
title={translate('::App.ListFormTodoBoard.SubTasks')}
>
<FaCheck className="text-[9px]" />
{cardSubTasksDone}/{cardSubTasks.length}
</span>
)}
{cardTimesheetTotal > 0 && (
<span
className="inline-flex items-center gap-1"
title={translate('::App.ListFormTodoBoard.Timesheets')}
>
<FaClock className="text-[9px]" />
{formatDuration(cardTimesheetTotal)}
</span>
)}
</div>
)}
2026-07-16 14:01:18 +00:00
{options.priorityExpr && (
<div
draggable={false}
className={`mt-2 inline-flex items-center gap-2 rounded-full px-2 py-1 ${priorityClass(priority)}`}
title={translate('::App.ListFormTodoBoard.ChangePriority')}
2026-07-16 14:01:18 +00:00
onClick={(event) => event.stopPropagation()}
>
<Rate
allowClear
color="yellow"
2026-07-30 14:37:15 +00:00
disabled={!canUpdate}
2026-07-16 14:01:18 +00:00
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 />
{lookupText(options.tagExpr, tag)}
2026-07-16 14:01:18 +00:00
</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}
2026-07-30 14:37:15 +00:00
disabled={!canUpdate}
2026-07-16 14:01:18 +00:00
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('::App.ListFormTodoBoard.ReopenTodo')
: translate('::App.ListFormTodoBoard.CompleteTodo')
2026-07-16 14:01:18 +00:00
}
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('::App.Platform.DropHere')}
2026-07-16 14:01:18 +00:00
</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>
)
})}
2026-07-30 14:37:15 +00:00
{canAdd && (
2026-08-21 19:41:01 +00:00
<Dialog
isOpen={isAddingStatus}
width={420}
onClose={closeAddStatus}
onRequestClose={closeAddStatus}
>
<h5 className="mb-4">{translate('::App.ListFormTodoBoard.AddNewColumn')}</h5>
<form
onSubmit={(event) => {
event.preventDefault()
addStatus()
}}
>
<label className="mb-2 block text-sm font-semibold" htmlFor="new-kanban-name">
{translate('::App.ListFormTodoBoard.NewColumnName')}
</label>
<Input
unstyle
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('::App.ListFormTodoBoard.NewColumnPlaceholder')}
value={newStatusName}
onChange={(event) => {
setNewStatusName(event.target.value)
2026-07-16 14:01:18 +00:00
setStatusError('')
}}
2026-08-21 19:41:01 +00:00
/>
{statusError && <p className="mt-2 text-xs text-red-500">{statusError}</p>}
<div className="mt-6 flex justify-end gap-2">
2026-07-30 14:37:15 +00:00
<button
type="button"
2026-08-21 19:41:01 +00:00
className="rounded-lg px-3 py-2 text-xs font-semibold text-gray-500 hover:bg-gray-200 dark:hover:bg-gray-800"
onClick={closeAddStatus}
2026-07-30 14:37:15 +00:00
>
2026-08-21 19:41:01 +00:00
{translate('::App.Platform.Cancel')}
2026-07-30 14:37:15 +00:00
</button>
2026-08-21 19:41:01 +00:00
<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('::App.ListFormTodoBoard.AddColumn')}
</button>
</div>
</form>
</Dialog>
2026-07-30 14:37:15 +00:00
)}
2026-07-16 14:01:18 +00:00
</div>
</div>
</div>
)
}
export default TodoBoard