2127 lines
91 KiB
TypeScript
2127 lines
91 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 { 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
|