erp-platform/ui/src/views/list/Card.tsx
2025-11-30 20:15:04 +03:00

754 lines
26 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 { useCallback, useEffect, useState, useRef, KeyboardEvent, useMemo } from 'react'
import { GridDto } from '@/proxy/form/models'
import { Button, Pagination, Select, Checkbox } from '@/components/ui'
import classNames from 'classnames'
import { FaCog, FaSearch, FaSortAmountDown, FaSortAmountUp } from 'react-icons/fa'
import { ROUTES_ENUM } from '@/routes/route.constant'
import CustomStore from 'devextreme/data/custom_store'
import { usePermission } from '@/utils/hooks/usePermission'
import { useLookupDataSource } from '../form/useLookupDataSource'
import { Container, Loading } from '@/components/shared'
import WidgetGroup from '@/components/common/WidgetGroup'
import { GridExtraFilterState } from './Utils'
import { useStoreActions, useStoreState } from '@/store/store'
import { usePWA } from '@/utils/hooks/usePWA'
import CardItem from './CardItem'
import { layoutTypes } from '../admin/listForm/edit/types'
import { useListFormCustomDataSource } from './useListFormCustomDataSource'
interface CardProps {
listFormCode: string
searchParams?: URLSearchParams
isSubForm?: boolean
level?: number
refreshData?: () => Promise<void>
gridDto?: GridDto
}
type Option = {
value: number
label: string
}
const Card = (props: CardProps) => {
const { listFormCode, searchParams, gridDto } = props
const { createSelectDataSource } = useListFormCustomDataSource({} as any)
const [data, setData] = useState<any[]>([])
const [totalCount, setTotalCount] = useState(0)
const [currentPage, setCurrentPage] = useState(1)
const [pageSize, setPageSize] = useState(20)
const [pageSizeOptions, setPageSizeOptions] = useState<Option[]>([])
const [gridDataSource, setGridDataSource] = useState<CustomStore<any, any>>()
const { getLookupDataSource } = useLookupDataSource({ listFormCode })
const [layoutCount, setLayoutCount] = useState(4)
const [searchText, setSearchText] = useState('')
const [prevValue, setPrevValue] = useState('')
const [loading, setLoading] = useState(false)
const { checkPermission } = usePermission()
const isPwaMode = usePWA()
const [extraFilters, setExtraFilters] = useState<GridExtraFilterState[]>([])
// Selection features - gridDto değiştiğinde güncellenmeli
const [selectedKeys, setSelectedKeys] = useState<Set<any>>(new Set())
const selectionMode: 'none' | 'single' | 'multiple' = useMemo(() => {
if (!gridDto?.gridOptions?.selectionDto?.mode) return 'none'
const mode = gridDto.gridOptions.selectionDto.mode.toLowerCase()
if (mode === 'single') return 'single'
if (mode === 'multiple') return 'multiple'
return 'none'
}, [gridDto])
// Sorting features
const [sortColumn, setSortColumn] = useState<string | null>(null)
const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>('asc')
// Focus and keyboard navigation
const [focusedCardIndex, setFocusedCardIndex] = useState<number>(-1)
const cardRefs = useRef<(HTMLDivElement | null)[]>([])
const containerRef = useRef<HTMLDivElement>(null)
// Hover state
const [hoveredCardKey, setHoveredCardKey] = useState<any>(null)
const { states } = useStoreState((state) => state.admin.lists)
const { setStates } = useStoreActions((a) => a.admin.lists)
// props.searchParams varsa onunla başlat
const [urlSearchParams, setUrlSearchParams] = useState<URLSearchParams>(() => {
const params = searchParams ? new URLSearchParams(searchParams) : new URLSearchParams()
// Initialize sort state from URL params
const sortParam = params.get('sort')
if (sortParam) {
try {
const sortArray = JSON.parse(sortParam)
if (sortArray && sortArray.length > 0) {
setSortColumn(sortArray[0].selector)
setSortOrder(sortArray[0].desc ? 'desc' : 'asc')
}
} catch (e) {
console.error('Sort param parse error:', e)
}
}
return params
})
const onPageSizeSelect = ({ value }: Option) => {
setPageSize(value)
setCurrentPage(1)
}
const onPageChange = (page: number) => {
setCurrentPage(page)
}
const toggleSort = (columnName: string) => {
const newParams = new URLSearchParams(urlSearchParams.toString())
if (sortColumn === columnName) {
const newOrder = sortOrder === 'asc' ? 'desc' : 'asc'
setSortOrder(newOrder)
newParams.set('sort', JSON.stringify([{ selector: columnName, desc: newOrder === 'desc' }]))
} else {
setSortColumn(columnName)
setSortOrder('asc')
newParams.set('sort', JSON.stringify([{ selector: columnName, desc: false }]))
}
setUrlSearchParams(newParams)
setCurrentPage(1) // Reset to first page when sorting
}
const handleSelectAll = async (checked: boolean) => {
if (checked) {
const keyField = gridDto?.gridOptions.keyFieldName
const selectAllMode = gridDto?.gridOptions?.selectionDto?.selectAllMode?.toLowerCase()
if (!keyField) return
if (selectAllMode === 'allpages') {
// Tüm sayfalardan tüm kayıtları al
if (!gridDataSource) return
setLoading(true)
try {
const loadOptions = {
skip: 0,
take: totalCount, // Tüm kayıtları al
requireTotalCount: false,
}
const res: any = await gridDataSource.load(loadOptions)
const allKeys = new Set(res.data.map((row: any) => row[keyField]))
setSelectedKeys(allKeys)
} catch (err) {
console.error('Select all pages error:', err)
} finally {
setLoading(false)
}
} else {
// Sadece mevcut sayfadaki kayıtları seç (page veya default)
const allKeys = new Set(data.map(row => row[keyField]))
setSelectedKeys(allKeys)
}
} else {
setSelectedKeys(new Set())
}
}
const handleCardSelection = (key: any, checked: boolean) => {
const newSelection = new Set(selectedKeys)
if (checked) {
if (selectionMode === 'single') {
newSelection.clear()
}
newSelection.add(key)
} else {
newSelection.delete(key)
}
setSelectedKeys(newSelection)
}
const handleCardClick = (key: any, index: number, event: React.MouseEvent) => {
setFocusedCardIndex(index)
if (selectionMode !== 'none' && event.ctrlKey) {
handleCardSelection(key, !selectedKeys.has(key))
}
}
const handleCardDoubleClick = (key: any, row: any) => {
// Navigate to edit page on double click
const navigate = window.location
window.open(
ROUTES_ENUM.protected.admin.formEdit
.replace(':listFormCode', listFormCode)
.replace(':id', key),
isPwaMode ? '_self' : '_blank'
)
}
const handleExport = async (format: 'xlsx' | 'csv' | 'pdf', selectedOnly: boolean = false) => {
try {
const exportData = selectedOnly
? data.filter(row => {
const keyField = gridDto?.gridOptions.keyFieldName
return keyField && selectedKeys.has(row[keyField])
})
: data
if (exportData.length === 0) {
return
}
if (format === 'xlsx' || format === 'csv') {
const [{ Workbook }, { saveAs }] = await Promise.all([
import('exceljs'),
import('file-saver'),
])
const workbook = new Workbook()
const worksheet = workbook.addWorksheet(`${listFormCode}_sheet`)
// Header row - visible columns only
const visibleColumns = gridDto?.columnFormats?.filter(
col => col.visible && col.dataType !== 'buttons'
) || []
const headerRow = visibleColumns.map(col => col.fieldName)
worksheet.addRow(headerRow)
worksheet.getRow(1).font = { bold: true }
// Data rows
exportData.forEach(row => {
const dataRow = visibleColumns.map(col => row[col.fieldName!])
worksheet.addRow(dataRow)
})
// Auto-fit columns
worksheet.columns.forEach((column: any) => {
column.width = 15
})
if (format === 'xlsx') {
const buffer = await workbook.xlsx.writeBuffer()
saveAs(
new Blob([buffer], { type: 'application/octet-stream' }),
`${listFormCode}_export${selectedOnly ? '_selected' : ''}.xlsx`,
)
} else {
const buffer = await workbook.csv.writeBuffer()
saveAs(
new Blob([buffer], { type: 'application/octet-stream' }),
`${listFormCode}_export${selectedOnly ? '_selected' : ''}.csv`,
)
}
} else if (format === 'pdf') {
const [jspdfMod] = await Promise.all([
import('jspdf'),
])
const JsPDFCtor = (jspdfMod as any).default ?? (jspdfMod as any).jsPDF
const doc = new JsPDFCtor({ orientation: 'landscape' })
// Header
const visibleColumns = gridDto?.columnFormats?.filter(
col => col.visible && col.dataType !== 'buttons'
) || []
let yPos = 10
doc.setFontSize(16)
doc.text(gridDto?.gridOptions.title || listFormCode, 10, yPos)
yPos += 10
// Table
doc.setFontSize(10)
const headers = visibleColumns.map(col => col.fieldName)
const rows = exportData.map(row =>
visibleColumns.map(col => String(row[col.fieldName!] || ''))
)
// Simple table rendering
doc.text(headers.join(' | '), 10, yPos)
yPos += 7
rows.forEach(row => {
if (yPos > 190) {
doc.addPage()
yPos = 10
}
doc.text(row.join(' | '), 10, yPos)
yPos += 7
})
doc.save(`${listFormCode}_export${selectedOnly ? '_selected' : ''}.pdf`)
}
} catch (err) {
console.error('Export error:', err)
}
}
const handleKeyDown = (e: KeyboardEvent) => {
if (data.length === 0) return
const keyField = gridDto?.gridOptions.keyFieldName
if (!keyField) return
switch (e.key) {
case 'ArrowRight':
e.preventDefault()
if (focusedCardIndex < data.length - 1) {
const newIndex = focusedCardIndex + 1
setFocusedCardIndex(newIndex)
cardRefs.current[newIndex]?.focus()
}
break
case 'ArrowLeft':
e.preventDefault()
if (focusedCardIndex > 0) {
const newIndex = focusedCardIndex - 1
setFocusedCardIndex(newIndex)
cardRefs.current[newIndex]?.focus()
}
break
case 'ArrowDown':
e.preventDefault()
if (focusedCardIndex + layoutCount < data.length) {
const newIndex = focusedCardIndex + layoutCount
setFocusedCardIndex(newIndex)
cardRefs.current[newIndex]?.focus()
}
break
case 'ArrowUp':
e.preventDefault()
if (focusedCardIndex - layoutCount >= 0) {
const newIndex = focusedCardIndex - layoutCount
setFocusedCardIndex(newIndex)
cardRefs.current[newIndex]?.focus()
}
break
case 'Enter':
e.preventDefault()
if (focusedCardIndex >= 0 && focusedCardIndex < data.length) {
const row = data[focusedCardIndex]
handleCardDoubleClick(row[keyField], row)
}
break
case ' ': // Space key for selection
e.preventDefault()
if (selectionMode !== 'none' && focusedCardIndex >= 0) {
const key = data[focusedCardIndex][keyField]
handleCardSelection(key, !selectedKeys.has(key))
}
break
}
}
const onFilter = useCallback(
(value?: string) => {
const text = value !== undefined ? value.trim() : searchText.trim()
if (!gridDto?.columnFormats) return
const newParams = new URLSearchParams(urlSearchParams.toString())
if (!text) {
newParams.delete('filter')
setUrlSearchParams(newParams)
return
}
const merged = gridDto.columnFormats
.filter(
(col) =>
col.dataType === 'string' &&
col.visible &&
col.width &&
col.allowSearch &&
col.width > 0,
)
.map((col) => [col.fieldName, 'contains', text])
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, 'or', f]
}, null as any)
}
if (filter) {
newParams.set('filter', JSON.stringify(filter))
} else {
newParams.delete('filter')
}
setUrlSearchParams(newParams)
},
[gridDto, urlSearchParams, searchText],
)
useEffect(() => {
if (gridDto) {
const dataSource = createSelectDataSource(
gridDto.gridOptions,
listFormCode,
urlSearchParams,
layoutTypes.card
)
setGridDataSource(dataSource)
//listFormStates
const listFormStates = states.find((a) => a.listFormCode === listFormCode)
if (listFormStates) {
setLayoutCount(listFormStates.cardLayoutColumn || 4)
} else {
setLayoutCount(gridDto.gridOptions.layoutDto.cardLayoutColumn || 4)
}
}
}, [gridDto, listFormCode, urlSearchParams, createSelectDataSource, states])
const loadData = useCallback(() => {
if (!gridDataSource) return
setLoading(true)
const loadOptions = {
skip: (currentPage - 1) * pageSize,
take: pageSize,
requireTotalCount: true,
}
gridDataSource
.load(loadOptions)
.then((res: any) => {
setData(res.data)
setTotalCount(res.totalCount || 0)
setLoading(false)
})
.catch(() => {
setLoading(false)
})
}, [gridDataSource, currentPage, pageSize])
useEffect(() => {
if (gridDataSource) {
loadData()
// selectionMode = page ise sayfa değiştiğinde seçimi temizle
const selectAllMode = gridDto?.gridOptions?.selectionDto?.selectAllMode?.toLowerCase()
if (selectAllMode === 'page') {
setSelectedKeys(new Set())
}
}
}, [gridDataSource, loadData, currentPage, gridDto])
useEffect(() => {
if (!gridDto) return
const pagerOptions = gridDto.gridOptions.pagerOptionDto
const allowedSizes = pagerOptions?.allowedPageSizes
?.split(',')
.map((s) => Number(s.trim()))
.filter((n) => !isNaN(n) && n > 0) || [10, 20, 50, 100]
setPageSizeOptions(allowedSizes.map((size) => ({ value: size, label: `${size} page` })))
}, [gridDto, listFormCode, searchParams])
useEffect(() => {
if (data.length > 0) {
window.scrollTo({ top: 0, behavior: 'smooth' })
}
}, [data])
if (!gridDto) return null
return (
<>
<WidgetGroup widgetGroups={gridDto.widgets || []} />
<Container>
<div
ref={containerRef}
onKeyDown={handleKeyDown}
tabIndex={0}
className="outline-none"
>
<div className="flex justify-between items-center mb-2">
<div className="flex items-center gap-4">
{selectionMode === 'multiple' && gridDto?.gridOptions?.selectionDto?.allowSelectAll && (
<div className="flex items-center">
<Checkbox
checked={
selectedKeys.size > 0 &&
(gridDto?.gridOptions?.selectionDto?.selectAllMode?.toLowerCase() === 'allpages'
? selectedKeys.size === totalCount
: selectedKeys.size === data.length)
}
onChange={(checked: boolean) => handleSelectAll(checked)}
className="cursor-pointer"
/>
<span className="text-sm text-gray-600 dark:text-gray-300">
{selectedKeys.size > 0 ? `${selectedKeys.size} seçili` : 'Tümünü Seç'}
</span>
</div>
)}
{selectionMode !== 'none' && selectedKeys.size > 0 && !gridDto?.gridOptions?.selectionDto?.allowSelectAll && (
<div className="flex items-center gap-2">
<span className="text-sm text-gray-600 dark:text-gray-300">
{selectedKeys.size} seçili
</span>
</div>
)}
</div>
<div className="flex justify-end items-center gap-2">
{/* Export Buttons */}
{gridDto?.gridOptions?.exportDto?.enabled && (
<div className="flex items-center gap-1">
<Button
size="xs"
variant="default"
onClick={() => handleExport('xlsx', selectionMode !== 'none' && selectedKeys.size > 0)}
title={selectionMode !== 'none' && selectedKeys.size > 0 ? `Seçilenleri Excel'e Aktar (${selectedKeys.size})` : "Tümünü Excel'e Aktar"}
>
📊 {selectionMode !== 'none' && selectedKeys.size > 0 ? `Seçilenleri Excel'e Aktar (${selectedKeys.size})` : `Tümünü Excel'e Aktar`}
</Button>
</div>
)}
{/* Sort Dropdown */}
{gridDto && gridDto.columnFormats && gridDto.columnFormats.length > 0 && (
<div className="flex items-center gap-1">
<select
className="text-xs border rounded px-2 py-1 dark:bg-neutral-700 dark:text-white dark:border-neutral-600"
onChange={(e) => {
if (e.target.value) {
toggleSort(e.target.value)
} else {
// "Sıralama Yok" seçildiğinde temizle
const newParams = new URLSearchParams(urlSearchParams.toString())
newParams.delete('sort')
setUrlSearchParams(newParams)
setSortColumn(null)
setSortOrder('asc')
}
}}
value={sortColumn || ''}
>
<option value="">Sıralama Yok</option>
{gridDto.columnFormats
.filter((col) => col.visible && col.dataType !== 'buttons')
.map((col) => (
<option key={col.fieldName} value={col.fieldName}>
{col.fieldName}
</option>
))}
</select>
{sortColumn && (
<button
onClick={() => toggleSort(sortColumn)}
className="text-gray-600 dark:text-gray-300 hover:text-blue-500 p-1"
title="Sıralama yönünü değiştir"
>
{sortOrder === 'asc' ? <FaSortAmountUp className="w-3 h-3" /> : <FaSortAmountDown className="w-3 h-3" />}
</button>
)}
</div>
)}
<div className="relative py-1 flex gap-1 border-b-1">
<FaSearch className="absolute left-2 top-1/2 -translate-y-1/2 text-gray-400 text-sm" />
<input
type="text"
placeholder="Search..."
value={searchText}
onChange={(e) => setSearchText(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
onFilter(e.currentTarget.value)
setPrevValue(e.currentTarget.value.trim()) // Enter ile tetiklenirse güncelle
}
}}
onBlur={(e) => {
const newValue = e.currentTarget.value.trim()
// 1. Değer değişmemişse => hiçbir şey yapma
if (newValue === prevValue) return
// 2. Yeni değer boş, ama eskiden değer vardı => filtre temizle
// 3. Yeni değer dolu ve eskisinden farklı => filtre uygula
onFilter(newValue)
setPrevValue(newValue)
}}
className="p-1 pl-6 pr-2 border border-1 outline-none text-xs text-gray-700 dark:text-gray-200 placeholder-gray-400 rounded"
/>
<Button
size="xs"
variant={layoutCount === 1 ? 'solid' : 'default'}
className="text-sm"
onClick={() => {
setLayoutCount(1)
setStates({ listFormCode, layout: 'card', cardLayoutColumn: 1 })
}}
title="1 Sütunda Göster"
>
1
</Button>
<Button
size="xs"
variant={layoutCount === 2 ? 'solid' : 'default'}
className="text-sm"
onClick={() => {
setLayoutCount(2)
setStates({ listFormCode, layout: 'card', cardLayoutColumn: 2 })
}}
title="2 Sütunda Göster"
>
2
</Button>
<Button
size="xs"
variant={layoutCount === 3 ? 'solid' : 'default'}
className="text-sm"
onClick={() => {
setLayoutCount(3)
setStates({ listFormCode, layout: 'card', cardLayoutColumn: 3 })
}}
title="3 Sütunda Göster"
>
3
</Button>
<Button
size="xs"
variant={layoutCount === 4 ? 'solid' : 'default'}
className="text-sm"
onClick={() => {
setLayoutCount(4)
setStates({ listFormCode, layout: 'card', cardLayoutColumn: 4 })
}}
title="4 Sütunda Göster"
>
4
</Button>
<Button
size="xs"
variant={layoutCount === 5 ? 'solid' : 'default'}
className="text-sm"
onClick={() => {
setLayoutCount(5)
setStates({ listFormCode, layout: 'card', cardLayoutColumn: 5 })
}}
title="5 Sütunda Göster"
>
5
</Button>
{checkPermission(gridDto?.gridOptions.permissionDto.u) && (
<Button
size="xs"
variant={'default'}
className="text-sm"
onClick={() => {
window.open(
ROUTES_ENUM.protected.saas.listFormManagement.edit.replace(
':listFormCode',
listFormCode,
),
isPwaMode ? '_self' : '_blank',
)
}}
title="Form Manager"
>
<FaCog className="w-3 h-3" />
</Button>
)}
</div>
</div>
</div>
{loading ? (
<Loading loading={true} />
) : data.length === 0 ? (
<div className="text-center py-12">
<FaSearch className="mx-auto h-10 w-10 text-gray-400" />
<h3 className="mt-2 text-sm font-medium text-gray-900 dark:text-gray-200">
Uygun kayıt bulunamadı
</h3>
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
Farklı filtreler deneyin veya yeni bir kayıt ekleyin.
</p>
</div>
) : (
<div
className="bg-transparent grid gap-4"
style={{
gridTemplateColumns: `repeat(${layoutCount}, minmax(0, 1fr))`
}}
>
{gridDataSource &&
data.map((row, idx) => {
const keyField = gridDto.gridOptions.keyFieldName
const rowId = row[keyField!]
const isSelected = selectedKeys.has(rowId)
const isFocused = focusedCardIndex === idx
const isHovered = hoveredCardKey === rowId
return (
<CardItem
ref={(el) => (cardRefs.current[idx] = el)}
isSubForm={true}
key={rowId || idx}
row={row}
gridDto={gridDto}
listFormCode={listFormCode}
dataSource={gridDataSource}
refreshData={loadData}
getCachedLookupDataSource={getLookupDataSource}
isSelected={isSelected}
isFocused={isFocused}
isHovered={isHovered}
onSelectionChange={(checked) => handleCardSelection(rowId, checked)}
onClick={(e) => handleCardClick(rowId, idx, e)}
onDoubleClick={() => handleCardDoubleClick(rowId, row)}
onMouseEnter={() => setHoveredCardKey(rowId)}
onMouseLeave={() => setHoveredCardKey(null)}
tabIndex={isFocused ? 0 : -1}
/>
)
})}
</div>
)}
{gridDto.gridOptions.pagerOptionDto?.visible && totalCount > pageSize && (
<div className={classNames('flex items-center justify-between border-t-1 gap-4 mt-2')}>
<div className="flex items-center gap-2">
<span className="text-xs text-gray-600 dark:text-gray-300">
Toplam {totalCount} kayıt
</span>
<Select
size="xs"
menuPlacement="top"
value={pageSizeOptions.find((o) => o.value === pageSize)}
options={pageSizeOptions}
onChange={(selected) => onPageSizeSelect(selected as Option)}
/>
</div>
<Pagination
currentPage={currentPage}
total={totalCount}
pageSize={pageSize}
onChange={onPageChange}
/>
</div>
)}
</div>
</Container>
</>
)
}
export default Card