From 87932929324a7a7a196f8dcb25845d9b8c9c874e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sedat=20=C3=96zt=C3=BCrk?= Date: Mon, 19 Jan 2026 02:40:24 +0300 Subject: [PATCH] CardView eklendi. --- .../ListForms/GridOptionsDto/LayoutDto.cs | 1 + .../Seeds/SeederDefaults.cs | 1 + ui/src/proxy/form/models.ts | 1 + .../admin/listForm/edit/FormTabDetails.tsx | 16 +- ui/src/views/admin/listForm/edit/options.ts | 1 + ui/src/views/admin/listForm/edit/types.ts | 3 +- ui/src/views/list/CardView.tsx | 1472 +++++++++++++++++ ui/src/views/list/List.tsx | 28 +- 8 files changed, 1518 insertions(+), 5 deletions(-) create mode 100644 ui/src/views/list/CardView.tsx diff --git a/api/src/Erp.Platform.Application.Contracts/ListForms/GridOptionsDto/LayoutDto.cs b/api/src/Erp.Platform.Application.Contracts/ListForms/GridOptionsDto/LayoutDto.cs index 9222eff8..90968b86 100644 --- a/api/src/Erp.Platform.Application.Contracts/ListForms/GridOptionsDto/LayoutDto.cs +++ b/api/src/Erp.Platform.Application.Contracts/ListForms/GridOptionsDto/LayoutDto.cs @@ -4,6 +4,7 @@ public class LayoutDto { public bool Grid { get; set; } = true; public bool Card { get; set; } = true; + public bool CardView { get; set; } = true; public bool Pivot { get; set; } = true; public bool Chart { get; set; } = true; public bool Tree { get; set; } = true; diff --git a/api/src/Erp.Platform.DbMigrator/Seeds/SeederDefaults.cs b/api/src/Erp.Platform.DbMigrator/Seeds/SeederDefaults.cs index 59d8410a..422a05d2 100644 --- a/api/src/Erp.Platform.DbMigrator/Seeds/SeederDefaults.cs +++ b/api/src/Erp.Platform.DbMigrator/Seeds/SeederDefaults.cs @@ -66,6 +66,7 @@ public static class SeederDefaults { Grid = true, Card = true, + CardView = true, Pivot = true, Chart = true, Tree = true, diff --git a/ui/src/proxy/form/models.ts b/ui/src/proxy/form/models.ts index 9f9b5d58..842372f2 100644 --- a/ui/src/proxy/form/models.ts +++ b/ui/src/proxy/form/models.ts @@ -894,6 +894,7 @@ export interface WidgetEditDto { export interface LayoutDto { grid: boolean card: boolean + cardView: boolean pivot: boolean tree: boolean chart: boolean diff --git a/ui/src/views/admin/listForm/edit/FormTabDetails.tsx b/ui/src/views/admin/listForm/edit/FormTabDetails.tsx index 22d885f3..b4ccc679 100644 --- a/ui/src/views/admin/listForm/edit/FormTabDetails.tsx +++ b/ui/src/views/admin/listForm/edit/FormTabDetails.tsx @@ -5,7 +5,6 @@ import { LanguageInfo } from '@/proxy/config/models' import { SelectBoxOption } from '@/types/shared' import { useStoreState } from '@/store' import { useLocalization } from '@/utils/hooks/useLocalization' -import SelectBox from 'devextreme-react/select-box' import { Field, FieldProps, Form, Formik } from 'formik' import { useEffect, useState } from 'react' import * as Yup from 'yup' @@ -25,6 +24,7 @@ const schema = Yup.object().shape({ layoutDto: Yup.object().shape({ grid: Yup.boolean(), card: Yup.boolean(), + cardView: Yup.boolean(), pivot: Yup.boolean(), chart: Yup.boolean(), tree: Yup.boolean(), @@ -311,6 +311,20 @@ function FormTabDetails( /> + + + + Promise + gridDto?: GridDto + refreshGridDto?: () => Promise +} + +const statedGridPanelColor = 'rgba(50, 200, 200, 0.5)' + +// Lookup cache (module scope) +const __lookupCache = new Map>() + +const cachedLoader = (key: string, loader: () => Promise) => { + if (__lookupCache.has(key)) return __lookupCache.get(key)! + const p = Promise.resolve() + .then(() => loader()) + .then((res) => res ?? []) + .catch((err) => { + __lookupCache.delete(key) + throw err + }) + __lookupCache.set(key, p) + return p +} + +const CardView = (props: CardViewProps) => { + const { listFormCode, searchParams, isSubForm, gridDto: extGridDto, refreshGridDto } = props + const { translate } = useLocalization() + const { checkPermission } = usePermission() + const isPwaMode = usePWA() + const config = useStoreState((state) => state.abpConfig.config) + + const cardViewRef = useRef>(null) + const refListFormCode = useRef('') + const widgetGroupRef = useRef(null) + + const [columnData, setColumnData] = useState() + const [formData, setFormData] = useState() + const [mode, setMode] = useState('view') + const [extraFilters, setExtraFilters] = useState([]) + const [gridDto, setGridDto] = useState() + const [isPopupFullScreen, setIsPopupFullScreen] = useState(false) + const [widgetGroupHeight, setWidgetGroupHeight] = useState(0) + const [cardsPerRow, setCardsPerRow] = useState(0) + const [pageSize, setPageSize] = useState(20) + const [lookupItemsCache, setLookupItemsCache] = useState>(new Map()) + + const defaultSearchParams = useRef(null) + + useEffect(() => { + const initializeCardView = async () => { + const response = await getList({ listFormCode }) + setGridDto(response.data) + } + + if (extGridDto === undefined) { + initializeCardView() + } else { + setGridDto(extGridDto) + } + }, [listFormCode, extGridDto]) + + useEffect(() => { + if (!defaultSearchParams.current) { + defaultSearchParams.current = searchParams?.get('filter') ?? null + } + }, [searchParams]) + + // Clear lookup cache when listFormCode changes + useEffect(() => { + __lookupCache.clear() + }, [listFormCode]) + + const layout = layoutTypes.cardView + + const { createSelectDataSource } = useListFormCustomDataSource({ gridRef: cardViewRef as any }) + const { getBandedColumns } = useListFormColumns({ + gridDto, + listFormCode, + isSubForm, + gridRef: cardViewRef as any, + }) + + // Lookup data source helpers + const createLookupStaticDataSource = useCallback( + (load: () => any, filter: any = null, key: any = 'static', sort: any = 'name') => + new DataSource({ + store: new CustomStore({ + key, + loadMode: 'raw', + load: async () => { + return cachedLoader(`static:${key}`, () => Promise.resolve(load())) + }, + }), + paginate: false, + sort, + filter, + }), + [], + ) + + const createLookupQueryDataSource = useCallback( + (listFormCode?: string, listFormFieldName?: string, filters?: any[]) => { + return new DataSource({ + store: new CustomStore({ + loadMode: 'raw', + load: async () => { + try { + const cacheKey = `query:${listFormCode}:${listFormFieldName}:${JSON.stringify(filters ?? null)}` + return cachedLoader(cacheKey, async () => { + const response = await dynamicFetch('list-form-select/lookup', 'POST', null, { + listFormCode, + listFormFieldName, + filters, + }) + + return (response.data ?? []).map((a: any) => ({ + key: a.Key, + name: a.Name, + group: a.Group, + ...a, + })) + }) + } catch (error: any) { + return [] + } + }, + }), + paginate: false, + }) + }, + [], + ) + + const createLookupApiDataSource = useCallback( + (listFormCode?: string, lookupQuery?: string, filters?: any[], keyName?: string) => { + return new DataSource({ + store: new CustomStore({ + key: keyName, + loadMode: 'raw', + load: async () => { + if (!lookupQuery) return [] + + const [method, url, body, keySelector, nameSelector, groupSelector] = + lookupQuery.split(';') + + let resolvedBody = body + if (filters?.length) { + for (let i = 0; i < filters.length; i++) { + resolvedBody = resolvedBody.replace( + new RegExp(`@param${i}`, 'g'), + String(filters[i]), + ) + } + } + + try { + const cacheKey = `api:${lookupQuery}:${JSON.stringify(filters ?? null)}` + return cachedLoader(cacheKey, async () => { + const response = await dynamicFetch(url, method, null, resolvedBody) + let { data } = response + if (!data) return [] + if (!Array.isArray(data)) data = [data] + return data.map((a: any) => ({ + key: eval(keySelector), + name: eval(nameSelector), + group: eval(groupSelector), + ...a, + })) + }) + } catch { + return [] + } + }, + }), + paginate: false, + }) + }, + [], + ) + + const lookupDataSource = useCallback( + (options: any, colData: any) => { + const { lookupDto } = colData + const filters: any[] = [] + + if (lookupDto.cascadeParentFields) { + if (lookupDto.dataSourceType === UiLookupDataSourceTypeEnum.StaticData) { + filters.push([ + lookupDto?.cascadeRelationField, + lookupDto?.cascadeFilterOperator, + options?.data?.[lookupDto?.cascadeParentField], + ]) + } else { + const data = options?.data ?? options + for (const cascadeParentField of lookupDto.cascadeParentFields.split(',')) { + filters.push(data?.[cascadeParentField]) + } + } + } + + if (lookupDto.dataSourceType === UiLookupDataSourceTypeEnum.StaticData) { + return createLookupStaticDataSource( + () => JSON.parse(lookupDto?.lookupQuery), + filters.length ? filters : null, + `static:${listFormCode}:${colData.fieldName}`, + ) + } else if (lookupDto.dataSourceType === UiLookupDataSourceTypeEnum.Query) { + return createLookupQueryDataSource(listFormCode, colData.fieldName, filters) + } else if (lookupDto.dataSourceType === UiLookupDataSourceTypeEnum.WebService) { + return createLookupApiDataSource( + listFormCode, + lookupDto?.lookupQuery, + filters, + colData.lookupDto?.valueExpr?.toLowerCase(), + ) + } + return { store: [] } + }, + [ + listFormCode, + createLookupStaticDataSource, + createLookupQueryDataSource, + createLookupApiDataSource, + ], + ) + + function refreshData() { + // Cache'i temizle + if (typeof (window as any).__clearCardViewCache === 'function') { + ;(window as any).__clearCardViewCache() + } + + const instance = cardViewRef.current?.instance() + if (instance) { + instance.getDataSource()?.reload() + } + } + + // CardView specific events + function onSelectionChanged(data: SelectionChangedEvent) { + const grdOpt = gridDto?.gridOptions + const cardView = cardViewRef.current?.instance() + if (!grdOpt || !cardView) { + return + } + + if (data.selectedCardsData?.length) { + setFormData(data.selectedCardsData[0]) + } + } + + function onInitNewCard(e: InitNewCardEvent) { + if (!gridDto?.columnFormats) { + return + } + + setMode('new') + setIsPopupFullScreen(gridDto?.gridOptions.editingOptionDto?.popup?.fullScreen ?? false) + + for (const colFormat of gridDto?.columnFormats) { + if (!colFormat.fieldName) { + continue + } + + // Grid'den gelen columnFormat'ları kullanarak default değerleri set et + if (colFormat.defaultValue != null) { + const defaultValStr = String(colFormat.defaultValue) + if (defaultValStr === '@AUTONUMBER') { + e.data[colFormat.fieldName] = autoNumber() + } else { + e.data[colFormat.fieldName] = colFormat.defaultValue + } + } + + // ExtraFilters içerisinde ilgili Field varsa, default değerleri set etme + if (extraFilters.some((f) => f.fieldName === colFormat.fieldName)) { + continue + } + + // URL'den veya Component Prop'dan gelen parametreleri set et + const defaultValue = searchParams?.get(colFormat.fieldName) + if (defaultValue) { + e.data[colFormat.fieldName] = defaultValue + } + } + } + + function onCardInserting(e: CardInsertingEvent) { + e.data = setFormEditingExtraItemValues(e.data) + } + + function onCardUpdating(e: CardUpdatingEvent) { + if (gridDto?.gridOptions.editingOptionDto?.sendOnlyChangedFormValuesUpdate) { + e.newData = { + ...e.newData, + [gridDto?.gridOptions.keyFieldName!]: e.oldData[gridDto?.gridOptions.keyFieldName!], + } + } else { + e.newData = { + ...e.oldData, + ...e.newData, + [gridDto?.gridOptions.keyFieldName!]: e.oldData[gridDto?.gridOptions.keyFieldName!], + } + } + + if (gridDto?.gridOptions.keyFieldName) { + e.newData = setFormEditingExtraItemValues(e.newData) + } + } + + function onEditingStart(e: EditingStartEvent) { + setMode('edit') + setIsPopupFullScreen(gridDto?.gridOptions.editingOptionDto?.popup?.fullScreen ?? false) + } + + function onDataErrorOccurred(e: EventInfo & DataErrorOccurredInfo) { + toast.push( + + {(e.error as any)?.message || 'An error occurred'} + , + { + placement: 'top-end', + }, + ) + } + + const customSaveState = useCallback( + (state: any) => { + return postListFormCustomization({ + listFormCode: listFormCode, + customizationType: ListFormCustomizationTypeEnum.GridState, + filterName: `cardview-state`, + customizationData: JSON.stringify(state), + }).then(() => { + setGridPanelColor(statedGridPanelColor) + }) + }, + [listFormCode], + ) + + const customLoadState = useCallback(() => { + return getListFormCustomization( + listFormCode, + ListFormCustomizationTypeEnum.GridState, + `cardview-state`, + ).then((response: any) => { + setGridPanelColor(statedGridPanelColor) + if (response.data?.length > 0) { + return JSON.parse(response.data[0].customizationData) + } + }) + }, [listFormCode]) + + useEffect(() => { + if (cardViewRef?.current) { + const instance = cardViewRef?.current?.instance() + if (instance) { + instance.option('dataSource', undefined) + } + } + + if (refListFormCode.current !== listFormCode) { + setColumnData(undefined) + } + }, [listFormCode]) + + useEffect(() => { + if (!gridDto) { + return + } + + // Set js and css + const grdOpt = gridDto.gridOptions + if (grdOpt.customJsSources.length) { + for (const js of grdOpt.customJsSources) { + addJs(js) + } + } + if (grdOpt.customStyleSources.length) { + for (const css of grdOpt.customStyleSources) { + addCss(css) + } + } + + if (gridDto?.gridOptions.extraFilterDto) { + const extras = gridDto.gridOptions.extraFilterDto.map((f) => ({ + fieldName: f.fieldName, + caption: f.caption, + operator: f.operator || '=', + value: f.defaultValue || '', + controlType: f.controlType, + })) + // Sadece ilk yüklemede extraFilters'ı set et, her gridDto değişiminde değil + setExtraFilters((prev) => { + if (prev.length === 0) return extras + return prev + }) + } + + if (gridDto?.gridOptions.editingOptionDto?.popup) { + setIsPopupFullScreen(gridDto.gridOptions.editingOptionDto.popup.fullScreen) + } + + // cardsPerRow başlangıç değeri - localStorage'dan oku + const storageKey = `cardview-cardsPerRow-${listFormCode}` + const savedCardsPerRow = localStorage.getItem(storageKey) + + if (savedCardsPerRow !== null) { + setCardsPerRow(parseInt(savedCardsPerRow, 10)) + } else if (gridDto?.gridOptions.layoutDto?.cardLayoutColumn) { + setCardsPerRow(gridDto.gridOptions.layoutDto.cardLayoutColumn) + } + + // pageSize başlangıç değeri + if (gridDto?.gridOptions.pageSize) { + setPageSize(gridDto.gridOptions.pageSize) + } + }, [gridDto, listFormCode]) + + // Lookup DataSource'ları bir kere oluştur ve cache'le + const lookupDataSourcesRef = useRef>(new Map()) + + // gridDto değiştiğinde lookup cache'i temizle + useEffect(() => { + lookupDataSourcesRef.current.clear() + }, [gridDto, listFormCode]) + + // Build columns with lookup support - sadece gridDto değiştiğinde + const columnsWithLookup = useMemo(() => { + if (!gridDto || !config) return [] + + const cols = getBandedColumns() + if (!cols) return [] + + return cols.map((col) => { + const colData = gridDto.columnFormats.find((c) => c.fieldName === col.dataField) + if (!colData) return col + + // Type assertion for extended column properties + const extCol = col as any + + // Form items için editorType ve editorOptions ayarla + const gridFormItem = gridDto.gridOptions.editingFormDto + ?.flatMap((f) => f.items) + .find((i) => i?.dataField === col.dataField) + + // Lookup desteği ekle - dataSourceType > 0 olmalı (0 = None) + if (colData.lookupDto?.dataSourceType && colData.lookupDto.dataSourceType > 0) { + // Cache'den al veya oluştur + const cacheKey = `${colData.fieldName}` + let lookupDs = lookupDataSourcesRef.current.get(cacheKey) + + if (!lookupDs) { + lookupDs = lookupDataSource(null, colData) + lookupDataSourcesRef.current.set(cacheKey, lookupDs) + } + + // NOT: Lookup için CardView'da editorType ve editorOptions KULLANMA + // DevExpress bunları görünce otomatik lookup resolution yapıp çift görünmeye sebep oluyor + // Sadece fieldValueRender ile lookup gösterimi yapılacak + // Edit popup'ta form item için lookup ayarları yapılacak + + // Lookup olduğunu işaretle (renderColumns'da kullanılacak) + ;(col as any).isLookup = true + ;(col as any).lookupInfo = { + valueExpr: colData.lookupDto?.valueExpr?.toLowerCase() || 'key', + displayExpr: colData.lookupDto?.displayExpr?.toLowerCase() || 'name', + } + } + + // Form item ayarları + if (gridFormItem) { + const editorOptions: any = {} + + // Parse editorOptions from JSON + if (gridFormItem.editorOptions) { + try { + Object.assign(editorOptions, JSON.parse(gridFormItem.editorOptions)) + } catch {} + } + + // EditorType belirleme (SchedulerView pattern'i) + let editorType: any = gridFormItem.editorType2 || gridFormItem.editorType + if (gridFormItem.editorType2 === PlatformEditorTypes.dxGridBox) { + editorType = 'dxDropDownBox' + } else if (gridFormItem.editorType2 === PlatformEditorTypes.dxTagBox) { + editorType = 'dxTagBox' + } else if (gridFormItem.editorType2) { + editorType = gridFormItem.editorType2 + } + + // Lookup için dataSource ve valueExpr/displayExpr ekle - dataSourceType > 0 olmalı + if (colData.lookupDto?.dataSourceType && colData.lookupDto.dataSourceType > 0) { + // Cache'den al + const cacheKey = `${colData.fieldName}` + const lookupDs = lookupDataSourcesRef.current.get(cacheKey) + + if (lookupDs) { + editorOptions.dataSource = lookupDs + editorOptions.valueExpr = colData.lookupDto?.valueExpr?.toLowerCase() || 'key' + editorOptions.displayExpr = colData.lookupDto?.displayExpr?.toLowerCase() || 'name' + editorOptions.searchEnabled = true + editorOptions.showClearButton = true + } + + // Lookup varsa SelectBox kullan (eğer başka bir tip belirtilmediyse) + if (!editorType || editorType === 'dxTextBox') { + editorType = 'dxSelectBox' + } + } + + // Date/DateTime alanları için özel ayarlar + if (colData.sourceDbType === DbTypeEnum.Date) { + editorType = 'dxDateBox' + editorOptions.type = 'date' + editorOptions.dateSerializationFormat = 'yyyy-MM-dd' + editorOptions.displayFormat = 'shortDate' + } else if ( + colData.sourceDbType === DbTypeEnum.DateTime || + colData.sourceDbType === DbTypeEnum.DateTime2 || + colData.sourceDbType === DbTypeEnum.DateTimeOffset + ) { + editorType = 'dxDateBox' + editorOptions.type = 'datetime' + editorOptions.dateSerializationFormat = 'yyyy-MM-ddTHH:mm:ss' + editorOptions.displayFormat = 'shortDateShortTime' + } + + col.formItem = { + ...col.formItem, + colSpan: gridFormItem.colSpan, + } + + if (editorType) { + extCol.editorType = editorType + } + + col.editorOptions = { + ...col.editorOptions, + ...editorOptions, + } + } + + return col + }) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [gridDto, config]) // getBandedColumns ve lookupDataSource çıkarıldı - sonsuz döngüyü önlemek için + + // DataSource oluştur - sadece gridDto ve listFormCode değiştiğinde (useMemo ile cache'le) + const cardViewDataSource = useMemo(() => { + if (!gridDto) return null + + const cols = getBandedColumns() + if (!cols || cols.length === 0) return null + + const baseStore = createSelectDataSource( + gridDto.gridOptions, + listFormCode, + searchParams, + layoutTypes.cardView, + cols, + ) + + // CardView için sadece 1 select çağrısı yapacak wrapper + let cachedData: any[] | null = null + let cachedTotalCount: number = 0 + let isLoading = false + const keyExpr = gridDto.gridOptions.keyFieldName + + const clearCache = () => { + cachedData = null + cachedTotalCount = 0 + isLoading = false + } + + // Cache temizleme fonksiyonunu dışarıya expose et + ;(window as any).__clearCardViewCache = clearCache + + const optimizedStore: any = new CustomStore({ + key: keyExpr, + load: async (loadOptions: any) => { + // Zaten yüklenmişse cache'den dön + if (cachedData !== null && !isLoading) { + return { + data: cachedData, + totalCount: cachedTotalCount, + } + } + + // Yükleme devam ediyorsa bekle + if (isLoading) { + await new Promise((resolve) => setTimeout(resolve, 100)) + if (cachedData !== null) { + return { + data: cachedData, + totalCount: cachedTotalCount, + } + } + } + + isLoading = true + try { + const result = await baseStore.load(loadOptions) + cachedData = result?.data || [] + cachedTotalCount = result?.totalCount || 0 + return result + } finally { + isLoading = false + } + }, + byKey: async (key: any) => { + // Cache'de ara + if (cachedData && keyExpr) { + const item = cachedData.find((row: any) => row?.[keyExpr] === key) + if (item) return item + } + + // Bulamazsa server'a git + return baseStore.byKey(key) + }, + insert: async (values: any) => { + const result = await baseStore.insert(values) + clearCache() + return result + }, + update: async (key: any, values: any) => { + const result = await baseStore.update(key, values) + clearCache() + return result + }, + remove: async (key: any) => { + const result = await baseStore.remove(key) + clearCache() + return result + }, + }) + + // DataSource içine sar + return new DataSource({ + store: optimizedStore, + reshapeOnPush: true, + paginate: true, + pageSize: pageSize, + }) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [gridDto, listFormCode]) + + // extraFilters değişikliğini izlemek için ref + const extraFiltersInitialized = useRef(false) + + useEffect(() => { + // İlk yüklemede reload yapma, sadece kullanıcı filtre değiştirdiğinde + if (!extraFiltersInitialized.current) { + extraFiltersInitialized.current = true + return + } + + const instance = cardViewRef.current?.instance() + if (!instance) return + + const activeFilters = extraFilters.filter((f) => f.value) + + let base: any = null + if (defaultSearchParams.current) { + base = JSON.parse(defaultSearchParams.current) + } + + const baseTriplets = extractSearchParamsFields(base) + const extraTriplets = activeFilters.map( + (f) => [f.fieldName, f.operator, f.value] as [string, string, any], + ) + + const merged = [...baseTriplets, ...extraTriplets].reduce( + (acc, cur) => { + const idx = acc.findIndex((a) => a[0] === cur[0] && a[1] === cur[1]) + if (idx >= 0) { + acc[idx] = cur + } else { + acc.push(cur) + } + return acc + }, + [] as [string, string, any][], + ) + + let filter: any = null + if (merged.length === 1) { + filter = merged[0] + } else if (merged.length > 1) { + filter = merged.reduce((acc, f, idx) => { + if (idx === 0) return f + return [acc, 'and', f] + }, null as any) + } + + if (filter) { + instance?.option('filterValue', filter) + } else { + instance?.option('filterValue', undefined) + } + + instance?.getDataSource()?.reload() + }, [extraFilters]) + + useEffect(() => { + refListFormCode.current = listFormCode + }, [listFormCode]) + + // WidgetGroup yüksekliğini hesapla + useEffect(() => { + const calculateWidgetHeight = () => { + if (widgetGroupRef.current) { + const height = widgetGroupRef.current.offsetHeight + setWidgetGroupHeight(height) + } + } + + calculateWidgetHeight() + + const resizeObserver = new ResizeObserver(calculateWidgetHeight) + if (widgetGroupRef.current) { + resizeObserver.observe(widgetGroupRef.current) + } + + return () => { + resizeObserver.disconnect() + } + }, [gridDto?.widgets]) + + const settingButtonClick = useCallback(() => { + window.open( + ROUTES_ENUM.protected.saas.listFormManagement.edit.replace(':listFormCode', listFormCode), + isPwaMode ? '_self' : '_blank', + ) + }, [listFormCode, isPwaMode]) + + const customizeLookupText = useCallback( + (fieldName: string) => { + return (cellInfo: any) => { + const value = cellInfo.value + if (!value) return '' + + const cacheKey = fieldName + const items = lookupItemsCache.get(cacheKey) + + if (!items || items.length === 0) { + return value + } + + try { + const colData = gridDto?.columnFormats.find((c) => c.fieldName === fieldName) + const valueExpr = colData?.lookupDto?.valueExpr?.toLowerCase() || 'key' + const displayExpr = colData?.lookupDto?.displayExpr?.toLowerCase() || 'name' + + const item = items.find((i: any) => i[valueExpr] === value) + + return item ? item[displayExpr] : value + } catch { + return value + } + } + }, + [gridDto, lookupItemsCache], + ) + + // CardView column render from GridColumnData + const renderColumns = () => { + if (!columnsWithLookup || !Array.isArray(columnsWithLookup) || columnsWithLookup.length === 0) { + return null + } + + return columnsWithLookup + .filter((col) => col.type !== 'buttons' && col.visible !== false) + .map((col) => { + const extCol = col as any + const colData = gridDto?.columnFormats.find((c) => c.fieldName === col.dataField) + + // Column props + const columnProps: any = { + dataField: col.dataField, + caption: col.caption ? translate('::' + col.caption) : captionize(col.dataField || ''), + dataType: col.dataType, + visible: col.visible, + allowSorting: col.allowSorting, + allowFiltering: col.allowFiltering, + allowHeaderFiltering: col.allowHeaderFiltering, + sortOrder: col.sortOrder, + sortIndex: col.sortIndex, + format: col.format, + alignment: col.alignment, + formItem: col.formItem, + } + + // Lookup varsa customizeText kullan - fieldValueRender çift görünmeye sebep oluyor + if (extCol.isLookup) { + columnProps.customizeText = customizeLookupText(col.dataField!) + } else { + // Lookup değilse editorType ve editorOptions ekle + if (extCol.editorType) { + columnProps.editorType = extCol.editorType + } + if (col.editorOptions) { + columnProps.editorOptions = col.editorOptions + } + + // fieldValueRender - farklı veri tipleri için özel renderlar (lookup değilse) + if (colData?.sourceDbType === DbTypeEnum.Date || col.dataType === 'date') { + columnProps.customizeText = (cellInfo: any) => { + if (!cellInfo.value) return '—' + return new Date(cellInfo.value).toLocaleDateString() + } + } else if ( + colData?.sourceDbType === DbTypeEnum.DateTime || + colData?.sourceDbType === DbTypeEnum.DateTime2 || + colData?.sourceDbType === DbTypeEnum.DateTimeOffset || + col.dataType === 'datetime' + ) { + columnProps.customizeText = (cellInfo: any) => { + if (!cellInfo.value) return '—' + return new Date(cellInfo.value).toLocaleString() + } + } + } + + // Boolean için customizeText kullan (fieldValueRender çift checkbox oluşturuyor) + if (colData?.sourceDbType === DbTypeEnum.Boolean || col.dataType === 'boolean') { + columnProps.customizeText = (cellInfo: any) => { + return cellInfo.value + ? translate('::App.Listforms.ImportManager.Yes') + : translate('::App.Listforms.ImportManager.No') + } + } + + return + }) + } + + // Kolon sayısı değiştiğinde + const onCardsPerRowChanged = useCallback( + (value: number) => { + setCardsPerRow(value) + // localStorage'a kaydet + const storageKey = `cardview-cardsPerRow-${listFormCode}` + localStorage.setItem(storageKey, value.toString()) + }, + [listFormCode], + ) + + // Page size değiştiğinde + const onPageSizeChanged = useCallback((newPageSize: number) => { + setPageSize(newPageSize) + const instance = cardViewRef.current?.instance() + if (instance) { + instance.pageSize(newPageSize) + } + }, []) + + // Toolbar items + const toolbarItems = useMemo(() => { + if (!gridDto) return [] + + const items: any[] = [ + { name: 'addCardButton' }, + { name: 'searchPanel' }, + { + location: 'after', + widget: 'dxButtonGroup', + options: { + items: [ + { text: 'Auto', value: 0, hint: 'Otomatik' }, + { text: '1', value: 1, hint: '1 Kolon' }, + { text: '2', value: 2, hint: '2 Kolon' }, + { text: '3', value: 3, hint: '3 Kolon' }, + { text: '4', value: 4, hint: '4 Kolon' }, + { text: '5', value: 5, hint: '5 Kolon' }, + ], + keyExpr: 'value', + selectedItemKeys: [cardsPerRow || 0], + selectionMode: 'single', + stylingMode: 'outlined', + onItemClick: (e: any) => { + if (e.itemData) { + onCardsPerRowChanged(e.itemData.value) + } + }, + }, + }, + { + location: 'after', + widget: 'dxButton', + options: { + icon: 'refresh', + hint: translate('::Refresh'), + stylingMode: 'text', + onClick: () => refreshData(), + }, + }, + ] + + // Column Chooser butonu için permission kontrolü + if (checkPermission(gridDto?.gridOptions.permissionDto?.u)) { + items.push({ + location: 'after', + widget: 'dxButton', + options: { + icon: 'columnchooser', + hint: translate('::ColumnChooser'), + stylingMode: 'text', + onClick: () => { + const instance = cardViewRef.current?.instance() + instance?.showColumnChooser() + }, + }, + }) + } + + // Settings butonu için permission kontrolü + if (checkPermission(gridDto?.gridOptions.permissionDto?.u)) { + items.push({ + location: 'after', + widget: 'dxButton', + options: { + icon: 'preferences', + hint: translate('::ListForms.ListForm.Manage'), + stylingMode: 'text', + onClick: settingButtonClick, + }, + }) + } + + return items + }, [ + gridDto, + cardsPerRow + ]) + + // Paging ayarları + const pagingConfig = useMemo( + () => ({ + enabled: gridDto?.gridOptions.pagerOptionDto?.visible !== false, + pageSize: pageSize, + }), + [gridDto?.gridOptions.pagerOptionDto?.visible, pageSize], + ) + + // Pager ayarları + const pagerConfig = useMemo(() => { + const allowedSizes = gridDto?.gridOptions.pagerOptionDto?.allowedPageSizes + ?.split(',') + .map((s) => Number(s.trim())) + .filter((n) => !isNaN(n) && n > 0) || [10, 20, 50, 100] + + return { + visible: gridDto?.gridOptions.pagerOptionDto?.visible !== false, + showPageSizeSelector: gridDto?.gridOptions.pagerOptionDto?.showPageSizeSelector !== false, + showInfo: gridDto?.gridOptions.pagerOptionDto?.showInfo !== false, + showNavigationButtons: gridDto?.gridOptions.pagerOptionDto?.showNavigationButtons !== false, + allowedPageSizes: allowedSizes, + displayMode: gridDto?.gridOptions.pagerOptionDto?.displayMode || 'full', + infoText: gridDto?.gridOptions.pagerOptionDto?.infoText, + } + }, [gridDto?.gridOptions.pagerOptionDto]) + + return ( + <> +
+ +
+ + {gridDto?.gridOptions.extraFilterDto && gridDto?.gridOptions.extraFilterDto.length > 0 && ( + + )} + + + {!isSubForm && ( + + )} + + {!gridDto && ( +
+ Loading CardView configuration... +
+ )} + + {gridDto && !cardViewDataSource && ( +
+ Loading data source... +
+ )} + + {gridDto && columnsWithLookup && Array.isArray(columnsWithLookup) && columnsWithLookup.length > 0 && cardViewDataSource && ( +
+ 0 + ? gridDto.gridOptions.height + : gridDto.gridOptions.fullHeight + ? `calc(100vh - ${170 + widgetGroupHeight}px)` + : undefined + } + remoteOperations={false} + onSelectionChanged={onSelectionChanged as any} + onInitNewCard={onInitNewCard as any} + onCardInserting={onCardInserting as any} + onCardUpdating={onCardUpdating as any} + onEditingStart={onEditingStart as any} + onDataErrorOccurred={onDataErrorOccurred as any} + onEditCanceled={() => { + setMode('view') + setIsPopupFullScreen(false) + }} + onCardInserted={() => { + setMode('view') + setIsPopupFullScreen(false) + // Küçük bir gecikme ile reload - server transaction commit bekle + setTimeout(() => { + refreshData() + props.refreshData?.() + }, 100) + }} + onCardUpdated={() => { + setMode('view') + setIsPopupFullScreen(false) + // Küçük bir gecikme ile reload - server transaction commit bekle + setTimeout(() => { + refreshData() + props.refreshData?.() + }, 100) + }} + onCardRemoved={() => { + // Küçük bir gecikme ile reload - server transaction commit bekle + setTimeout(() => { + refreshData() + props.refreshData?.() + }, 100) + }} + onOptionChanged={(e: any) => { + if (e.name === 'paging.pageSize' && e.value !== pageSize) { + setPageSize(e.value) + } + }} + onContentReady={() => { + // Lookup DataSource'ları yükle ve state'e cache'le (sadece ilk yüklemede) + if (lookupItemsCache.size === 0 && lookupDataSourcesRef.current.size > 0) { + const lookupPromises: Array> = [] + + lookupDataSourcesRef.current.forEach((ds, key) => { + if (ds && typeof ds.load === 'function') { + lookupPromises.push( + ds.load().then(() => ({ + key, + items: ds.items() || [], + })), + ) + } + }) + + if (lookupPromises.length > 0) { + Promise.all(lookupPromises) + .then((results) => { + const newCache = new Map() + results.forEach(({ key, items }) => { + newCache.set(key, items) + }) + setLookupItemsCache(newCache) + + const instance = cardViewRef.current?.instance() + if (instance) { + instance.repaint() + } + }) + .catch(() => { + // Hata durumunda sessizce devam et + }) + } + } + }} + > + {/* Toolbar */} + + + {/* Selection */} + + + {/* Sorting */} + + + {/* Header Filter */} + + + {/* Filter Panel */} + + + {/* Search Panel */} + + + {/* Column Chooser */} + + + {/* Paging */} + + + {/* Pager */} + + + {/* Editing */} + { + const cardView = cardViewRef.current?.instance() + if (cardView) { + // Form validasyonu yap + const editForm = (cardView as any).getController?.('validating')?.validate?.() + + // Eğer validate fonksiyonu yoksa direkt kaydet + if (!editForm) { + cardView.saveEditData() + return + } + + // Validasyon hatası varsa kaydetme + if (editForm && !editForm.brokenRules?.length) { + cardView.saveEditData() + } + } + }, + }, + }, + { + widget: 'dxButton', + toolbar: 'bottom', + location: 'after', + options: { + text: translate('::Cancel'), + onClick: () => { + const cardView = cardViewRef.current?.instance() + cardView?.cancelEditData() + }, + }, + }, + { + widget: 'dxButton', + toolbar: 'top', + location: 'after', + options: { + icon: isPopupFullScreen ? 'collapse' : 'fullscreen', + hint: isPopupFullScreen + ? translate('::Normal Boyut') + : translate('::Tam Ekran'), + stylingMode: 'text', + onClick: () => setIsPopupFullScreen(!isPopupFullScreen), + }, + }, + ], + }} + form={{ + colCount: gridDto.gridOptions.editingFormDto?.[0]?.colCount || 2, + showValidationSummary: true, + items: + gridDto.gridOptions.editingFormDto?.length > 0 + ? (() => { + const sortedFormDto = gridDto.gridOptions.editingFormDto + .slice() + .sort((a: any, b: any) => (a.order >= b.order ? 1 : -1)) + + const tabbedItems = sortedFormDto.filter( + (e: any) => e.itemType === 'tabbed', + ) + const result: any[] = [] + + const mapFormItem = (i: EditingFormItemDto) => { + let editorOptions: any = {} + try { + if (i.editorOptions) { + editorOptions = JSON.parse(i.editorOptions) + } + } catch {} + + const fieldName = i.dataField.split(':')[0] + const listFormField = gridDto.columnFormats.find( + (x: any) => x.fieldName === fieldName, + ) + + if (listFormField?.sourceDbType === DbTypeEnum.Date) { + editorOptions = { + ...{ + type: 'date', + dateSerializationFormat: 'yyyy-MM-dd', + displayFormat: 'shortDate', + }, + ...editorOptions, + } + } else if ( + listFormField?.sourceDbType === DbTypeEnum.DateTime || + listFormField?.sourceDbType === DbTypeEnum.DateTime2 || + listFormField?.sourceDbType === DbTypeEnum.DateTimeOffset + ) { + editorOptions = { + ...{ + type: 'datetime', + dateSerializationFormat: 'yyyy-MM-ddTHH:mm:ss', + displayFormat: 'shortDateShortTime', + }, + ...editorOptions, + } + } + + // Set defaultValue for @AUTONUMBER fields + if ( + typeof listFormField?.defaultValue === 'string' && + listFormField?.defaultValue === '@AUTONUMBER' && + mode === 'new' + ) { + editorOptions = { + ...editorOptions, + value: autoNumber(), + } + } + + // EditorType belirleme + let editorType: any = i.editorType2 || i.editorType + if (i.editorType2 === PlatformEditorTypes.dxGridBox) { + editorType = 'dxDropDownBox' + } else if (i.editorType2) { + editorType = i.editorType2 + } + + // Lookup DataSource oluştur + if ( + listFormField?.lookupDto && + listFormField.lookupDto.dataSourceType > 0 + ) { + const cacheKey = `${listFormField.fieldName}` + let lookupDs = lookupDataSourcesRef.current.get(cacheKey) + + if (!lookupDs) { + lookupDs = lookupDataSource(null, listFormField) + lookupDataSourcesRef.current.set(cacheKey, lookupDs) + } + + editorOptions.dataSource = lookupDs + editorOptions.valueExpr = + listFormField.lookupDto.valueExpr?.toLowerCase() || 'key' + editorOptions.displayExpr = + listFormField.lookupDto.displayExpr?.toLowerCase() || 'name' + editorOptions.searchEnabled = true + editorOptions.showClearButton = true + + if (!editorType || editorType === 'dxTextBox') { + editorType = 'dxSelectBox' + } + } + + const item: any = { + dataField: i.dataField, + editorType: editorType, + colSpan: i.colSpan, + isRequired: i.isRequired, + editorOptions, + } + + // Required field için validasyon kuralı ekle + if (i.isRequired) { + item.validationRules = [ + { + type: 'required', + message: `${i.dataField.split(':')[1] || i.dataField} zorunludur`, + }, + ] + } + + if (i.dataField.indexOf(':') >= 0) { + item.label = { text: captionize(i.dataField.split(':')[1]) } + } + + return item + } + + sortedFormDto.forEach((e: any) => { + if (e.itemType !== 'tabbed') { + result.push({ + itemType: e.itemType, + colCount: e.colCount || 1, + colSpan: e.colSpan || 1, + caption: e.caption, + items: e.items + ?.sort((a: any, b: any) => (a.order >= b.order ? 1 : -1)) + .map(mapFormItem), + }) + } else if (tabbedItems.length > 0 && e === tabbedItems[0]) { + result.push({ + itemType: 'tabbed', + colCount: 1, + colSpan: 1, + tabs: tabbedItems.map((tabbedItem: any) => ({ + title: tabbedItem.caption, + colCount: tabbedItem.colCount || 1, + items: tabbedItem.items + ?.sort((a: any, b: any) => (a.order >= b.order ? 1 : -1)) + .map(mapFormItem), + })), + }) + } + }) + + return result + })() + : undefined, + }} + /> + + {/* Columns */} + {renderColumns()} + +
+ )} +
+ + ) +} + +export default CardView diff --git a/ui/src/views/list/List.tsx b/ui/src/views/list/List.tsx index 4e1c3d65..f6f48488 100644 --- a/ui/src/views/list/List.tsx +++ b/ui/src/views/list/List.tsx @@ -2,7 +2,7 @@ import { useParams, useSearchParams } from 'react-router-dom' import { useEffect, useState } from 'react' import Container from '@/components/shared/Container' import Grid from './Grid' -import { FaChartArea, FaList, FaSitemap, FaTable, FaTh, FaUser, FaCalendarAlt } from 'react-icons/fa' +import { FaChartArea, FaList, FaSitemap, FaTable, FaTh, FaCalendarAlt, FaIdCard, FaProjectDiagram } from 'react-icons/fa' import { useStoreActions, useStoreState } from '@/store/store' import classNames from 'classnames' import { useLocalization } from '@/utils/hooks/useLocalization' @@ -15,7 +15,7 @@ import { useCurrentMenuIcon } from '@/utils/hooks/useCurrentMenuIcon' import { ListViewLayoutType } from '../admin/listForm/edit/types' import Chart from './Chart' import Card from './Card' -import { FaChartGantt } from 'react-icons/fa6' +import CardView from './CardView' import GanttView from './GanttView' import SchedulerView from './SchedulerView' @@ -124,7 +124,7 @@ const List = () => { }} title="Gantt Görünümü" > - + )} @@ -171,6 +171,20 @@ const List = () => { )} + {gridDto?.gridOptions?.layoutDto.cardView && ( + + )} + {gridDto?.gridOptions?.layoutDto.pivot && (