1364 lines
57 KiB
TypeScript
1364 lines
57 KiB
TypeScript
import type { GridDto, TodoOptionDto } from '@/proxy/form/models'
|
|
import type { IdentityUserDto } from '@/proxy/admin/models'
|
|
import type { DataGridRef } from 'devextreme-react/data-grid'
|
|
import type CustomStore from 'devextreme/data/custom_store'
|
|
import TagBox from 'devextreme-react/tag-box'
|
|
import { AVATAR_URL } from '@/constants/app.constant'
|
|
import dayjs from 'dayjs'
|
|
import relativeTime from 'dayjs/plugin/relativeTime'
|
|
import { Fragment, useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
|
import {
|
|
FaArrowLeft,
|
|
FaCheckCircle,
|
|
FaCheck,
|
|
FaClock,
|
|
FaGripVertical,
|
|
FaPencilAlt,
|
|
FaPlus,
|
|
FaRegCircle,
|
|
FaTag,
|
|
FaTimes,
|
|
FaTrash,
|
|
} from 'react-icons/fa'
|
|
import { useListFormCustomDataSource } from './useListFormCustomDataSource'
|
|
import { useListFormStateStoring } from './useListFormStateStoring'
|
|
import { Avatar, Rate } from '@/components/ui'
|
|
import { getUsers } from '@/services/identity.service'
|
|
import { useLocalization } from '@/utils/hooks/useLocalization'
|
|
|
|
dayjs.extend(relativeTime)
|
|
|
|
interface TodoBoardProps {
|
|
listFormCode: string
|
|
searchParams?: URLSearchParams
|
|
isSubForm?: boolean
|
|
gridDto: GridDto
|
|
}
|
|
|
|
type TodoRow = Record<string, any>
|
|
|
|
interface DropTarget {
|
|
status: string
|
|
index: number
|
|
}
|
|
|
|
interface StoredBoardColumns {
|
|
columns: string[]
|
|
retired: string[]
|
|
}
|
|
|
|
interface StatusNotice {
|
|
status: string
|
|
message: string
|
|
}
|
|
|
|
interface UserOption {
|
|
value: string
|
|
label: string
|
|
avatarUrl?: string
|
|
}
|
|
|
|
const fieldValue = (row: TodoRow, field?: string) => (field ? row[field] : undefined)
|
|
|
|
const textList = (value: unknown) =>
|
|
Array.isArray(value)
|
|
? value.map(String)
|
|
: String(value ?? '')
|
|
.split(/[,;|]/)
|
|
.map((item) => item.trim())
|
|
.filter(Boolean)
|
|
|
|
const isCompleted = (value: unknown) =>
|
|
value === true ||
|
|
value === 1 ||
|
|
['true', 'done', 'completed'].includes(String(value).toLowerCase())
|
|
|
|
const initials = (name: string) =>
|
|
name
|
|
.split(/\s+/)
|
|
.map((part) => part[0])
|
|
.join('')
|
|
.slice(0, 2)
|
|
.toUpperCase()
|
|
|
|
const priorityClass = (priority: unknown) => {
|
|
switch (priorityRating(priority)) {
|
|
case 5:
|
|
return 'bg-red-100 text-red-700 dark:bg-red-950 dark:text-red-200'
|
|
case 4:
|
|
return 'bg-orange-100 text-orange-700 dark:bg-orange-950 dark:text-orange-200'
|
|
case 3:
|
|
return 'bg-amber-100 text-amber-700 dark:bg-amber-950 dark:text-amber-200'
|
|
case 2:
|
|
case 1:
|
|
return 'bg-emerald-100 text-emerald-700 dark:bg-emerald-950 dark:text-emerald-200'
|
|
default:
|
|
return 'bg-slate-100 text-slate-700 dark:bg-slate-700 dark:text-slate-100'
|
|
}
|
|
}
|
|
|
|
const priorityRating = (priority: unknown) => {
|
|
const numeric = Number(priority)
|
|
if (Number.isFinite(numeric) && numeric >= 0 && numeric <= 5) return numeric
|
|
switch (String(priority ?? '').toLowerCase()) {
|
|
case 'urgent':
|
|
case 'critical':
|
|
return 5
|
|
case 'high':
|
|
return 4
|
|
case 'medium':
|
|
return 3
|
|
case 'low':
|
|
return 1
|
|
default:
|
|
return 0
|
|
}
|
|
}
|
|
|
|
const priorityLabel = (
|
|
priority: unknown,
|
|
translate: (key: string, params?: Record<string, string | number>) => string,
|
|
) => {
|
|
const rating = priorityRating(priority)
|
|
return rating
|
|
? translate('::ListForms.TodoBoard.PriorityRating', { 0: rating })
|
|
: translate('::ListForms.TodoBoard.NoPriority')
|
|
}
|
|
|
|
const TodoBoard = ({ listFormCode, searchParams, gridDto }: TodoBoardProps) => {
|
|
const options = gridDto.gridOptions.todoOptionDto as TodoOptionDto
|
|
const keyField = gridDto.gridOptions.keyFieldName ?? 'id'
|
|
const stateStoring = gridDto.gridOptions.stateStoringDto
|
|
const gridRef = useRef<DataGridRef<any, any>>()
|
|
const { createSelectDataSource } = useListFormCustomDataSource({ gridRef })
|
|
const [store, setStore] = useState<CustomStore<any, any>>()
|
|
const [rows, setRows] = useState<TodoRow[]>([])
|
|
const [statuses, setStatuses] = useState<string[]>([])
|
|
const [selected, setSelected] = useState<TodoRow>()
|
|
const [draft, setDraft] = useState<TodoRow>({})
|
|
const [saving, setSaving] = useState(false)
|
|
const [loading, setLoading] = useState(true)
|
|
const [draggedKey, setDraggedKey] = useState<unknown>()
|
|
const [draggedColumn, setDraggedColumn] = useState<string>()
|
|
const [todoDropTarget, setTodoDropTarget] = useState<DropTarget>()
|
|
const [columnDropTarget, setColumnDropTarget] = useState<string>()
|
|
const [newTodoStatus, setNewTodoStatus] = useState<string>()
|
|
const [newTodoTitle, setNewTodoTitle] = useState('')
|
|
const [editingStatus, setEditingStatus] = useState<string>()
|
|
const [statusName, setStatusName] = useState('')
|
|
const [isAddingStatus, setIsAddingStatus] = useState(false)
|
|
const [newStatusName, setNewStatusName] = useState('')
|
|
const [statusError, setStatusError] = useState('')
|
|
const [statusNotice, setStatusNotice] = useState<StatusNotice>()
|
|
const [renamingStatus, setRenamingStatus] = useState(false)
|
|
const [columnConfig, setColumnConfig] = useState<StoredBoardColumns>()
|
|
const [userOptions, setUserOptions] = useState<UserOption[]>([])
|
|
const [usersLoading, setUsersLoading] = useState(false)
|
|
const columnConfigRef = useRef<StoredBoardColumns>({ columns: [], retired: [] })
|
|
const dragActiveRef = useRef(false)
|
|
const { translate } = useLocalization()
|
|
const uncategorized = translate('::ListForms.TodoBoard.Uncategorized')
|
|
|
|
const clearDragState = useCallback(() => {
|
|
dragActiveRef.current = false
|
|
setDraggedKey(undefined)
|
|
setDraggedColumn(undefined)
|
|
setTodoDropTarget(undefined)
|
|
setColumnDropTarget(undefined)
|
|
}, [])
|
|
|
|
const { customLoadState, customSaveState } = useListFormStateStoring({
|
|
listFormCode,
|
|
storageKey: stateStoring?.enabled ? stateStoring.storageKey || listFormCode : undefined,
|
|
filterPrefix: 'todo',
|
|
saveDebounceMs: stateStoring?.savingTimeout,
|
|
showSaveToast: false,
|
|
})
|
|
const customLoadStateRef = useRef(customLoadState)
|
|
const customSaveStateRef = useRef(customSaveState)
|
|
customLoadStateRef.current = customLoadState
|
|
customSaveStateRef.current = customSaveState
|
|
|
|
useEffect(() => {
|
|
const finishDrag = () => window.setTimeout(clearDragState, 0)
|
|
const cancelWithEscape = (event: KeyboardEvent) => {
|
|
if (event.key === 'Escape' && dragActiveRef.current) finishDrag()
|
|
}
|
|
|
|
window.addEventListener('dragend', finishDrag, true)
|
|
window.addEventListener('drop', finishDrag, true)
|
|
window.addEventListener('keydown', cancelWithEscape, true)
|
|
return () => {
|
|
window.removeEventListener('dragend', finishDrag, true)
|
|
window.removeEventListener('drop', finishDrag, true)
|
|
window.removeEventListener('keydown', cancelWithEscape, true)
|
|
}
|
|
}, [clearDragState])
|
|
|
|
useEffect(() => {
|
|
let cancelled = false
|
|
setUsersLoading(true)
|
|
getUsers(0, 1000)
|
|
.then((response) => {
|
|
if (cancelled) return
|
|
const users = response.data?.items ?? []
|
|
setUserOptions(
|
|
users
|
|
.filter((user: IdentityUserDto) => Boolean(user.userName))
|
|
.map((user: IdentityUserDto) => {
|
|
const fullName = [user.name, user.surname].filter(Boolean).join(' ')
|
|
return {
|
|
value: user.userName!,
|
|
label: fullName || user.userName!,
|
|
avatarUrl: AVATAR_URL(user.id, user.tenantId),
|
|
}
|
|
}),
|
|
)
|
|
})
|
|
.catch((error) => console.error('Todo assignee users load error:', error))
|
|
.finally(() => {
|
|
if (!cancelled) setUsersLoading(false)
|
|
})
|
|
|
|
return () => {
|
|
cancelled = true
|
|
}
|
|
}, [])
|
|
|
|
const loadRows = async (dataSource: CustomStore<any, any>) => {
|
|
const result: any = await dataSource.load({ skip: 0, take: 1000, requireTotalCount: true })
|
|
const loadedRows = Array.isArray(result) ? result : (result?.data ?? [])
|
|
setRows(loadedRows)
|
|
return loadedRows as TodoRow[]
|
|
}
|
|
|
|
useEffect(() => {
|
|
const dataSource = createSelectDataSource(
|
|
gridDto.gridOptions,
|
|
listFormCode,
|
|
searchParams,
|
|
'todo',
|
|
)
|
|
setStore(dataSource)
|
|
setLoading(true)
|
|
loadRows(dataSource).finally(() => setLoading(false))
|
|
}, [createSelectDataSource, gridDto, listFormCode, searchParams])
|
|
|
|
const availableStatuses = useMemo(() => {
|
|
const configured = textList(options.statusOrder)
|
|
const actual = rows.map((row) => String(fieldValue(row, options.statusExpr) ?? uncategorized))
|
|
return [...new Set([...configured, ...actual])]
|
|
}, [options.statusExpr, options.statusOrder, rows, uncategorized])
|
|
|
|
const assigneeSuggestions = useMemo(
|
|
() => [...new Set(rows.flatMap((row) => textList(fieldValue(row, options.assigneeExpr))))],
|
|
[options.assigneeExpr, rows],
|
|
)
|
|
const assigneeOptions = useMemo<UserOption[]>(() => {
|
|
const knownUsers = new Set(userOptions.map((option) => option.value))
|
|
return [
|
|
...userOptions,
|
|
...assigneeSuggestions
|
|
.filter((value) => !knownUsers.has(value))
|
|
.map((value) => ({ value, label: value })),
|
|
]
|
|
}, [assigneeSuggestions, userOptions])
|
|
const assigneeOptionMap = useMemo(
|
|
() => new Map(assigneeOptions.map((option) => [option.value, option])),
|
|
[assigneeOptions],
|
|
)
|
|
const tagSuggestions = useMemo(
|
|
() => [...new Set(rows.flatMap((row) => textList(fieldValue(row, options.tagExpr))))],
|
|
[options.tagExpr, rows],
|
|
)
|
|
const draftAssignees = useMemo(
|
|
() => textList(fieldValue(draft, options.assigneeExpr)),
|
|
[draft, options.assigneeExpr],
|
|
)
|
|
const draftTags = useMemo(
|
|
() => textList(fieldValue(draft, options.tagExpr)),
|
|
[draft, options.tagExpr],
|
|
)
|
|
|
|
const saveColumnConfig = useCallback(
|
|
(columns: string[], retired = columnConfigRef.current.retired) => {
|
|
const next = { columns, retired }
|
|
columnConfigRef.current = next
|
|
setColumnConfig(next)
|
|
if (stateStoring?.enabled) {
|
|
customSaveStateRef
|
|
.current(next)
|
|
.catch((error) => console.error('Todo board state save error:', error))
|
|
}
|
|
},
|
|
[stateStoring?.enabled],
|
|
)
|
|
|
|
useEffect(() => {
|
|
let cancelled = false
|
|
const legacyStorageKey = `todo-board:${listFormCode}:column-order`
|
|
let legacyConfig: StoredBoardColumns = { columns: [], retired: [] }
|
|
try {
|
|
const legacy = JSON.parse(localStorage.getItem(legacyStorageKey) ?? '{}')
|
|
legacyConfig = Array.isArray(legacy)
|
|
? { columns: legacy.map(String), retired: [] }
|
|
: {
|
|
columns: Array.isArray(legacy?.columns) ? legacy.columns.map(String) : [],
|
|
retired: Array.isArray(legacy?.retired) ? legacy.retired.map(String) : [],
|
|
}
|
|
} catch {
|
|
legacyConfig = { columns: [], retired: [] }
|
|
}
|
|
setColumnConfig(undefined)
|
|
|
|
if (!stateStoring?.enabled) {
|
|
const emptyConfig = { columns: [], retired: [] }
|
|
columnConfigRef.current = emptyConfig
|
|
setColumnConfig(emptyConfig)
|
|
localStorage.removeItem(legacyStorageKey)
|
|
return () => {
|
|
cancelled = true
|
|
}
|
|
}
|
|
|
|
customLoadStateRef
|
|
.current()
|
|
.then((stored) => {
|
|
if (cancelled) return
|
|
const hasDatabaseState = Array.isArray(stored?.columns)
|
|
const config: StoredBoardColumns = hasDatabaseState
|
|
? {
|
|
columns: stored.columns.map(String),
|
|
retired: Array.isArray(stored?.retired) ? stored.retired.map(String) : [],
|
|
}
|
|
: legacyConfig
|
|
columnConfigRef.current = config
|
|
setColumnConfig(config)
|
|
if (!hasDatabaseState && legacyConfig.columns.length) {
|
|
customSaveStateRef
|
|
.current(config)
|
|
.then(() => localStorage.removeItem(legacyStorageKey))
|
|
.catch((error) => console.error('Todo board state migration error:', error))
|
|
} else {
|
|
localStorage.removeItem(legacyStorageKey)
|
|
}
|
|
})
|
|
.catch((error) => {
|
|
if (cancelled) return
|
|
console.error('Todo board state load error:', error)
|
|
const emptyConfig = { columns: [], retired: [] }
|
|
columnConfigRef.current = emptyConfig
|
|
setColumnConfig(emptyConfig)
|
|
})
|
|
|
|
return () => {
|
|
cancelled = true
|
|
}
|
|
}, [listFormCode, stateStoring?.enabled, stateStoring?.storageKey])
|
|
|
|
useEffect(() => {
|
|
if (!columnConfig) return
|
|
const { columns: saved, retired } = columnConfig
|
|
const visibleAvailable = availableStatuses.filter((status) => !retired.includes(status))
|
|
const merged = [...saved, ...visibleAvailable.filter((status) => !saved.includes(status))]
|
|
setStatuses((current) =>
|
|
JSON.stringify(current) === JSON.stringify(merged) ? current : merged,
|
|
)
|
|
}, [availableStatuses, columnConfig])
|
|
|
|
const itemsForStatus = (status: string) => {
|
|
const items = rows.filter(
|
|
(row) => String(fieldValue(row, options.statusExpr) ?? uncategorized) === status,
|
|
)
|
|
if (!options.orderExpr) return items
|
|
return items.sort(
|
|
(left, right) =>
|
|
Number(fieldValue(left, options.orderExpr) ?? 0) -
|
|
Number(fieldValue(right, options.orderExpr) ?? 0),
|
|
)
|
|
}
|
|
|
|
const reorderColumn = (targetStatus: string) => {
|
|
if (!draggedColumn || draggedColumn === targetStatus) return
|
|
setStatuses((current) => {
|
|
const next = current.filter((status) => status !== draggedColumn)
|
|
next.splice(current.indexOf(targetStatus), 0, draggedColumn)
|
|
saveColumnConfig(next)
|
|
return next
|
|
})
|
|
setDraggedColumn(undefined)
|
|
setColumnDropTarget(undefined)
|
|
}
|
|
|
|
const addStatus = () => {
|
|
const nextName = newStatusName.trim()
|
|
if (!nextName) return
|
|
if (statuses.some((status) => status.toLocaleLowerCase() === nextName.toLocaleLowerCase())) {
|
|
setStatusError(translate('::ListForms.TodoBoard.DuplicateColumnName'))
|
|
return
|
|
}
|
|
const next = [...statuses, nextName]
|
|
setStatuses(next)
|
|
saveColumnConfig(
|
|
next,
|
|
columnConfigRef.current.retired.filter((status) => status !== nextName),
|
|
)
|
|
setNewStatusName('')
|
|
setStatusError('')
|
|
setIsAddingStatus(false)
|
|
}
|
|
|
|
const deleteStatus = (status: string) => {
|
|
const itemCount = itemsForStatus(status).length
|
|
if (itemCount) {
|
|
setStatusNotice({
|
|
status,
|
|
message: translate('::ListForms.TodoBoard.ColumnNotEmptyMessage', { 0: itemCount }),
|
|
})
|
|
return
|
|
}
|
|
if (!window.confirm(translate('::ListForms.TodoBoard.ConfirmDeleteColumn', { 0: status })))
|
|
return
|
|
|
|
const next = statuses.filter((item) => item !== status)
|
|
const retired = [...new Set([...columnConfigRef.current.retired, status])]
|
|
setStatuses(next)
|
|
saveColumnConfig(next, retired)
|
|
setStatusNotice(undefined)
|
|
if (editingStatus === status) setEditingStatus(undefined)
|
|
if (newTodoStatus === status) setNewTodoStatus(undefined)
|
|
}
|
|
|
|
const renameStatus = async (status: string) => {
|
|
const nextName = statusName.trim()
|
|
if (!nextName || nextName === status) {
|
|
setEditingStatus(undefined)
|
|
setStatusError('')
|
|
return
|
|
}
|
|
if (
|
|
statuses.some(
|
|
(item) => item !== status && item.toLocaleLowerCase() === nextName.toLocaleLowerCase(),
|
|
)
|
|
) {
|
|
setStatusError(translate('::ListForms.TodoBoard.DuplicateColumnName'))
|
|
return
|
|
}
|
|
|
|
const affectedRows = itemsForStatus(status)
|
|
if (affectedRows.length && !gridDto.gridOptions.updateServiceAddress) {
|
|
setStatusError(translate('::ListForms.TodoBoard.RenameRequiresUpdateService'))
|
|
return
|
|
}
|
|
const nextStatuses = statuses.map((item) => (item === status ? nextName : item))
|
|
const config = columnConfigRef.current
|
|
const retired = [...new Set([...config.retired.filter((item) => item !== nextName), status])]
|
|
setRenamingStatus(true)
|
|
setStatuses(nextStatuses)
|
|
saveColumnConfig(nextStatuses, retired)
|
|
setRows((current) =>
|
|
current.map((row) =>
|
|
String(fieldValue(row, options.statusExpr) ?? uncategorized) === status
|
|
? { ...row, [options.statusExpr!]: nextName }
|
|
: row,
|
|
),
|
|
)
|
|
try {
|
|
if (store && options.statusExpr && gridDto.gridOptions.updateServiceAddress) {
|
|
await Promise.all(
|
|
affectedRows.map((row) =>
|
|
store.update(row[keyField], { [options.statusExpr!]: nextName }),
|
|
),
|
|
)
|
|
await loadRows(store)
|
|
}
|
|
setEditingStatus(undefined)
|
|
setStatusError('')
|
|
} catch {
|
|
setStatuses(statuses)
|
|
saveColumnConfig(statuses, config.retired)
|
|
setRows((current) =>
|
|
current.map((row) =>
|
|
String(fieldValue(row, options.statusExpr) ?? uncategorized) === nextName
|
|
? { ...row, [options.statusExpr!]: status }
|
|
: row,
|
|
),
|
|
)
|
|
setStatusError(translate('::ListForms.TodoBoard.ColumnNameSaveFailed'))
|
|
} finally {
|
|
setRenamingStatus(false)
|
|
}
|
|
}
|
|
|
|
const moveTodo = async (status: string, targetIndex: number, transferredKey?: string) => {
|
|
const effectiveKey =
|
|
draggedKey ?? rows.find((row) => String(row[keyField]) === transferredKey)?.[keyField]
|
|
if (effectiveKey === undefined || !store || !options.statusExpr || !options.allowDragging)
|
|
return
|
|
|
|
const moved = rows.find((row) => row[keyField] === effectiveKey)
|
|
if (!moved) return
|
|
const sourceStatus = String(fieldValue(moved, options.statusExpr) ?? uncategorized)
|
|
const targetItems = itemsForStatus(status).filter((row) => row[keyField] !== effectiveKey)
|
|
targetItems.splice(Math.min(targetIndex, targetItems.length), 0, moved)
|
|
const sourceItems =
|
|
sourceStatus === status
|
|
? []
|
|
: itemsForStatus(sourceStatus).filter((row) => row[keyField] !== effectiveKey)
|
|
|
|
const updates = new Map<unknown, Record<string, any>>()
|
|
targetItems.forEach((row, index) => {
|
|
const values: Record<string, any> = { [options.statusExpr!]: status }
|
|
if (options.orderExpr) values[options.orderExpr] = index
|
|
updates.set(row[keyField], values)
|
|
})
|
|
if (options.orderExpr) {
|
|
sourceItems.forEach((row, index) =>
|
|
updates.set(row[keyField], { [options.orderExpr!]: index }),
|
|
)
|
|
}
|
|
|
|
const updated = rows.map((row) =>
|
|
updates.has(row[keyField]) ? { ...row, ...updates.get(row[keyField]) } : row,
|
|
)
|
|
const targetKeys = new Set(targetItems.map((row) => row[keyField]))
|
|
setRows(
|
|
options.orderExpr
|
|
? updated
|
|
: [...updated.filter((row) => !targetKeys.has(row[keyField])), ...targetItems],
|
|
)
|
|
setDraggedKey(undefined)
|
|
setTodoDropTarget(undefined)
|
|
if (gridDto.gridOptions.updateServiceAddress) {
|
|
await Promise.allSettled([...updates].map(([key, values]) => store.update(key, values)))
|
|
await loadRows(store)
|
|
}
|
|
}
|
|
|
|
const addTodo = async (status: string) => {
|
|
const title = newTodoTitle.trim()
|
|
if (!title || !store || !options.titleExpr || !options.statusExpr) return
|
|
|
|
const values: Record<string, any> = {
|
|
[options.titleExpr]: title,
|
|
[options.statusExpr]: status,
|
|
}
|
|
if (options.completedExpr) values[options.completedExpr] = false
|
|
if (options.orderExpr) values[options.orderExpr] = itemsForStatus(status).length
|
|
|
|
const inserted: any = await store.insert(values)
|
|
const loadedRows = await loadRows(store)
|
|
setNewTodoTitle('')
|
|
setNewTodoStatus(undefined)
|
|
const created = loadedRows.find(
|
|
(row) =>
|
|
(inserted?.[keyField] !== undefined && row[keyField] === inserted[keyField]) ||
|
|
(row[options.titleExpr!] === title && row[options.statusExpr!] === status),
|
|
)
|
|
if (created) openTodo(created)
|
|
}
|
|
|
|
const deleteTodo = async (row: TodoRow) => {
|
|
if (!store || !gridDto.gridOptions.deleteServiceAddress) return
|
|
if (
|
|
!window.confirm(
|
|
translate('::ListForms.TodoBoard.ConfirmDeleteTodo', {
|
|
0: String(fieldValue(row, options.titleExpr) ?? ''),
|
|
}),
|
|
)
|
|
)
|
|
return
|
|
await store.remove(row[keyField])
|
|
setRows((current) => current.filter((item) => item[keyField] !== row[keyField]))
|
|
if (selected?.[keyField] === row[keyField]) setSelected(undefined)
|
|
}
|
|
|
|
const openTodo = (row: TodoRow) => {
|
|
setSelected(row)
|
|
setDraft({ ...row })
|
|
}
|
|
|
|
const saveTodo = async () => {
|
|
if (!selected || !store || !gridDto.gridOptions.updateServiceAddress) return
|
|
const mappedFields = [
|
|
options.titleExpr,
|
|
options.statusExpr,
|
|
options.descriptionExpr,
|
|
options.dueDateExpr,
|
|
options.tagExpr,
|
|
options.assigneeExpr,
|
|
options.priorityExpr,
|
|
options.completedExpr,
|
|
options.orderExpr,
|
|
].filter(Boolean) as string[]
|
|
const values = Object.fromEntries(mappedFields.map((field) => [field, draft[field]]))
|
|
|
|
setSaving(true)
|
|
try {
|
|
await store.update(selected[keyField], values)
|
|
const updated = { ...selected, ...values }
|
|
setRows((current) =>
|
|
current.map((row) => (row[keyField] === selected[keyField] ? updated : row)),
|
|
)
|
|
setSelected(updated)
|
|
setDraft(updated)
|
|
} finally {
|
|
setSaving(false)
|
|
}
|
|
}
|
|
|
|
const updateTodoInline = async (row: TodoRow, values: Record<string, any>) => {
|
|
const updated = { ...row, ...values }
|
|
setRows((current) => current.map((item) => (item[keyField] === row[keyField] ? updated : item)))
|
|
if (selected?.[keyField] === row[keyField]) {
|
|
setSelected(updated)
|
|
setDraft((current) => ({ ...current, ...values }))
|
|
}
|
|
if (store && gridDto.gridOptions.updateServiceAddress) {
|
|
await store.update(row[keyField], values)
|
|
}
|
|
}
|
|
|
|
if (loading)
|
|
return (
|
|
<div className="flex min-h-64 items-center justify-center text-sm">
|
|
{translate('::ListForms.TodoBoard.Loading')}
|
|
</div>
|
|
)
|
|
|
|
if (selected) {
|
|
const canEdit = Boolean(gridDto.gridOptions.updateServiceAddress)
|
|
const inputClass =
|
|
'w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm outline-none transition focus:border-blue-500 focus:ring-2 focus:ring-blue-500/20 disabled:bg-gray-100 dark:border-gray-600 dark:bg-gray-800 dark:disabled:bg-gray-900'
|
|
const labelClass = 'mb-1.5 block text-xs font-semibold uppercase tracking-wide text-gray-500'
|
|
return (
|
|
<section className="min-h-[420px] rounded-xl py-5 shadow-sm">
|
|
<div className="mb-6 flex items-center justify-between border-b border-gray-200 pb-4 dark:border-gray-700">
|
|
<button
|
|
type="button"
|
|
className="inline-flex items-center gap-2 text-sm text-gray-500 hover:text-blue-500"
|
|
onClick={() => setSelected(undefined)}
|
|
>
|
|
<FaArrowLeft /> {translate('::ListForms.TodoBoard.BackToBoard')}
|
|
</button>
|
|
<div className="flex items-center gap-2">
|
|
{!canEdit && (
|
|
<span className="text-xs text-amber-600">
|
|
{translate('::ListForms.TodoBoard.EditRequiresUpdateService')}
|
|
</span>
|
|
)}
|
|
{gridDto.gridOptions.deleteServiceAddress && (
|
|
<button
|
|
type="button"
|
|
className="rounded-lg p-2 text-gray-400 hover:bg-red-50 hover:text-red-500 dark:hover:bg-red-950"
|
|
title={translate('::ListForms.TodoBoard.DeleteTodo')}
|
|
onClick={() => deleteTodo(selected)}
|
|
>
|
|
<FaTrash />
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
<div className="grid gap-5 lg:grid-cols-3">
|
|
<div className="space-y-5 lg:col-span-2">
|
|
{options.titleExpr && (
|
|
<div>
|
|
<label className={labelClass}>{translate('::ListForms.TodoBoard.Title')}</label>
|
|
<input
|
|
className={`${inputClass} text-lg font-semibold`}
|
|
disabled={!canEdit}
|
|
value={String(draft[options.titleExpr] ?? '')}
|
|
onChange={(event) =>
|
|
setDraft((current) => ({
|
|
...current,
|
|
[options.titleExpr!]: event.target.value,
|
|
}))
|
|
}
|
|
/>
|
|
</div>
|
|
)}
|
|
{options.descriptionExpr && (
|
|
<div>
|
|
<label className={labelClass}>
|
|
{translate('::ListForms.TodoBoard.Description')}
|
|
</label>
|
|
<textarea
|
|
className={`${inputClass} min-h-36 resize-y`}
|
|
disabled={!canEdit}
|
|
placeholder={translate('::ListForms.TodoBoard.DescriptionPlaceholder')}
|
|
value={String(draft[options.descriptionExpr] ?? '')}
|
|
onChange={(event) =>
|
|
setDraft((current) => ({
|
|
...current,
|
|
[options.descriptionExpr!]: event.target.value,
|
|
}))
|
|
}
|
|
/>
|
|
</div>
|
|
)}
|
|
<div className="grid gap-4 sm:grid-cols-2">
|
|
{options.assigneeExpr && (
|
|
<div>
|
|
<label className={labelClass}>
|
|
{translate('::ListForms.TodoBoard.Assignees')}
|
|
</label>
|
|
<TagBox
|
|
searchEnabled
|
|
showClearButton
|
|
showSelectionControls
|
|
disabled={!canEdit || usersLoading}
|
|
displayExpr="label"
|
|
items={assigneeOptions}
|
|
valueExpr="value"
|
|
value={draftAssignees}
|
|
applyValueMode="instantly"
|
|
placeholder={
|
|
usersLoading
|
|
? translate('::ListForms.TodoBoard.UsersLoading')
|
|
: translate('::ListForms.TodoBoard.SelectUser')
|
|
}
|
|
onValueChanged={(event) => {
|
|
if (!event.event) return
|
|
const nextValue = (event.value ?? []).join(', ')
|
|
setDraft((current) =>
|
|
String(current[options.assigneeExpr!] ?? '') === nextValue
|
|
? current
|
|
: { ...current, [options.assigneeExpr!]: nextValue },
|
|
)
|
|
}}
|
|
/>
|
|
</div>
|
|
)}
|
|
{options.tagExpr && (
|
|
<div>
|
|
<label className={labelClass}>{translate('::ListForms.TodoBoard.Tags')}</label>
|
|
<TagBox
|
|
acceptCustomValue
|
|
searchEnabled
|
|
showClearButton
|
|
showSelectionControls
|
|
disabled={!canEdit}
|
|
items={tagSuggestions}
|
|
value={draftTags}
|
|
applyValueMode="instantly"
|
|
placeholder={translate('::ListForms.TodoBoard.TagsPlaceholder')}
|
|
onCustomItemCreating={(event) => {
|
|
event.customItem = event.text?.trim()
|
|
}}
|
|
onValueChanged={(event) => {
|
|
if (!event.event) return
|
|
const nextValue = (event.value ?? []).join(', ')
|
|
setDraft((current) =>
|
|
String(current[options.tagExpr!] ?? '') === nextValue
|
|
? current
|
|
: { ...current, [options.tagExpr!]: nextValue },
|
|
)
|
|
}}
|
|
/>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
<aside className="space-y-4 rounded-xl bg-gray-50 p-4 dark:bg-gray-800/60">
|
|
{options.statusExpr && (
|
|
<div>
|
|
<label className={labelClass}>{translate('::ListForms.TodoBoard.Status')}</label>
|
|
<select
|
|
className={inputClass}
|
|
disabled={!canEdit}
|
|
value={String(draft[options.statusExpr] ?? '')}
|
|
onChange={(event) =>
|
|
setDraft((current) => ({
|
|
...current,
|
|
[options.statusExpr!]: event.target.value,
|
|
}))
|
|
}
|
|
>
|
|
{statuses.map((status) => (
|
|
<option key={status} value={status}>
|
|
{status}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
)}
|
|
{options.dueDateExpr && (
|
|
<div>
|
|
<label className={labelClass}>{translate('::ListForms.TodoBoard.DueDate')}</label>
|
|
<input
|
|
type="datetime-local"
|
|
className={inputClass}
|
|
disabled={!canEdit}
|
|
value={
|
|
draft[options.dueDateExpr]
|
|
? dayjs(draft[options.dueDateExpr]).format('YYYY-MM-DDTHH:mm')
|
|
: ''
|
|
}
|
|
onChange={(event) =>
|
|
setDraft((current) => ({
|
|
...current,
|
|
[options.dueDateExpr!]: event.target.value || null,
|
|
}))
|
|
}
|
|
/>
|
|
</div>
|
|
)}
|
|
{options.priorityExpr && (
|
|
<div>
|
|
<label className={labelClass}>{translate('::ListForms.TodoBoard.Priority')}</label>
|
|
<div className="rounded-lg border border-gray-300 bg-white px-3 py-2 dark:border-gray-600 dark:bg-gray-800">
|
|
<Rate
|
|
allowClear
|
|
color="yellow"
|
|
disabled={!canEdit}
|
|
gap={7}
|
|
size={24}
|
|
tooltips={[
|
|
translate('::ListForms.TodoBoard.PriorityVeryLow'),
|
|
translate('::ListForms.TodoBoard.PriorityLow'),
|
|
translate('::ListForms.TodoBoard.PriorityMedium'),
|
|
translate('::ListForms.TodoBoard.PriorityHigh'),
|
|
translate('::ListForms.TodoBoard.PriorityUrgent'),
|
|
]}
|
|
value={priorityRating(draft[options.priorityExpr])}
|
|
onChange={(value) =>
|
|
setDraft((current) => ({
|
|
...current,
|
|
[options.priorityExpr!]: value,
|
|
}))
|
|
}
|
|
/>
|
|
<div className="mt-1 text-xs text-gray-500">
|
|
{priorityLabel(draft[options.priorityExpr], translate)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
{options.completedExpr && (
|
|
<label className="flex cursor-pointer items-center gap-3 rounded-lg border border-gray-200 bg-white p-3 text-sm font-medium dark:border-gray-700 dark:bg-gray-800">
|
|
<input
|
|
type="checkbox"
|
|
className="h-4 w-4 accent-emerald-500"
|
|
disabled={!canEdit}
|
|
checked={isCompleted(draft[options.completedExpr])}
|
|
onChange={(event) =>
|
|
setDraft((current) => ({
|
|
...current,
|
|
[options.completedExpr!]: event.target.checked,
|
|
}))
|
|
}
|
|
/>
|
|
{translate('::ListForms.TodoBoard.Completed')}
|
|
</label>
|
|
)}
|
|
</aside>
|
|
</div>
|
|
{canEdit && (
|
|
<div className="mt-6 flex justify-end gap-2 border-t border-gray-200 pt-4 dark:border-gray-700">
|
|
<button
|
|
type="button"
|
|
className="rounded-lg border border-gray-300 px-4 py-2 text-sm hover:bg-gray-50 dark:border-gray-600 dark:hover:bg-gray-800"
|
|
onClick={() => setDraft({ ...selected })}
|
|
>
|
|
{translate('::ListForms.TodoBoard.UndoChanges')}
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className="rounded-lg bg-blue-600 px-5 py-2 text-sm font-semibold text-white hover:bg-blue-700 disabled:opacity-50"
|
|
disabled={saving || !String(draft[options.titleExpr ?? ''] ?? '').trim()}
|
|
onClick={saveTodo}
|
|
>
|
|
{saving
|
|
? translate('::Saving')
|
|
: translate('::Save')}
|
|
</button>
|
|
</div>
|
|
)}
|
|
</section>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<div className="pb-3">
|
|
<div className="overflow-x-auto">
|
|
<div className="flex min-h-[420px] min-w-max gap-3 pt-3">
|
|
{statuses.map((status) => {
|
|
const items = itemsForStatus(status)
|
|
const isAdding = newTodoStatus === status
|
|
return (
|
|
<section
|
|
key={status}
|
|
className={`group w-[310px] rounded-xl border-2 p-2 transition-all ${draggedColumn !== undefined && columnDropTarget === status ? 'border-violet-400 bg-violet-50 shadow-lg dark:bg-violet-950/30' : draggedKey !== undefined && todoDropTarget?.status === status ? 'border-blue-400 bg-blue-50/70 shadow-lg dark:bg-blue-950/20' : 'border-transparent bg-gray-50 dark:bg-gray-900/60'} ${draggedColumn === status ? 'opacity-50' : ''}`}
|
|
onDragOver={(event) => {
|
|
event.preventDefault()
|
|
if (dragActiveRef.current && draggedColumn) {
|
|
setColumnDropTarget(status)
|
|
return
|
|
}
|
|
if (dragActiveRef.current && draggedKey !== undefined)
|
|
setTodoDropTarget({ status, index: items.length })
|
|
}}
|
|
onDrop={(event) => {
|
|
event.preventDefault()
|
|
if (draggedColumn) reorderColumn(status)
|
|
else
|
|
moveTodo(
|
|
status,
|
|
todoDropTarget?.status === status ? todoDropTarget.index : items.length,
|
|
event.dataTransfer.getData('text/todo-key'),
|
|
)
|
|
}}
|
|
>
|
|
<header className="mb-2 flex min-h-9 items-center gap-2 px-1">
|
|
<span
|
|
draggable
|
|
className="cursor-grab rounded p-1 text-gray-400 hover:bg-gray-200 hover:text-gray-600 active:cursor-grabbing dark:hover:bg-gray-700"
|
|
title={translate('::ListForms.TodoBoard.ReorderColumn')}
|
|
onDragStart={(event) => {
|
|
event.stopPropagation()
|
|
event.dataTransfer.effectAllowed = 'move'
|
|
dragActiveRef.current = true
|
|
setDraggedKey(undefined)
|
|
setDraggedColumn(status)
|
|
}}
|
|
onDragEnd={clearDragState}
|
|
>
|
|
<FaGripVertical />
|
|
</span>
|
|
{editingStatus === status ? (
|
|
<form
|
|
className="flex min-w-0 flex-1 items-center gap-1"
|
|
onSubmit={(event) => {
|
|
event.preventDefault()
|
|
renameStatus(status)
|
|
}}
|
|
>
|
|
<input
|
|
autoFocus
|
|
className="min-w-0 flex-1 rounded-md border border-blue-400 bg-white px-2 py-1 text-sm font-semibold outline-none ring-2 ring-blue-500/20 dark:bg-gray-800"
|
|
value={statusName}
|
|
disabled={renamingStatus}
|
|
aria-label={translate('::ListForms.TodoBoard.ColumnName')}
|
|
onChange={(event) => {
|
|
setStatusName(event.target.value)
|
|
setStatusError('')
|
|
}}
|
|
onKeyDown={(event) => {
|
|
if (event.key === 'Escape') {
|
|
setEditingStatus(undefined)
|
|
setStatusError('')
|
|
}
|
|
}}
|
|
/>
|
|
<button
|
|
type="submit"
|
|
className="rounded p-1.5 text-emerald-600 hover:bg-emerald-100 disabled:opacity-40 dark:hover:bg-emerald-950"
|
|
disabled={renamingStatus || !statusName.trim()}
|
|
title={translate('::Save')}
|
|
>
|
|
<FaCheck />
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className="rounded p-1.5 text-gray-400 hover:bg-gray-200 dark:hover:bg-gray-700"
|
|
title={translate('::Cancel')}
|
|
onClick={() => {
|
|
setEditingStatus(undefined)
|
|
setStatusError('')
|
|
}}
|
|
>
|
|
<FaTimes />
|
|
</button>
|
|
</form>
|
|
) : (
|
|
<>
|
|
<h3 className="min-w-0 truncate font-semibold" title={status}>
|
|
{status}
|
|
</h3>
|
|
<button
|
|
type="button"
|
|
className="rounded p-1 text-gray-400 opacity-0 transition hover:bg-gray-200 hover:text-blue-600 group-hover:opacity-100 dark:hover:bg-gray-700"
|
|
title={translate('::ListForms.TodoBoard.RenameColumn')}
|
|
onClick={() => {
|
|
setEditingStatus(status)
|
|
setStatusName(status)
|
|
setStatusError('')
|
|
}}
|
|
>
|
|
<FaPencilAlt className="text-xs" />
|
|
</button>
|
|
</>
|
|
)}
|
|
<span className="ml-auto rounded-full bg-gray-200 px-2 py-0.5 text-xs dark:bg-gray-700">
|
|
{items.length}
|
|
</span>
|
|
{gridDto.gridOptions.insertServiceAddress && (
|
|
<button
|
|
type="button"
|
|
draggable={false}
|
|
className="rounded p-1 text-gray-400 hover:bg-blue-100 hover:text-blue-600 dark:hover:bg-blue-950"
|
|
title={translate('::ListForms.TodoBoard.NewTodo')}
|
|
onClick={() => {
|
|
setNewTodoStatus(status)
|
|
setNewTodoTitle('')
|
|
}}
|
|
>
|
|
<FaPlus />
|
|
</button>
|
|
)}
|
|
<button
|
|
type="button"
|
|
draggable={false}
|
|
aria-disabled={items.length > 0}
|
|
className={`rounded p-1 transition ${items.length ? 'cursor-not-allowed text-gray-300 dark:text-gray-600' : 'text-gray-400 hover:bg-red-100 hover:text-red-600 dark:hover:bg-red-950'}`}
|
|
title={
|
|
items.length
|
|
? translate('::ListForms.TodoBoard.ColumnWithTodosCannotBeDeleted')
|
|
: translate('::ListForms.TodoBoard.DeleteColumn')
|
|
}
|
|
onClick={() => deleteStatus(status)}
|
|
>
|
|
<FaTrash className="text-xs" />
|
|
</button>
|
|
</header>
|
|
{editingStatus === status && statusError && (
|
|
<p className="mb-2 px-1 text-xs text-red-500">{statusError}</p>
|
|
)}
|
|
{statusNotice?.status === status && (
|
|
<div className="mb-2 flex items-start justify-between gap-2 rounded-lg bg-amber-50 px-2.5 py-2 text-xs text-amber-700 dark:bg-amber-950/40 dark:text-amber-200">
|
|
<span>{statusNotice.message}</span>
|
|
<button
|
|
type="button"
|
|
className="shrink-0 rounded p-0.5 hover:bg-amber-100 dark:hover:bg-amber-900"
|
|
title={translate('::ListForms.TodoBoard.Close')}
|
|
onClick={() => setStatusNotice(undefined)}
|
|
>
|
|
<FaTimes />
|
|
</button>
|
|
</div>
|
|
)}
|
|
<div className="h-1 overflow-hidden rounded-full bg-gray-200 dark:bg-gray-700">
|
|
<div
|
|
className="h-full bg-blue-500"
|
|
style={{ width: `${Math.min(100, items.length * 18)}%` }}
|
|
/>
|
|
</div>
|
|
{isAdding && (
|
|
<div className="mt-3 rounded border border-blue-300 bg-white p-2 dark:border-blue-700 dark:bg-gray-800">
|
|
<div className="flex gap-1">
|
|
<input
|
|
autoFocus
|
|
value={newTodoTitle}
|
|
className="min-w-0 flex-1 rounded border border-gray-300 bg-transparent px-2 py-1 text-sm outline-none focus:border-blue-500 dark:border-gray-600"
|
|
placeholder={translate('::ListForms.TodoBoard.TodoTitlePlaceholder')}
|
|
onChange={(event) => setNewTodoTitle(event.target.value)}
|
|
onKeyDown={(event) => {
|
|
if (event.key === 'Enter') addTodo(status)
|
|
if (event.key === 'Escape') setNewTodoStatus(undefined)
|
|
}}
|
|
/>
|
|
<button
|
|
type="button"
|
|
className="rounded bg-blue-600 px-2 text-white disabled:opacity-40"
|
|
disabled={!newTodoTitle.trim()}
|
|
onClick={() => addTodo(status)}
|
|
>
|
|
<FaPlus />
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className="rounded px-2 text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700"
|
|
onClick={() => setNewTodoStatus(undefined)}
|
|
>
|
|
<FaTimes />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
<div className="mt-3 space-y-2">
|
|
{items.map((row, index) => {
|
|
const key = row[keyField]
|
|
const tags = textList(fieldValue(row, options.tagExpr))
|
|
const assignees = textList(fieldValue(row, options.assigneeExpr))
|
|
const due = fieldValue(row, options.dueDateExpr)
|
|
const completed = isCompleted(fieldValue(row, options.completedExpr))
|
|
const priority = fieldValue(row, options.priorityExpr)
|
|
const isDragged = draggedKey === key
|
|
const hiddenAssigneeLabels = assignees
|
|
.slice(2)
|
|
.map((name) => assigneeOptionMap.get(name)?.label ?? name)
|
|
.join(', ')
|
|
return (
|
|
<Fragment key={String(key)}>
|
|
{draggedKey !== undefined &&
|
|
todoDropTarget?.status === status &&
|
|
todoDropTarget.index === index && (
|
|
<div className="flex h-8 items-center gap-2 rounded-lg border-2 border-dashed border-blue-500 bg-blue-100 px-3 text-xs font-semibold text-blue-700 dark:bg-blue-950/60 dark:text-blue-200">
|
|
<FaArrowLeft className="rotate-[-90deg]" />{' '}
|
|
{translate('::ListForms.TodoBoard.DropHere')}
|
|
</div>
|
|
)}
|
|
<article
|
|
draggable={Boolean(options.allowDragging)}
|
|
className={`group/card cursor-pointer rounded border bg-white p-3 shadow-sm transition hover:-translate-y-0.5 hover:shadow-md dark:bg-gray-800 ${isDragged ? 'scale-95 border-blue-400 opacity-35' : completed ? 'border-emerald-300 opacity-75 dark:border-emerald-800' : 'border-gray-200 dark:border-gray-700'}`}
|
|
onDragStart={(event) => {
|
|
event.stopPropagation()
|
|
event.dataTransfer.effectAllowed = 'move'
|
|
event.dataTransfer.setData('text/todo-key', String(key))
|
|
dragActiveRef.current = true
|
|
setDraggedColumn(undefined)
|
|
setDraggedKey(key)
|
|
}}
|
|
onDragEnd={clearDragState}
|
|
onDragOver={(event) => {
|
|
event.preventDefault()
|
|
event.stopPropagation()
|
|
if (!dragActiveRef.current) return
|
|
const bounds = event.currentTarget.getBoundingClientRect()
|
|
const dropAfter = event.clientY > bounds.top + bounds.height / 2
|
|
setTodoDropTarget({ status, index: index + (dropAfter ? 1 : 0) })
|
|
}}
|
|
onDrop={(event) => {
|
|
event.stopPropagation()
|
|
moveTodo(
|
|
status,
|
|
todoDropTarget?.status === status ? todoDropTarget.index : index,
|
|
event.dataTransfer.getData('text/todo-key'),
|
|
)
|
|
}}
|
|
onClick={() => openTodo(row)}
|
|
>
|
|
<div className="flex items-start gap-2">
|
|
<h4 className="font-semibold leading-5">
|
|
<span className={completed ? 'line-through text-gray-400' : ''}>
|
|
{String(fieldValue(row, options.titleExpr) ?? '')}
|
|
</span>
|
|
</h4>
|
|
<div className="ml-auto flex items-center gap-1">
|
|
{gridDto.gridOptions.deleteServiceAddress && (
|
|
<button
|
|
type="button"
|
|
className="hidden rounded p-1 text-gray-400 hover:bg-red-50 hover:text-red-500 group-hover/card:block dark:hover:bg-red-950"
|
|
title={translate('::ListForms.TodoBoard.DeleteTodo')}
|
|
onClick={(event) => {
|
|
event.stopPropagation()
|
|
deleteTodo(row)
|
|
}}
|
|
>
|
|
<FaTrash />
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
{fieldValue(row, options.descriptionExpr) && (
|
|
<p className="mt-1 line-clamp-2 text-xs leading-5 text-gray-500 dark:text-gray-400">
|
|
{String(fieldValue(row, options.descriptionExpr))}
|
|
</p>
|
|
)}
|
|
{due && (
|
|
<div className="mt-1 flex items-center gap-1 text-xs text-gray-500">
|
|
<FaClock />
|
|
{dayjs(due).fromNow()}
|
|
</div>
|
|
)}
|
|
{options.priorityExpr && (
|
|
<div
|
|
draggable={false}
|
|
className={`mt-2 inline-flex items-center gap-2 rounded-full px-2 py-1 ${priorityClass(priority)}`}
|
|
title={translate('::ListForms.TodoBoard.ChangePriority')}
|
|
onClick={(event) => event.stopPropagation()}
|
|
>
|
|
<Rate
|
|
allowClear
|
|
color="yellow"
|
|
gap={2}
|
|
size={14}
|
|
value={priorityRating(priority)}
|
|
onChange={(value) =>
|
|
updateTodoInline(row, { [options.priorityExpr!]: value })
|
|
}
|
|
/>
|
|
<span className="text-[10px] font-semibold">
|
|
{priorityLabel(priority, translate)}
|
|
</span>
|
|
</div>
|
|
)}
|
|
<div className="mt-3 flex items-center gap-2">
|
|
<div className="flex min-w-0 flex-1 flex-wrap items-center gap-1.5">
|
|
{tags.slice(0, 3).map((tag) => (
|
|
<span
|
|
key={tag}
|
|
className="inline-flex items-center gap-1 rounded-full bg-blue-50 px-2 py-0.5 text-[11px] text-blue-700 dark:bg-blue-950 dark:text-blue-200"
|
|
>
|
|
<FaTag />
|
|
{tag}
|
|
</span>
|
|
))}
|
|
{tags.length > 3 && (
|
|
<span className="text-[11px] text-gray-400">
|
|
+{tags.length - 3}
|
|
</span>
|
|
)}
|
|
</div>
|
|
<div className="flex shrink-0 items-center gap-1">
|
|
{assignees.slice(0, 2).map((name) => {
|
|
const assignee = assigneeOptionMap.get(name)
|
|
const label = assignee?.label ?? name
|
|
return (
|
|
<Avatar
|
|
key={name}
|
|
alt={label}
|
|
className="shrink-0 bg-indigo-500 text-[8px] font-bold text-white"
|
|
shape="circle"
|
|
size={20}
|
|
src={assignee?.avatarUrl}
|
|
title={label}
|
|
>
|
|
{initials(label)}
|
|
</Avatar>
|
|
)
|
|
})}
|
|
{assignees.length > 2 && (
|
|
<Avatar
|
|
className="bg-gray-200 text-[9px] font-semibold text-gray-600 dark:bg-gray-700 dark:text-gray-200"
|
|
shape="circle"
|
|
size={20}
|
|
title={hiddenAssigneeLabels}
|
|
>
|
|
+{assignees.length - 2}
|
|
</Avatar>
|
|
)}
|
|
</div>
|
|
{options.completedExpr && (
|
|
<button
|
|
type="button"
|
|
draggable={false}
|
|
className={`ml-1 shrink-0 rounded-full p-1 text-lg transition ${completed ? 'text-emerald-500 hover:bg-emerald-50 dark:hover:bg-emerald-950' : 'text-gray-400 hover:bg-gray-100 hover:text-emerald-500 dark:hover:bg-gray-700'}`}
|
|
title={
|
|
completed
|
|
? translate('::ListForms.TodoBoard.ReopenTodo')
|
|
: translate('::ListForms.TodoBoard.CompleteTodo')
|
|
}
|
|
onClick={(event) => {
|
|
event.stopPropagation()
|
|
updateTodoInline(row, { [options.completedExpr!]: !completed })
|
|
}}
|
|
>
|
|
{completed ? <FaCheckCircle /> : <FaRegCircle />}
|
|
</button>
|
|
)}
|
|
</div>
|
|
</article>
|
|
</Fragment>
|
|
)
|
|
})}
|
|
{draggedKey !== undefined &&
|
|
todoDropTarget?.status === status &&
|
|
todoDropTarget.index === items.length && (
|
|
<div className="flex h-12 items-center justify-center gap-2 rounded-lg border-2 border-dashed border-blue-500 bg-blue-100 text-xs font-semibold text-blue-700 dark:bg-blue-950/60 dark:text-blue-200">
|
|
<FaArrowLeft className="rotate-[-90deg]" />{' '}
|
|
{translate('::ListForms.TodoBoard.DropHere')}
|
|
</div>
|
|
)}
|
|
{!items.length && draggedKey === undefined && (
|
|
<div className="flex min-h-24 items-center justify-center rounded-lg border-2 border-dashed border-gray-200 px-4 text-center text-xs text-gray-400 dark:border-gray-700"></div>
|
|
)}
|
|
</div>
|
|
</section>
|
|
)
|
|
})}
|
|
<section className="group w-[310px] shrink-0 rounded-xl border-2 border-transparent bg-gray-50 p-2 dark:bg-gray-900/60">
|
|
<header className="mb-2 flex min-h-9 items-center gap-2 px-1">
|
|
<FaGripVertical className="text-gray-300 dark:text-gray-700" />
|
|
<h3 className="font-semibold text-gray-500 dark:text-gray-400">
|
|
{translate('::ListForms.TodoBoard.NewColumn')}
|
|
</h3>
|
|
<span className="ml-auto rounded-full bg-gray-200 px-2 py-0.5 text-xs text-gray-500 dark:bg-gray-700 dark:text-gray-400">
|
|
0
|
|
</span>
|
|
<button
|
|
type="button"
|
|
className="rounded p-1 text-gray-400 hover:bg-blue-100 hover:text-blue-600 dark:hover:bg-blue-950"
|
|
title={translate('::ListForms.TodoBoard.AddNewColumn')}
|
|
onClick={() => {
|
|
setIsAddingStatus(true)
|
|
setStatusError('')
|
|
}}
|
|
>
|
|
<FaPlus />
|
|
</button>
|
|
</header>
|
|
<div className="h-1 overflow-hidden rounded-full bg-gray-200 dark:bg-gray-700" />
|
|
<div className="mt-3">
|
|
{isAddingStatus ? (
|
|
<form
|
|
className="rounded-lg border border-blue-300 bg-white p-3 shadow-sm dark:border-blue-800 dark:bg-gray-800"
|
|
onSubmit={(event) => {
|
|
event.preventDefault()
|
|
addStatus()
|
|
}}
|
|
>
|
|
<label className="mb-2 block text-sm font-semibold" htmlFor="new-kanban-name">
|
|
{translate('::ListForms.TodoBoard.NewColumnName')}
|
|
</label>
|
|
<input
|
|
autoFocus
|
|
id="new-kanban-name"
|
|
className="w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm outline-none focus:border-blue-500 focus:ring-2 focus:ring-blue-500/20 dark:border-gray-600 dark:bg-gray-800"
|
|
placeholder={translate('::ListForms.TodoBoard.NewColumnPlaceholder')}
|
|
value={newStatusName}
|
|
onChange={(event) => {
|
|
setNewStatusName(event.target.value)
|
|
setStatusError('')
|
|
}}
|
|
onKeyDown={(event) => {
|
|
if (event.key === 'Escape') {
|
|
setIsAddingStatus(false)
|
|
setNewStatusName('')
|
|
setStatusError('')
|
|
}
|
|
}}
|
|
/>
|
|
{statusError && <p className="mt-2 text-xs text-red-500">{statusError}</p>}
|
|
<div className="mt-3 flex gap-2">
|
|
<button
|
|
type="submit"
|
|
className="rounded-lg bg-blue-600 px-3 py-2 text-xs font-semibold text-white hover:bg-blue-700 disabled:opacity-40"
|
|
disabled={!newStatusName.trim()}
|
|
>
|
|
{translate('::ListForms.TodoBoard.AddColumn')}
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className="rounded-lg px-3 py-2 text-xs font-semibold text-gray-500 hover:bg-gray-200 dark:hover:bg-gray-800"
|
|
onClick={() => {
|
|
setIsAddingStatus(false)
|
|
setNewStatusName('')
|
|
setStatusError('')
|
|
}}
|
|
>
|
|
{translate('::Cancel')}
|
|
</button>
|
|
</div>
|
|
</form>
|
|
) : (
|
|
<button
|
|
type="button"
|
|
className="flex min-h-24 w-full items-center justify-center gap-2 rounded-lg border-2 border-dashed border-gray-200 px-4 text-sm font-medium text-gray-400 transition hover:border-blue-400 hover:bg-blue-50 hover:text-blue-600 dark:border-gray-700 dark:hover:bg-blue-950/20"
|
|
onClick={() => {
|
|
setIsAddingStatus(true)
|
|
setStatusError('')
|
|
}}
|
|
>
|
|
<FaPlus /> {translate('::ListForms.TodoBoard.AddNewColumn')}
|
|
</button>
|
|
)}
|
|
</div>
|
|
</section>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
export default TodoBoard
|