sozsoft-platform/ui/src/views/list/TodoBoard.tsx
2026-09-24 11:42:44 +03:00

2127 lines
91 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 } from 'devextreme-react/html-editor'
import { htmlEditorDefaults } from '@/views/shared/htmlEditorOptions'
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,
FaSave,
FaTag,
FaTimes,
FaTrash,
} from 'react-icons/fa'
import { useListFormCustomDataSource } from './useListFormCustomDataSource'
import { useListFormStateStoring } from './useListFormStateStoring'
import { useListFormGridDto } from './shared/hooks'
import { Avatar, Dialog, 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'
import Select from '@/components/ui/Select'
dayjs.extend(relativeTime)
interface TodoBoardProps {
listFormCode: string
searchParams?: URLSearchParams
isSubForm?: boolean
level?: number
refreshData?: () => Promise<void>
gridDto?: GridDto
}
type TodoRow = Record<string, any>
interface DropTarget {
status: string
index: number
}
interface StoredBoardColumns {
columns: string[]
retired: string[]
}
interface StatusNotice {
status: string
message: string
}
interface UserOption {
value: string
label: string
avatarUrl?: string
}
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',
}
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 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
}
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')
}
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 }) => {
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<DataGridRef<any, any> | undefined>(undefined)
// 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
: 'calc(100dvh - 210px)'
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<CustomStore<any, any>>()
const [rows, setRows] = useState<TodoRow[]>([])
const [statuses, setStatuses] = useState<string[]>([])
const [selected, setSelected] = useState<TodoRow>()
const [draft, setDraft] = useState<TodoRow>({})
const [saving, setSaving] = useState(false)
const [loading, setLoading] = useState(true)
const [draggedKey, setDraggedKey] = useState<unknown>()
const [draggedColumn, setDraggedColumn] = useState<string>()
const [todoDropTarget, setTodoDropTarget] = useState<DropTarget>()
const [columnDropTarget, setColumnDropTarget] = useState<string>()
const [newTodoStatus, setNewTodoStatus] = useState<string>()
const [newTodoTitle, setNewTodoTitle] = useState('')
const [editingStatus, setEditingStatus] = useState<string>()
const [statusName, setStatusName] = useState('')
const [isAddingStatus, setIsAddingStatus] = useState(false)
const [newStatusName, setNewStatusName] = useState('')
const [statusError, setStatusError] = useState('')
const [statusNotice, setStatusNotice] = useState<StatusNotice>()
const [renamingStatus, setRenamingStatus] = useState(false)
const [columnConfig, setColumnConfig] = useState<StoredBoardColumns>()
const [userOptions, setUserOptions] = useState<UserOption[]>([])
const [tenantNames, setTenantNames] = useState<Map<string, string>>(new Map())
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)
const columnConfigRef = useRef<StoredBoardColumns>({ 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<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[]
}
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'),
onClick: () => setSelected(undefined),
}}
/>
)}
{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('')
},
}}
/>
)}
<ToolbarItem
location="after"
widget="dxButton"
locateInMenu="auto"
showText="always"
options={{
icon: 'refresh',
text: translate('::App.Platform.Refresh'),
hint: translate('::App.Platform.Refresh'),
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'),
onClick: openFormManager,
}}
/>
)}
</Toolbar>
)
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))
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 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],
)
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])
// 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],
)
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 closeAddStatus = () => {
setIsAddingStatus(false)
setNewStatusName('')
setStatusError('')
}
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<unknown, Record<string, any>>()
targetItems.forEach((row, index) => {
const values: Record<string, any> = { [options.statusExpr!]: status }
if (options.orderExpr) values[options.orderExpr] = index
updates.set(row[keyField], values)
})
if (options.orderExpr) {
sourceItems.forEach((row, index) =>
updates.set(row[keyField], { [options.orderExpr!]: index }),
)
}
const updated = rows.map((row) =>
updates.has(row[keyField]) ? { ...row, ...updates.get(row[keyField]) } : row,
)
const targetKeys = new Set(targetItems.map((row) => row[keyField]))
setRows(
options.orderExpr
? updated
: [...updated.filter((row) => !targetKeys.has(row[keyField])), ...targetItems],
)
setDraggedKey(undefined)
setTodoDropTarget(undefined)
if (gridDto.gridOptions.updateServiceAddress) {
await Promise.allSettled([...updates].map(([key, values]) => store.update(key, values)))
await loadRows(store)
}
}
const addTodo = async (status: string) => {
const title = newTodoTitle.trim()
if (!canAdd || !title || !store || !options.titleExpr || !options.statusExpr) return
const values: Record<string, any> = {
[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 })
setDetailTab('description')
}
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,
options.timesheetExpr,
options.subTaskExpr,
].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<string, any>) => {
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 (
<div className="flex min-h-64 items-center justify-center text-sm">
{translate('::App.ListFormTodoBoard.Loading')}
</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'
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,
)
return (
<section className="min-h-[420px] rounded-xl">
{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>
)}
</div>
<div className="grid items-stretch gap-5 lg:grid-cols-3">
<div className="flex flex-col gap-5 lg:col-span-2">
{options.titleExpr && (
<div>
<label className={labelClass}>{translate('::App.Listform.ListformField.Title')}</label>
<Input
unstyle
className={`${inputClass} text-lg font-semibold`}
disabled={!canUpdate}
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>
))}
</div>
<div className="flex min-h-[260px] flex-1 flex-col pt-4">
{activeTab === 'description' && options.descriptionExpr && (
<HtmlEditor
{...htmlEditorDefaults}
height="100%"
placeholder={translate('::App.ListFormTodoBoard.DescriptionPlaceholder')}
readOnly={!canUpdate}
value={String(draft[options.descriptionExpr] ?? '')}
onValueChanged={(event) =>
setDraft((current) => ({
...current,
[options.descriptionExpr!]: event.value,
}))
}
/>
)}
{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>
)}
</div>
</div>
)}
</div>
<aside className="space-y-4 rounded-xl bg-gray-50 p-4 dark:bg-gray-800/60">
{selectedTenantId && (
<div>
<label className={labelClass}>Tenant</label>
<Input
unstyle
readOnly
className={`${inputClass} cursor-default`}
value={selectedTenantName ?? ''}
/>
</div>
)}
<div>
<label className={labelClass}>
{translate('::Abp.Mailing.Smtp.UserName')}
</label>
<Input
unstyle
readOnly
className={`${inputClass} cursor-default`}
value={String(draft[userNameField] ?? '')}
/>
</div>
{options.assigneeExpr && (
<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')
}
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 },
)
}}
/>
</div>
)}
{options.dueDateExpr && (
<div>
<label className={labelClass}>{translate('::App.ListFormTodoBoard.DueDate')}</label>
<Input
unstyle
type="datetime-local"
className={inputClass}
disabled={!canUpdate}
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>
<div className="rounded-lg border border-gray-300 bg-white px-3 py-2 dark:border-gray-600 dark:bg-gray-800">
<Rate
allowClear
color="yellow"
disabled={!canUpdate}
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'),
]}
value={priorityRating(draft[options.priorityExpr])}
onChange={(value) =>
setDraft((current) => ({
...current,
[options.priorityExpr!]: value,
}))
}
/>
<div className="mt-1 text-xs text-gray-500">
{priorityLabel(draft[options.priorityExpr], translate)}
</div>
</div>
</div>
)}
{options.completedExpr && (
<label className="flex cursor-pointer items-center gap-3 rounded-lg border border-gray-200 bg-white p-3 text-sm font-medium dark:border-gray-700 dark:bg-gray-800">
<Input
unstyle
type="checkbox"
className="h-4 w-4 accent-emerald-500"
disabled={!canUpdate}
checked={isCompleted(draft[options.completedExpr])}
onChange={(event) =>
setDraft((current) => ({
...current,
[options.completedExpr!]: event.target.checked,
}))
}
/>
{translate('::App.Listform.ListformField.Completed')}
</label>
)}
</aside>
</div>
{(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')}
</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')}
</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')}
</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')}
</button>
</div>
)}
</div>
)}
</section>
)
}
return (
<div className="flex flex-col pb-3">
{renderToolbar()}
<div className="min-h-full overflow-auto" style={{ height: boardHeight }}>
<div className="flex min-w-max">
{statuses.map((status) => {
const items = itemsForStatus(status)
const isAdding = newTodoStatus === status
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'
return (
<section
key={status}
className={`group w-[310px] shrink-0 self-stretch rounded-xl border-2 p-2 transition-all ${columnBorderClass} ${columnBgClass} ${draggedColumn === status ? 'opacity-50' : ''}`}
onDragOver={(event) => {
event.preventDefault()
if (dragActiveRef.current && draggedColumn) {
setColumnDropTarget(status)
return
}
if (dragActiveRef.current && draggedKey !== undefined)
setTodoDropTarget({ status, index: items.length })
}}
onDrop={(event) => {
event.preventDefault()
if (draggedColumn) reorderColumn(status)
else
moveTodo(
status,
todoDropTarget?.status === status ? todoDropTarget.index : items.length,
event.dataTransfer.getData('text/todo-key'),
)
}}
>
<div className={`sticky top-0 z-10 -mx-2 -mt-2 rounded-t-xl px-2 pb-1 pt-2 ${columnHeaderBgClass}`}>
<header className="mb-2 flex min-h-9 items-center gap-2 px-1">
<span
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')}
onDragStart={(event) => {
event.stopPropagation()
event.dataTransfer.effectAllowed = 'move'
dragActiveRef.current = true
setDraggedKey(undefined)
setDraggedColumn(status)
}}
onDragEnd={clearDragState}
>
<FaGripVertical />
</span>
{editingStatus === status ? (
<form
className="flex min-w-0 flex-1 items-center gap-1"
onSubmit={(event) => {
event.preventDefault()
renameStatus(status)
}}
>
<Input
unstyle
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')}
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')}
>
<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')}
onClick={() => {
setEditingStatus(undefined)
setStatusError('')
}}
>
<FaTimes />
</button>
</form>
) : (
<>
<h4 className="min-w-0 truncate font-sm" title={statusText(status)}>
{statusText(status)}
</h4>
{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')}
onClick={() => {
setEditingStatus(status)
setStatusName(status)
setStatusError('')
}}
>
<FaPencilAlt className="text-xs" />
</button>
)}
</>
)}
<span className="ml-auto rounded-full bg-gray-200 px-2 py-0.5 text-xs dark:bg-gray-700">
{items.length}
</span>
{canAdd && (
<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')}
onClick={() => {
setNewTodoStatus(status)
setNewTodoTitle('')
}}
>
<FaPlus />
</button>
)}
{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')
}
onClick={() => deleteStatus(status)}
>
<FaTrash className="text-xs" />
</button>
)}
</header>
{editingStatus === status && statusError && (
<p className="mb-2 px-1 text-xs text-red-500">{statusError}</p>
)}
{statusNotice?.status === status && (
<div className="mb-2 flex items-start justify-between gap-2 rounded-lg bg-amber-50 px-2.5 py-2 text-xs text-amber-700 dark:bg-amber-950/40 dark:text-amber-200">
<span>{statusNotice.message}</span>
<button
type="button"
className="shrink-0 rounded p-0.5 hover:bg-amber-100 dark:hover:bg-amber-900"
title={translate('::App.Platform.Close')}
onClick={() => setStatusNotice(undefined)}
>
<FaTimes />
</button>
</div>
)}
<div className="h-1 overflow-hidden rounded-full bg-gray-200 dark:bg-gray-700">
<div
className="h-full bg-blue-500"
style={{ width: `${Math.min(100, items.length * 18)}%` }}
/>
</div>
{isAdding && (
<div className="mt-3 rounded border border-blue-300 bg-white p-2 dark:border-blue-700 dark:bg-gray-800">
<div className="flex gap-1">
<Input
unstyle
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')}
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')}
onClick={() => addTodo(status)}
>
<FaSave />
</button>
<button
type="button"
className="rounded px-2 text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700"
onClick={() => setNewTodoStatus(undefined)}
>
<FaTimes />
</button>
</div>
</div>
)}
</div>
<div className="mt-3 space-y-2">
{items.map((row, index) => {
const key = row[keyField]
const tags = textList(fieldValue(row, options.tagExpr))
const assignees = textList(fieldValue(row, options.assigneeExpr))
const due = fieldValue(row, options.dueDateExpr)
const completed = isCompleted(fieldValue(row, options.completedExpr))
const priority = fieldValue(row, options.priorityExpr)
const tenantId = tenantIdValue(row)
const tenantName = tenantId
? tenantNames.get(String(tenantId).toLowerCase())
: undefined
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)
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')}
</div>
)}
<article
draggable={Boolean(options.allowDragging && canUpdate)}
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">
{canDelete && (
<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')}
onClick={(event) => {
event.stopPropagation()
deleteTodo(row)
}}
>
<FaTrash />
</button>
)}
</div>
</div>
{tenantName && (
<div
className="mt-1 text-[11px] text-gray-500 dark:text-gray-400"
title={tenantName}
>
{tenantName}
</div>
)}
{fieldValue(row, options.descriptionExpr) && (
<p className="mt-1 line-clamp-2 text-xs leading-5 text-gray-500 dark:text-gray-400">
{htmlToPlainText(fieldValue(row, options.descriptionExpr))}
</p>
)}
{due && (
<div className="mt-1 flex items-center gap-1 text-xs text-gray-500">
<FaClock />
{dayjs(due).fromNow()}
</div>
)}
{(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>
)}
{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')}
onClick={(event) => event.stopPropagation()}
>
<Rate
allowClear
color="yellow"
disabled={!canUpdate}
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)}
</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}
disabled={!canUpdate}
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')
}
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')}
</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>
)
})}
{canAdd && (
<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)
setStatusError('')
}}
/>
{statusError && <p className="mt-2 text-xs text-red-500">{statusError}</p>}
<div className="mt-6 flex justify-end gap-2">
<button
type="button"
className="rounded-lg px-3 py-2 text-xs font-semibold text-gray-500 hover:bg-gray-200 dark:hover:bg-gray-800"
onClick={closeAddStatus}
>
{translate('::App.Platform.Cancel')}
</button>
<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>
)}
</div>
</div>
</div>
)
}
export default TodoBoard