sozsoft-platform/ui/src/views/list/GanttView.tsx
2026-09-30 23:52:33 +03:00

630 lines
24 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

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

import Container from '@/components/shared/Container'
import { DX_CLASSNAMES } from '@/constants/app.constant'
import { GridDto, ListFormCustomizationTypeEnum } from '@/proxy/form/models'
import { useLocalization } from '@/utils/hooks/useLocalization'
import Gantt, {
Column,
ContextMenu,
Editing,
FilterRow,
type GanttRef,
HeaderFilter,
Item,
Sorting,
Tasks,
Toolbar,
Validation,
} from 'devextreme-react/gantt'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import PageTitle from '@/components/shared/PageTitle'
import { useStylesheet } from '@/utils/hooks/useStylesheet'
import { useListFormCustomDataSource } from './useListFormCustomDataSource'
import { layoutTypes } from '../admin/listForm/edit/types'
import WidgetGroup from '@/components/ui/Widget/WidgetGroup'
import { ROUTES_ENUM } from '@/routes/route.constant'
import { usePWA } from '@/utils/hooks/usePWA'
import type { ContentReadyEvent, GanttScaleType } from 'devextreme/ui/gantt'
import { useListFormColumns } from './useListFormColumns'
import CustomStore from 'devextreme/data/custom_store'
import { GridColumnData } from './GridColumnData'
import { Loading } from '@/components/shared'
import { usePermission } from '@/utils/hooks/usePermission'
import { useListFormStateStoring } from './useListFormStateStoring'
import TreeList from 'devextreme/ui/tree_list'
import { postListFormCustomization } from '@/services/list-form-customization.service'
import { useFilters } from './useFilters'
import {
useListFormCustomSources,
useListFormGridDto,
useListFormWidgets,
useWidgetGroupHeight,
} from './shared/hooks'
import GridFilterDialogs from './GridFilterDialogs'
import { useStoreState } from '@/store'
type GanttInstance = ReturnType<GanttRef['instance']>
type GanttTreeListInstance = {
element: () => HTMLElement
getVisibleColumns: () => Array<Record<string, any>>
option: (name: string, value?: unknown) => any
showColumnChooser: () => void
state: {
(): Record<string, any>
(value: Record<string, any> | null): void
}
}
// Gorev listesi kolon sayisina gore acilir; DevExtreme TreeList sabit width verilmezse
// kolonlari mevcut genislige esit paylastirip kolon arttikca hepsini okunamaz hale getiriyor.
const DEFAULT_TASK_LIST_COLUMN_WIDTH = 100
const MIN_TASK_LIST_WIDTH = 300
const MAX_TASK_LIST_WIDTH_RATIO = 0.8
const TASK_LIST_WIDTH_PADDING = 2
const getGanttTreeList = (gantt: GanttInstance) => {
const element = (gantt.element() as HTMLElement).querySelector('.dx-treelist')
return element
? (TreeList.getInstance(element as HTMLElement) as unknown as GanttTreeListInstance)
: undefined
}
interface GanttViewProps {
listFormCode: string
searchParams?: URLSearchParams
isSubForm?: boolean
level?: number
refreshData?: () => Promise<void>
gridDto?: GridDto
}
const GanttView = (props: GanttViewProps) => {
const { listFormCode, searchParams, isSubForm, gridDto: extGridDto } = props
const { translate } = useLocalization()
const currentCulture = useStoreState(
(state) => state.abpConfig.config?.localization.currentCulture.cultureName,
)
const localizationTexts = useStoreState((state) => state.abpConfig.texts)
const isPwaMode = usePWA()
// Eskiden Helmet'in <link> çocuğuydu; koşul aynen korunuyor.
useStylesheet(isSubForm ? null : '/css/gantt/dx-gantt.min.css', 'dx-gantt-css')
const gridRef = useRef<GanttRef | undefined>(undefined)
const widgetGroupRef = useRef<HTMLDivElement>(null)
const { checkPermission } = usePermission()
const [ganttDataSource, setGanttDataSource] = useState<CustomStore<any, any>>()
const [columnData, setColumnData] = useState<GridColumnData[]>()
const gridDto = useListFormGridDto(listFormCode, extGridDto)
const [scaleType, setScaleType] = useState<GanttScaleType>('weeks')
const [taskListWidth, setTaskListWidth] = useState(MIN_TASK_LIST_WIDTH)
const layout = layoutTypes.gantt || 'gantt'
const pendingStateRef = useRef<Record<string, any> | undefined>(undefined)
const loadedStateKeyRef = useRef('')
const { widgets, refreshWidgets } = useListFormWidgets(listFormCode, gridDto, isSubForm)
const widgetGroupHeight = useWidgetGroupHeight(widgetGroupRef, widgets)
useListFormCustomSources(gridDto)
const { customSaveState, customLoadState, storageKey } = useListFormStateStoring({
listFormCode,
storageKey: gridDto?.gridOptions.stateStoringDto?.storageKey,
filterPrefix: 'gantt',
})
const ganttFilterRef = useRef<any>({
instance: () => {
const gantt = gridRef.current?.instance()
return gantt ? getGanttTreeList(gantt) : undefined
},
})
const { filterToolbarData, ...filterData } = useFilters({
gridDto,
gridRef: ganttFilterRef,
listFormCode,
searchParams,
})
const ganttFilterToolbarData = filterToolbarData.filter((item) =>
['saveActiveFilter', 'deleteFilter', 'clearFilter'].includes(item.name ?? ''),
)
useEffect(() => {
setScaleType(gridDto?.gridOptions.ganttOptionDto?.scaleType || 'weeks')
}, [gridDto])
// listFormCode değişiminde eski veri kaynağını temizle.
useEffect(() => {
gridRef.current?.instance()?.option('dataSource', undefined)
}, [listFormCode])
const { createSelectDataSource } = useListFormCustomDataSource({ gridRef })
const { getBandedColumns } = useListFormColumns({
gridDto,
listFormCode,
isSubForm,
gridRef,
})
// Kolonlar dil değiştiğinde de yeniden üretilir.
const memoizedColumns = useMemo(() => {
if (!gridDto) return undefined
return getBandedColumns()
// getBandedColumns her render'da yeni referans üretebildiği için bağımlılığa alınmaz.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [gridDto, currentCulture, localizationTexts])
// Gantt, dataSource'u prop olarak aldigi icin ilk yukleme widget kurulurken,
// yani gridRef atanmadan once calisir. Kolonlar store'a verilmezse ilk yuklemede
// multiValue alanlar '|' ile ayrilmadan kalir ve hucrede ham deger gorunur.
const memoizedDataSource = useMemo(() => {
if (!gridDto) return undefined
return createSelectDataSource(
gridDto.gridOptions,
listFormCode,
searchParams,
layout,
memoizedColumns,
)
}, [createSelectDataSource, gridDto, layout, listFormCode, memoizedColumns, searchParams])
// Cizelgede zaten gorsellesen alanlar (baslangic/bitis/ilerleme/ust gorev) gorev
// listesinde ikinci kez yer kaplamasin; gerekirse Sutun Secici'den geri acilir.
const ganttColumns = useMemo(() => {
const options = gridDto?.gridOptions.ganttOptionDto
const chartFields = new Set(
[options?.parentIdExpr, options?.startExpr, options?.endExpr, options?.progressExpr].filter(
Boolean,
) as string[],
)
return (columnData ?? [])
.filter((column: any) => column.type !== 'buttons')
.map((column: any) => ({
...column,
// width baslangic degeridir; alt sinir konmadigi icin fareyle serbestce daraltilabilir.
width: column.width ?? DEFAULT_TASK_LIST_COLUMN_WIDTH,
...(chartFields.has(column.dataField) ? { visible: false } : {}),
}))
}, [columnData, gridDto])
// Kullanici bolucuyu surukledigi ya da kayitli gorunum yuklendigi anda otomatik
// genislik devreden cikar; sonrasinda panel yalnizca fareyle degisir.
const taskListWidthLockedRef = useRef(false)
const clampTaskListWidth = useCallback((width: number) => {
const ganttWidth =
(gridRef.current?.instance()?.element() as HTMLElement | undefined)?.getBoundingClientRect()
.width || window.innerWidth
const upperBound = Math.max(
MIN_TASK_LIST_WIDTH,
Math.round(ganttWidth * MAX_TASK_LIST_WIDTH_RATIO),
)
return Math.min(Math.max(Math.round(width), MIN_TASK_LIST_WIDTH), upperBound)
}, [])
const defaultTaskListWidth = useMemo(() => {
const visibleColumns = ganttColumns.filter((column: any) => column.visible !== false)
if (visibleColumns.length === 0) return MIN_TASK_LIST_WIDTH
return clampTaskListWidth(
visibleColumns.reduce(
(sum: number, column: any) => sum + (column.width ?? DEFAULT_TASK_LIST_COLUMN_WIDTH),
TASK_LIST_WIDTH_PADDING,
),
)
}, [clampTaskListWidth, ganttColumns])
useEffect(() => {
if (taskListWidthLockedRef.current) return
setTaskListWidth(defaultTaskListWidth)
}, [defaultTaskListWidth])
useEffect(() => setColumnData(memoizedColumns), [memoizedColumns])
useEffect(() => setGanttDataSource(memoizedDataSource), [memoizedDataSource])
const settingButtonClick = useCallback(() => {
window.open(
ROUTES_ENUM.protected.saas.listFormManagement.edit.replace(':listFormCode', listFormCode),
isPwaMode ? '_self' : '_blank',
)
}, [isPwaMode, listFormCode])
const getSettingButtonOptions = useCallback(
() => ({
icon: 'preferences',
text: translate('::App.Platform.Manage'),
hint: translate('::App.Platform.Manage'),
stylingMode: 'icon',
onClick: () => {
settingButtonClick()
},
}),
[settingButtonClick, translate],
)
const getRefreshButtonOptions = useCallback(
() => ({
icon: 'refresh',
text: translate('::App.Platform.Refresh'),
stylingMode: 'icon',
onClick: () => {
gridRef.current?.instance()?.refresh()
void refreshWidgets()
},
}),
[translate, refreshWidgets],
)
// Gantt'in kendi sutun secicisi yok; ici TreeList oldugu icin onun secicisi acilir.
// Gizleme/gosterme TreeList state'ine yazildigindan kaydedilen gorunume de dahil olur.
const showColumnChooser = useCallback(() => {
const gantt = gridRef.current?.instance()
const treeList = gantt ? getGanttTreeList(gantt) : undefined
if (!treeList) return
treeList.option('columnChooser', {
enabled: true,
mode: gridDto?.gridOptions.columnOptionDto?.columnChooserMode ?? 'select',
title: translate('::App.ListForm.ColumnChooser'),
})
treeList.showColumnChooser()
}, [gridDto, translate])
const getCurrentState = useCallback((gantt: GanttInstance) => {
const treeList = getGanttTreeList(gantt)
const state = (treeList?.state() ?? {}) as Record<string, any>
const visibleColumns = (treeList?.getVisibleColumns() ?? []) as Array<Record<string, any>>
const headerCells = Array.from(
(treeList?.element() as HTMLElement | undefined)?.querySelectorAll('.dx-header-row > td') ??
[],
)
const widths = new Map(
visibleColumns.map((column, index) => [
column.dataField,
headerCells[index]?.getBoundingClientRect().width,
]),
)
const taskListElement = (gantt.element() as HTMLElement).querySelector(
'.dx-gantt-treelist-wrapper',
)
return {
...state,
columns: state.columns?.map((column: Record<string, any>) => {
const width = widths.get(column.dataField)
return width ? { ...column, width: Math.round(width) } : column
}),
scaleType: gantt.option('scaleType'),
showDependencies: gantt.option('showDependencies'),
showResources: gantt.option('showResources'),
taskListWidth: Math.round(
taskListElement?.getBoundingClientRect().width ?? gantt.option('taskListWidth') ?? 500,
),
}
}, [])
const applyState = useCallback((gantt: GanttInstance, state: Record<string, any>) => {
const treeList = getGanttTreeList(gantt)
if (!treeList) return
const {
scaleType: savedScaleType,
showDependencies,
showResources,
taskListWidth: savedTaskListWidth,
treeListState,
...treeState
} = state
treeList.state(treeListState ?? treeState)
if (savedScaleType) setScaleType(savedScaleType)
if (savedTaskListWidth) {
taskListWidthLockedRef.current = true
setTaskListWidth(savedTaskListWidth)
}
gantt.option({
...(showDependencies !== undefined ? { showDependencies } : {}),
...(showResources !== undefined ? { showResources } : {}),
...(savedTaskListWidth ? { taskListWidth: savedTaskListWidth } : {}),
})
}, [])
const splitterBoundRef = useRef(false)
const columnResizingModeRef = useRef(false)
const handleGanttContentReady = useCallback(
(event: ContentReadyEvent) => {
// Gantt ic TreeList'i 'nextColumn' ile olusturuyor; kolon daraltinca fark
// komsu kolona gidiyordu.
if (!columnResizingModeRef.current) {
const treeList = getGanttTreeList(event.component)
if (treeList) {
columnResizingModeRef.current = true
treeList.option('columnResizingMode', 'widget')
}
}
// DevExtreme, bolucu suruklemesini taskListWidth option'ina yazmadigi icin
// kullanicinin elle ayarladigi genislik olaydan yakalanip kilitlenir.
if (!splitterBoundRef.current) {
const splitter = (event.component.element() as HTMLElement).querySelector(
'.dx-splitter-wrapper',
)
if (splitter) {
splitterBoundRef.current = true
splitter.addEventListener(
'pointerdown',
() => {
taskListWidthLockedRef.current = true
},
{ capture: true },
)
}
}
if (!pendingStateRef.current) return
const state = pendingStateRef.current
pendingStateRef.current = undefined
requestAnimationFrame(() => requestAnimationFrame(() => applyState(event.component, state)))
},
[applyState],
)
const saveGanttState = useCallback(() => {
const gantt = gridRef.current?.instance()
if (gantt) customSaveState(getCurrentState(gantt)).catch(() => undefined)
}, [customSaveState, getCurrentState])
const resetGanttState = useCallback(async () => {
const gantt = gridRef.current?.instance()
if (!gantt || !storageKey) return
await postListFormCustomization({
listFormCode,
customizationType: ListFormCustomizationTypeEnum.GridState,
filterName: `gantt-${storageKey}`,
customizationData: '',
})
getGanttTreeList(gantt)?.state(null)
taskListWidthLockedRef.current = false
setTaskListWidth(defaultTaskListWidth)
}, [defaultTaskListWidth, listFormCode, storageKey])
useEffect(() => {
if (
!gridDto?.gridOptions.stateStoringDto?.enabled ||
!ganttDataSource ||
!columnData ||
!storageKey
)
return
const key = `${listFormCode}-${storageKey}`
if (loadedStateKeyRef.current === key) return
loadedStateKeyRef.current = key
customLoadState()
.then((state) => {
pendingStateRef.current = state ?? undefined
const gantt = gridRef.current?.instance()
if (gantt && state) handleGanttContentReady({ component: gantt } as ContentReadyEvent)
})
.catch(() => {
loadedStateKeyRef.current = ''
})
}, [
columnData,
customLoadState,
ganttDataSource,
gridDto,
handleGanttContentReady,
listFormCode,
storageKey,
])
return (
<>
<div ref={widgetGroupRef} className={widgets.length > 0 ? 'mt-2' : ''}>
<WidgetGroup widgetGroups={widgets} />
</div>
<Container className={DX_CLASSNAMES}>
{!isSubForm && <PageTitle title={translate('::' + gridDto?.gridOptions.title)} />}
{!gridDto && (
<div className="p-4">
<Loading loading>Loading gantt configuration...</Loading>
</div>
)}
{gridDto && !ganttDataSource && (
<div className="p-4">
<Loading loading>Loading data source...</Loading>
</div>
)}
{gridDto && columnData && ganttDataSource && (
<>
<div className="p-1">
<Gantt
ref={gridRef as any}
key={`Gantt-${listFormCode}-${ganttDataSource ? 'loaded' : 'loading'}`}
id={'Gantt-' + listFormCode}
className="list-view-toolbar-host"
taskListWidth={taskListWidth}
scaleType={scaleType}
rootValue={
gridDto.gridOptions.ganttOptionDto?.rootValue === '' ||
gridDto.gridOptions.ganttOptionDto?.rootValue === undefined
? null
: gridDto.gridOptions.ganttOptionDto?.rootValue
}
height={
gridDto.gridOptions.height > 0
? gridDto.gridOptions.height
: gridDto.gridOptions.fullHeight
? `calc(100vh - ${170 + widgetGroupHeight}px)`
: undefined
}
onContentReady={handleGanttContentReady}
>
<Tasks
dataSource={ganttDataSource}
keyExpr={gridDto.gridOptions.ganttOptionDto?.keyExpr}
parentIdExpr={gridDto.gridOptions.ganttOptionDto?.parentIdExpr}
titleExpr={gridDto.gridOptions.ganttOptionDto?.titleExpr}
startExpr={gridDto.gridOptions.ganttOptionDto?.startExpr}
endExpr={gridDto.gridOptions.ganttOptionDto?.endExpr}
progressExpr={gridDto.gridOptions.ganttOptionDto?.progressExpr}
/>
<Toolbar>
<Item name="undo" locateInMenu="auto" />
<Item name="redo" locateInMenu="auto" />
<Item name="separator" locateInMenu="auto" />
<Item name="collapseAll" locateInMenu="auto" />
<Item name="expandAll" locateInMenu="auto" />
{gridDto.gridOptions.ganttOptionDto?.allowTaskAdding && (
<Item name="addTask" locateInMenu="auto" />
)}
{gridDto.gridOptions.ganttOptionDto?.allowTaskDeleting && (
<Item name="deleteTask" locateInMenu="auto" />
)}
<Item name="separator" locateInMenu="auto" />
<Item name="zoomIn" locateInMenu="auto" />
<Item name="zoomOut" locateInMenu="auto" />
<Item name="separator" locateInMenu="auto" />
<Item
location="after"
widget="dxSelectBox"
locateInMenu="auto"
options={{
width: 150,
items: [
{ value: 'auto', text: translate('::App.Platform.Auto') },
{ value: 'minutes', text: translate('::App.Listform.ListformField.Minutes') },
{ value: 'hours', text: translate('::App.Platform.Hours') },
{ value: 'days', text: translate('::App.Listform.ListformField.Day') },
{ value: 'weeks', text: translate('::App.Platform.Weeks') },
{ value: 'months', text: translate('::App.Platform.Months') },
{ value: 'quarters', text: translate('::App.Platform.Quarters') },
{ value: 'years', text: translate('::App.Platform.Years') },
],
displayExpr: 'text',
valueExpr: 'value',
value: scaleType,
onValueChanged: (e: any) => setScaleType(e.value),
}}
/>
<Item
location="after"
widget="dxButton"
locateInMenu="auto"
showText="always"
options={getRefreshButtonOptions()}
/>
{ganttFilterToolbarData.map((item) => (
<Item
key={item.name}
name={item.name}
location="after"
widget="dxButton"
locateInMenu="never"
showText="always"
options={item.options}
/>
))}
{gridDto.gridOptions.columnOptionDto?.columnChooserEnabled && (
<Item
location="after"
widget="dxButton"
locateInMenu="auto"
showText="always"
options={{
icon: 'columnchooser',
text: translate('::App.ListForm.ColumnChooser'),
hint: translate('::App.ListForm.ColumnChooser'),
onClick: showColumnChooser,
}}
/>
)}
{gridDto.gridOptions.stateStoringDto?.enabled && (
<Item
location="after"
widget="dxButton"
locateInMenu="auto"
showText="always"
options={{
icon: 'save',
text: translate('::App.ListForm.SaveGridState'),
hint: translate('::App.ListForm.SaveGridState'),
onClick: saveGanttState,
}}
/>
)}
{gridDto.gridOptions.stateStoringDto?.enabled && (
<Item
location="after"
widget="dxButton"
locateInMenu="auto"
showText="always"
options={{
icon: 'revert',
text: translate('::App.ListForm.ResetGridState'),
hint: translate('::App.ListForm.ResetGridState'),
onClick: resetGanttState,
}}
/>
)}
{checkPermission('App.Listforms.Listform.Update') && (
<Item
location="after"
widget="dxButton"
locateInMenu="auto"
showText="always"
options={getSettingButtonOptions()}
/>
)}
</Toolbar>
<Editing
enabled={gridDto.gridOptions.ganttOptionDto?.editingEnabled}
allowTaskAdding={gridDto.gridOptions.ganttOptionDto?.allowTaskAdding}
allowTaskUpdating={gridDto.gridOptions.ganttOptionDto?.allowTaskUpdating}
allowTaskDeleting={gridDto.gridOptions.ganttOptionDto?.allowTaskDeleting}
allowTaskResourceUpdating={
gridDto.gridOptions.ganttOptionDto?.allowTaskResourceUpdating
}
allowDependencyAdding={gridDto.gridOptions.ganttOptionDto?.allowDependencyAdding}
allowDependencyDeleting={
gridDto.gridOptions.ganttOptionDto?.allowDependencyDeleting
}
allowResourceAdding={gridDto.gridOptions.ganttOptionDto?.allowResourceAdding}
allowResourceDeleting={gridDto.gridOptions.ganttOptionDto?.allowResourceDeleting}
allowResourceUpdating={gridDto.gridOptions.ganttOptionDto?.allowResourceUpdating}
/>
<FilterRow visible={gridDto.gridOptions.filterRowDto?.visible}></FilterRow>
<HeaderFilter visible={gridDto.gridOptions.headerFilterDto.visible}></HeaderFilter>
<Sorting mode={gridDto.gridOptions?.sortMode}></Sorting>
<Validation autoUpdateParentTasks={true} />
<ContextMenu enabled={false} />
{ganttColumns.map((col: any) => (
<Column key={col.dataField} {...col} />
))}
</Gantt>
<GridFilterDialogs
gridRef={ganttFilterRef}
listFormCode={listFormCode}
{...filterData}
/>
</div>
</>
)}
</Container>
</>
)
}
export default GanttView