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 gridDto?: GridDto } type TodoRow = Record interface DropTarget { status: string index: number } interface StoredBoardColumns { columns: string[] retired: string[] } interface StatusNotice { status: string message: string } interface UserOption { value: string label: string avatarUrl?: string } type LookupDisplayValues = Record> type DetailTab = 'description' | 'timesheet' | 'subtask' type JsonRow = Record const TODO_STATUS_LANGUAGE_KEYS: Record = { backlog: 'App.StaticLookup.Backlog', 'ready to start': 'App.StaticLookup.ReadyToStart', 'in progress': 'App.StaticLookup.InProgress', 'code review': 'App.StaticLookup.CodeReview', testing: 'App.StaticLookup.Testing', staging: 'App.StaticLookup.Staging', } const fieldValue = (row: TodoRow, field?: string) => (field ? row[field] : undefined) const tenantIdValue = (row: TodoRow) => row.TenantId ?? row.tenantId const htmlToPlainText = (value: unknown) => { const html = String(value ?? '') if (!html) return '' if (typeof document === 'undefined') return html.replace(/<[^>]*>/g, ' ') const container = document.createElement('div') container.innerHTML = html return (container.textContent ?? '').replace(/\s+/g, ' ').trim() } const textList = (value: unknown) => Array.isArray(value) ? value.map(String) : String(value ?? '') .split(/[,;|]/) .map((item) => item.trim()) .filter(Boolean) const 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, ) => { 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 ? ( ) : null } const TodoBoardContent = ({ listFormCode, searchParams, isSubForm, gridDto, }: TodoBoardProps & { gridDto: GridDto }) => { const options = gridDto.gridOptions.todoOptionDto as TodoOptionDto const editingOptions = gridDto.gridOptions.editingOptionDto const canAdd = editingOptions?.allowAdding === true && Boolean(gridDto.gridOptions.insertServiceAddress) const canUpdate = editingOptions?.allowUpdating === true && Boolean(gridDto.gridOptions.updateServiceAddress) const canDelete = editingOptions?.allowDeleting === true && Boolean(gridDto.gridOptions.deleteServiceAddress) const keyField = gridDto.gridOptions.keyFieldName ?? 'id' const userNameField = gridDto.columnFormats?.find((column) => column.fieldName?.toLowerCase() === 'username') ?.fieldName ?? 'UserName' const stateStoring = gridDto.gridOptions.stateStoringDto const currentUserName = useStoreState((state) => state.auth.user.userName) const gridRef = useRef | undefined>(undefined) // 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>() const [rows, setRows] = useState([]) const [statuses, setStatuses] = useState([]) const [selected, setSelected] = useState() const [draft, setDraft] = useState({}) const [saving, setSaving] = useState(false) const [loading, setLoading] = useState(true) const [draggedKey, setDraggedKey] = useState() const [draggedColumn, setDraggedColumn] = useState() const [todoDropTarget, setTodoDropTarget] = useState() const [columnDropTarget, setColumnDropTarget] = useState() const [newTodoStatus, setNewTodoStatus] = useState() const [newTodoTitle, setNewTodoTitle] = useState('') const [editingStatus, setEditingStatus] = useState() const [statusName, setStatusName] = useState('') const [isAddingStatus, setIsAddingStatus] = useState(false) const [newStatusName, setNewStatusName] = useState('') const [statusError, setStatusError] = useState('') const [statusNotice, setStatusNotice] = useState() const [renamingStatus, setRenamingStatus] = useState(false) const [columnConfig, setColumnConfig] = useState() const [userOptions, setUserOptions] = useState([]) const [tenantNames, setTenantNames] = useState>(new Map()) const [usersLoading, setUsersLoading] = useState(false) const [lookupDisplayValues, setLookupDisplayValues] = useState({}) const [detailTab, setDetailTab] = useState('description') const focusRowRef = useRef<{ field?: string; index: number } | undefined>(undefined) const columnConfigRef = useRef({ columns: [], retired: [] }) const dragActiveRef = useRef(false) const { translate } = useLocalization() const { checkPermission } = usePermission() const isPwaMode = usePWA() const uncategorized = translate('::App.ListFormTodoBoard.Uncategorized') const lookupValuesForField = useCallback( (field?: string) => { if (!field) return undefined const dataField = listFormColumns.find( (column) => column.dataField?.toLowerCase() === field.toLowerCase(), )?.dataField return dataField ? lookupDisplayValues[dataField] : undefined }, [listFormColumns, lookupDisplayValues], ) const lookupText = useCallback( (field: string | undefined, value: unknown) => lookupValuesForField(field)?.get(String(value)) ?? String(value ?? ''), [lookupValuesForField], ) const statusText = useCallback( (status: string) => { const languageKey = TODO_STATUS_LANGUAGE_KEYS[status.trim().toLocaleLowerCase()] return languageKey ? translate(`::${languageKey}`) : lookupText(options.statusExpr, status) }, [lookupText, options.statusExpr, translate], ) const clearDragState = useCallback(() => { dragActiveRef.current = false setDraggedKey(undefined) setDraggedColumn(undefined) setTodoDropTarget(undefined) setColumnDropTarget(undefined) }, []) const { customLoadState, customSaveState } = useListFormStateStoring({ listFormCode, storageKey: stateStoring?.enabled ? stateStoring.storageKey || listFormCode : undefined, filterPrefix: 'todo', saveDebounceMs: stateStoring?.savingTimeout, showSaveToast: false, }) const customLoadStateRef = useRef(customLoadState) const customSaveStateRef = useRef(customSaveState) customLoadStateRef.current = customLoadState customSaveStateRef.current = customSaveState useEffect(() => { const finishDrag = () => window.setTimeout(clearDragState, 0) const cancelWithEscape = (event: KeyboardEvent) => { if (event.key === 'Escape' && dragActiveRef.current) finishDrag() } window.addEventListener('dragend', finishDrag, true) window.addEventListener('drop', finishDrag, true) window.addEventListener('keydown', cancelWithEscape, true) return () => { window.removeEventListener('dragend', finishDrag, true) window.removeEventListener('drop', finishDrag, true) window.removeEventListener('keydown', cancelWithEscape, true) } }, [clearDragState]) useEffect(() => { let cancelled = false setUsersLoading(true) getUsers(0, 1000) .then((response) => { if (cancelled) return const users = response.data?.items ?? [] setUserOptions( users .filter((user: IdentityUserDto) => Boolean(user.userName)) .map((user: IdentityUserDto) => { const fullName = [user.name, user.surname].filter(Boolean).join(' ') return { value: user.userName!, label: fullName || user.userName!, avatarUrl: AVATAR_URL(user.id, user.tenantId), } }), ) }) .catch((error) => console.error('Todo assignee users load error:', error)) .finally(() => { if (!cancelled) setUsersLoading(false) }) return () => { cancelled = true } }, []) useEffect(() => { let active = true setLookupDisplayValues({}) loadLookupDisplayValuesRef .current(listFormColumns) .then((values) => { if (active) setLookupDisplayValues(values) }) .catch((error) => console.error('TodoBoard lookup load error:', error)) return () => { active = false } }, [listFormColumns]) useEffect(() => { let cancelled = false getTenants(0, 1000) .then((response) => { if (cancelled) return setTenantNames( new Map( (response.data?.items ?? []) .filter((tenant) => Boolean(tenant.id && tenant.name)) .map((tenant) => [String(tenant.id).toLowerCase(), tenant.name!] as const), ), ) }) .catch((error) => console.error('Todo tenants load error:', error)) return () => { cancelled = true } }, []) const loadRows = async (dataSource: CustomStore) => { const result: any = await dataSource.load({ skip: 0, take: 1000, requireTotalCount: true }) const loadedRows = Array.isArray(result) ? result : (result?.data ?? []) setRows(loadedRows) return loadedRows as TodoRow[] } const refreshData = async () => { if (!store) return const loadedRows = await loadRows(store) if (!selected) return const refreshed = loadedRows.find((row) => row[keyField] === selected[keyField]) if (refreshed) { setSelected(refreshed) setDraft({ ...refreshed }) } else { setSelected(undefined) } } const openFormManager = () => { window.open( ROUTES_ENUM.protected.saas.listFormManagement.edit.replace(':listFormCode', listFormCode), isPwaMode ? '_self' : '_blank', ) } const renderToolbar = (showBackToBoard = false) => ( {showBackToBoard && ( setSelected(undefined), }} /> )} {canAdd && ( { setIsAddingStatus(true) setStatusError('') }, }} /> )} {checkPermission('App.Listforms.Listform.Update') && ( )} ) useEffect(() => { const dataSource = createSelectDataSource( gridDto.gridOptions, listFormCode, searchParams, 'todo', ) setStore(dataSource) setLoading(true) loadRows(dataSource).finally(() => setLoading(false)) }, [createSelectDataSource, gridDto, listFormCode, searchParams]) const availableStatuses = useMemo(() => { const configured = textList(options.statusOrder) const actual = rows.map((row) => String(fieldValue(row, options.statusExpr) ?? uncategorized)) return [...new Set([...configured, ...actual])] }, [options.statusExpr, options.statusOrder, rows, uncategorized]) const assigneeSuggestions = useMemo( () => [...new Set(rows.flatMap((row) => textList(fieldValue(row, options.assigneeExpr))))], [options.assigneeExpr, rows], ) const assigneeOptions = useMemo(() => { const lookupValues = lookupValuesForField(options.assigneeExpr) const avatarUrls = new Map(userOptions.map((option) => [option.value, option.avatarUrl])) const configuredOptions = lookupValues ? Array.from(lookupValues, ([value, label]) => ({ value, label, avatarUrl: avatarUrls.get(value), })) : userOptions const knownUsers = new Set(configuredOptions.map((option) => option.value)) return [ ...configuredOptions, ...assigneeSuggestions .filter((value) => !knownUsers.has(value)) .map((value) => ({ value, label: value })), ] }, [assigneeSuggestions, lookupValuesForField, options.assigneeExpr, userOptions]) const assigneeOptionMap = useMemo( () => new Map(assigneeOptions.map((option) => [option.value, option])), [assigneeOptions], ) const tagSuggestions = useMemo( () => [...new Set(rows.flatMap((row) => textList(fieldValue(row, options.tagExpr))))], [options.tagExpr, rows], ) const draftAssignees = useMemo( () => textList(fieldValue(draft, options.assigneeExpr)), [draft, options.assigneeExpr], ) const draftTags = useMemo( () => textList(fieldValue(draft, options.tagExpr)), [draft, options.tagExpr], ) const 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() 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>() targetItems.forEach((row, index) => { const values: Record = { [options.statusExpr!]: status } if (options.orderExpr) values[options.orderExpr] = index updates.set(row[keyField], values) }) if (options.orderExpr) { sourceItems.forEach((row, index) => updates.set(row[keyField], { [options.orderExpr!]: index }), ) } const updated = rows.map((row) => updates.has(row[keyField]) ? { ...row, ...updates.get(row[keyField]) } : row, ) const targetKeys = new Set(targetItems.map((row) => row[keyField])) setRows( options.orderExpr ? updated : [...updated.filter((row) => !targetKeys.has(row[keyField])), ...targetItems], ) setDraggedKey(undefined) setTodoDropTarget(undefined) if (gridDto.gridOptions.updateServiceAddress) { await Promise.allSettled([...updates].map(([key, values]) => store.update(key, values))) await loadRows(store) } } const addTodo = async (status: string) => { const title = newTodoTitle.trim() if (!canAdd || !title || !store || !options.titleExpr || !options.statusExpr) return const values: Record = { [options.titleExpr]: title, [options.statusExpr]: status, [userNameField]: currentUserName, } if (options.completedExpr) values[options.completedExpr] = false if (options.orderExpr) values[options.orderExpr] = itemsForStatus(status).length const inserted: any = await store.insert(values) const loadedRows = await loadRows(store) setNewTodoTitle('') setNewTodoStatus(undefined) const created = loadedRows.find( (row) => (inserted?.[keyField] !== undefined && row[keyField] === inserted[keyField]) || (row[options.titleExpr!] === title && row[options.statusExpr!] === status), ) if (created) openTodo(created) } const deleteTodo = async (row: TodoRow) => { if (!canDelete || !store) return if ( !window.confirm( translate('::App.ListFormTodoBoard.ConfirmDeleteTodo', { 0: String(fieldValue(row, options.titleExpr) ?? ''), }), ) ) return await store.remove(row[keyField]) setRows((current) => current.filter((item) => item[keyField] !== row[keyField])) if (selected?.[keyField] === row[keyField]) setSelected(undefined) } const openTodo = (row: TodoRow) => { setSelected(row) setDraft({ ...row }) 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) => { if (!canUpdate) return const updated = { ...row, ...values } setRows((current) => current.map((item) => (item[keyField] === row[keyField] ? updated : item))) if (selected?.[keyField] === row[keyField]) { setSelected(updated) setDraft((current) => ({ ...current, ...values })) } if (store && gridDto.gridOptions.updateServiceAddress) { await store.update(row[keyField], values) } } if (loading) return (
{translate('::App.ListFormTodoBoard.Loading')}
) if (selected) { const inputClass = 'w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm outline-none transition focus:border-blue-500 focus:ring-2 focus:ring-blue-500/20 disabled:bg-gray-100 dark:border-gray-600 dark:bg-gray-800 dark:disabled:bg-gray-900' const labelClass = 'mb-1.5 block text-xs font-semibold uppercase tracking-wide text-gray-500' const 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 (
{renderToolbar(true)}
{editingOptions?.allowUpdating === true && !gridDto.gridOptions.updateServiceAddress && ( {translate('::App.ListFormTodoBoard.EditRequiresUpdateService')} )} {options.statusExpr && (
{statuses.map((status) => { const isActiveStatus = String(draft[options.statusExpr!] ?? '') === status return ( ) })}
)}
{options.titleExpr && (
setDraft((current) => ({ ...current, [options.titleExpr!]: event.target.value, })) } />
)} {detailTabs.length > 0 && (
{detailTabs.map((tab) => ( ))}
{activeTab === 'description' && options.descriptionExpr && ( setDraft((current) => ({ ...current, [options.descriptionExpr!]: event.value, })) } /> )} {activeTab === 'timesheet' && options.timesheetExpr && (
{hasInvalidJson(fieldValue(draft, options.timesheetExpr)) && (

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

)}
{timesheetRows.map((row, index) => ( ))} {!timesheetRows.length && ( )} {timesheetRows.length > 0 && ( )}
{translate('::App.ListFormTodoBoard.TimesheetDate')} {translate('::App.ListFormTodoBoard.TimesheetEmployee')} {translate('::App.Listform.ListformField.Description')} {translate('::App.ListFormTodoBoard.TimesheetDuration')}
updateJsonRow(options.timesheetExpr, timesheetRows, index, { date: event.target.value || null, }) } /> updateJsonRow(options.timesheetExpr, timesheetRows, index, { description: event.target.value, }) } /> updateJsonRow(options.timesheetExpr, timesheetRows, index, { duration: timeToDuration(event.target.value), }) } /> {canUpdate && ( )}
{translate('::App.ListFormTodoBoard.NoRows')}
{translate('::App.ListFormTodoBoard.TimesheetTotal')} {formatDuration(timesheetTotal)}
{canUpdate && ( )}
)} {activeTab === 'subtask' && options.subTaskExpr && (
{hasInvalidJson(fieldValue(draft, options.subTaskExpr)) && (

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

)}
{subTaskRows.map((row, index) => ( ))} {!subTaskRows.length && ( )}
{translate('::App.ListFormTodoBoard.SubTaskTitle')} {translate('::App.ListFormTodoBoard.SubTaskAssignee')} {translate('::App.ListFormTodoBoard.SubTaskDueDate')}
updateJsonRow(options.subTaskExpr, subTaskRows, index, { completed: event.target.checked, }) } /> updateJsonRow(options.subTaskExpr, subTaskRows, index, { title: event.target.value, }) } /> updateJsonRow(options.subTaskExpr, subTaskRows, index, { dueDate: event.target.value || null, }) } /> {canUpdate && ( )}
{translate('::App.ListFormTodoBoard.NoRows')}
{canUpdate && ( )}
)}
)}
{(canUpdate || canDelete) && (
{canDelete && ( )}
{canUpdate && (
)}
)}
) } return (
{renderToolbar()}
{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 (
{ event.preventDefault() if (dragActiveRef.current && draggedColumn) { setColumnDropTarget(status) return } if (dragActiveRef.current && draggedKey !== undefined) setTodoDropTarget({ status, index: items.length }) }} onDrop={(event) => { event.preventDefault() if (draggedColumn) reorderColumn(status) else moveTodo( status, todoDropTarget?.status === status ? todoDropTarget.index : items.length, event.dataTransfer.getData('text/todo-key'), ) }} >
{ event.stopPropagation() event.dataTransfer.effectAllowed = 'move' dragActiveRef.current = true setDraggedKey(undefined) setDraggedColumn(status) }} onDragEnd={clearDragState} > {editingStatus === status ? (
{ event.preventDefault() renameStatus(status) }} > { setStatusName(event.target.value) setStatusError('') }} onKeyDown={(event) => { if (event.key === 'Escape') { setEditingStatus(undefined) setStatusError('') } }} />
) : ( <>

{statusText(status)}

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

{statusError}

)} {statusNotice?.status === status && (
{statusNotice.message}
)}
{isAdding && (
setNewTodoTitle(event.target.value)} onKeyDown={(event) => { if (event.key === 'Enter') addTodo(status) if (event.key === 'Escape') setNewTodoStatus(undefined) }} />
)}
{items.map((row, index) => { const key = row[keyField] const tags = textList(fieldValue(row, options.tagExpr)) const assignees = textList(fieldValue(row, options.assigneeExpr)) const due = fieldValue(row, options.dueDateExpr) const completed = isCompleted(fieldValue(row, options.completedExpr)) const priority = fieldValue(row, options.priorityExpr) const tenantId = tenantIdValue(row) const tenantName = tenantId ? tenantNames.get(String(tenantId).toLowerCase()) : undefined const isDragged = draggedKey === key const 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 ( {draggedKey !== undefined && todoDropTarget?.status === status && todoDropTarget.index === index && (
{' '} {translate('::App.Platform.DropHere')}
)}
{ event.stopPropagation() event.dataTransfer.effectAllowed = 'move' event.dataTransfer.setData('text/todo-key', String(key)) dragActiveRef.current = true setDraggedColumn(undefined) setDraggedKey(key) }} onDragEnd={clearDragState} onDragOver={(event) => { event.preventDefault() event.stopPropagation() if (!dragActiveRef.current) return const bounds = event.currentTarget.getBoundingClientRect() const dropAfter = event.clientY > bounds.top + bounds.height / 2 setTodoDropTarget({ status, index: index + (dropAfter ? 1 : 0) }) }} onDrop={(event) => { event.stopPropagation() moveTodo( status, todoDropTarget?.status === status ? todoDropTarget.index : index, event.dataTransfer.getData('text/todo-key'), ) }} onClick={() => openTodo(row)} >

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

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

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

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

{statusError}

}
)}
) } export default TodoBoard