import React, { useCallback, useEffect, useMemo, useState } from 'react' import { FaSearch, FaCopy, FaCheckCircle, FaExclamationCircle, FaDatabase, FaSyncAlt, FaPaperPlane, FaTrash, FaBolt, FaTable, FaToggleOn, FaToggleOff, FaChevronRight, FaChevronDown, } from 'react-icons/fa' import { useLocalization } from '@/utils/hooks/useLocalization' import Widget from '@/components/common/Widget' import { getDataSources } from '@/services/data-source.service' import { sqlObjectManagerService } from '@/services/sql-query-manager.service' import { developerKitService } from '@/services/developerKit.service' import apiService from '@/services/api.service' import type { DataSourceDto } from '@/proxy/data-source' import type { DatabaseTableDto } from '@/proxy/sql-query-manager/models' import type { CrudEndpoint } from '@/proxy/developerKit/models' import PageTitle from '@/components/shared/PageTitle' import { ConfirmDialog } from '@/components/shared' import Button from '@/components/ui/Button' import { Notification, toast } from '@/components/ui' interface TestResult { success: boolean status: number data?: unknown error?: unknown timestamp: string } interface ParameterInput { name: string value: string type: 'path' | 'query' } type CrudFilter = 'all' | 'with' | 'without' interface TableStat { total: number active: number } const CRUD_FILTERS: readonly CrudFilter[] = ['all', 'with', 'without'] as const const EMPTY_ENDPOINTS: CrudEndpoint[] = [] const EMPTY_PARAMS: ParameterInput[] = [] const DEFAULT_SAMPLE_BODY = JSON.stringify( { name: 'Sample Item', description: 'Description', isActive: true }, null, 2, ) const DEFAULT_ID_PARAM = '3fa85f64-5717-4562-b3fc-2c963f66afa6' const METHOD_COLOR: Record = { GET: 'bg-blue-100 text-blue-800 border-blue-200 dark:bg-blue-900/30 dark:text-blue-300 dark:border-blue-800', POST: 'bg-green-100 text-green-800 border-green-200 dark:bg-green-900/30 dark:text-green-300 dark:border-green-800', PUT: 'bg-yellow-100 text-yellow-800 border-yellow-200 dark:bg-yellow-900/30 dark:text-yellow-300 dark:border-yellow-800', DELETE: 'bg-red-100 text-red-800 border-red-200 dark:bg-red-900/30 dark:text-red-300 dark:border-red-800', } const FILTER_ACTIVE_CLASS: Record = { all: '!bg-blue-500 text-white', with: '!bg-green-500 text-white', without: '!bg-slate-500 text-white', } // Helper: tableName -> PascalCase entity name function toPascalCase(tableName: string): string { const name = tableName.replace(/^.*\./g, '') return name .replace(/[_-]([a-z])/gi, (_: string, c: string) => c.toUpperCase()) .replace(/^([a-z])/, (c: string) => c.toUpperCase()) } const getErrorMessage = (error: unknown, fallback: string): string => { const response = ( error as { response?: { data?: { error?: { message?: string }; message?: string } } } )?.response return ( response?.data?.error?.message || response?.data?.message || (error instanceof Error ? error.message : undefined) || fallback ) } const needsBody = (ep: CrudEndpoint) => ep.operationType === 'Create' || ep.operationType === 'Update' const CrudEndpointManager: React.FC = () => { const { translate } = useLocalization() // Endpoints const [generatedEndpoints, setGeneratedEndpoints] = useState([]) // Data source + tables const [dataSources, setDataSources] = useState([]) const [selectedDataSource, setSelectedDataSource] = useState(null) const [dbTables, setDbTables] = useState([]) const [loadingTables, setLoadingTables] = useState(false) // Selection const [selectedTable, setSelectedTable] = useState(null) const [tableSearch, setTableSearch] = useState('') const [crudFilter, setCrudFilter] = useState('all') // Endpoint management state const [generatingFor, setGeneratingFor] = useState(null) const [deletingAll, setDeletingAll] = useState(null) const [confirmDeleteTable, setConfirmDeleteTable] = useState(null) const [togglingId, setTogglingId] = useState(null) const [expandedEndpoint, setExpandedEndpoint] = useState(null) // Test state const [testResults, setTestResults] = useState>({}) const [loadingEndpoints, setLoadingEndpoints] = useState>(new Set()) const [parameterValues, setParameterValues] = useState>>({}) const [requestBodies, setRequestBodies] = useState>({}) const pushError = useCallback((message: string) => { toast.push( {message} , ) }, []) const pushSuccess = useCallback((message: string) => { toast.push( {message} , ) }, []) // Load endpoints + data sources on mount useEffect(() => { let cancelled = false developerKitService .getGeneratedListEndpoints() .then((res) => { if (!cancelled) setGeneratedEndpoints(res.items || []) }) .catch((err) => { if (!cancelled) pushError(getErrorMessage(err, translate('::App.Platform.ErrorLoading'))) }) getDataSources() .then((res) => { if (cancelled) return const items = res.data.items || [] setDataSources(items) setSelectedDataSource((current) => current ?? items[0]?.code ?? null) }) .catch((err) => { if (!cancelled) pushError(getErrorMessage(err, translate('::App.Platform.FailedtoloadDatasources'))) }) return () => { cancelled = true } }, [pushError, translate]) // Load tables when datasource changes useEffect(() => { if (!selectedDataSource) return let cancelled = false setLoadingTables(true) setDbTables([]) setSelectedTable(null) sqlObjectManagerService .getAllObjects(selectedDataSource) .then((res) => { if (!cancelled) setDbTables(res.data.tables || []) }) .catch((err) => { if (!cancelled) pushError(getErrorMessage(err, translate('::App.Platform.FailedToLoadObjects'))) }) .finally(() => { if (!cancelled) setLoadingTables(false) }) return () => { cancelled = true } }, [selectedDataSource, pushError, translate]) // entityName -> endpoints (tek geçiş, tablo başına filtrelemeyi önler) const endpointsByEntity = useMemo(() => { const map = new Map() for (const ep of generatedEndpoints) { const list = map.get(ep.entityName) if (list) list.push(ep) else map.set(ep.entityName, [ep]) } return map }, [generatedEndpoints]) const getEndpointsForTable = useCallback( (tableName: string): CrudEndpoint[] => endpointsByEntity.get(toPascalCase(tableName)) ?? EMPTY_ENDPOINTS, [endpointsByEntity], ) // fullName -> { total, active } const tableStats = useMemo(() => { const map = new Map() for (const table of dbTables) { const endpoints = endpointsByEntity.get(toPascalCase(table.tableName)) ?? EMPTY_ENDPOINTS map.set(table.fullName, { total: endpoints.length, active: endpoints.reduce((sum, ep) => sum + (ep.isActive ? 1 : 0), 0), }) } return map }, [dbTables, endpointsByEntity]) const withCrudCount = useMemo( () => dbTables.reduce((sum, t) => sum + ((tableStats.get(t.fullName)?.total ?? 0) > 0 ? 1 : 0), 0), [dbTables, tableStats], ) const filteredTables = useMemo(() => { const search = tableSearch.trim().toLowerCase() return dbTables.filter((t) => { const matchesSearch = !search || t.fullName.toLowerCase().includes(search) || t.tableName.toLowerCase().includes(search) if (!matchesSearch) return false if (crudFilter === 'all') return true const hasCrud = (tableStats.get(t.fullName)?.total ?? 0) > 0 return crudFilter === 'with' ? hasCrud : !hasCrud }) }, [dbTables, tableSearch, crudFilter, tableStats]) const tablesBySchema = useMemo( () => filteredTables.reduce>((acc, t) => { const schema = t.schemaName || 'dbo' ;(acc[schema] ??= []).push(t) return acc }, {}), [filteredTables], ) const filterLabels = useMemo>( () => ({ all: `${translate('::App.StaticLookup.All')} (${dbTables.length})`, with: `${translate('::App.DeveloperKitCrudEndpoints.FilterWith')} (${withCrudCount})`, without: `${translate('::App.DeveloperKitCrudEndpoints.FilterWithout')} (${dbTables.length - withCrudCount})`, }), [dbTables.length, withCrudCount, translate], ) // Generate CRUD endpoints for a table const handleGenerate = useCallback( async (table: DatabaseTableDto) => { setGeneratingFor(table.fullName) try { const entityName = toPascalCase(table.tableName) const result = await developerKitService.generateCrudEndpoints(entityName) setGeneratedEndpoints((prev) => [ ...prev.filter((ep) => ep.entityName !== entityName), ...(result.items || []), ]) pushSuccess(translate('::App.Platform.OperationCompleted')) } catch (err) { pushError(getErrorMessage(err, translate('::App.Platform.IslemBasarisiz'))) } finally { setGeneratingFor(null) } }, [pushError, pushSuccess, translate], ) // Delete all endpoints for a table const handleDeleteAll = useCallback( async (table: DatabaseTableDto) => { setDeletingAll(table.fullName) try { const endpoints = getEndpointsForTable(table.tableName) const results = await Promise.allSettled( endpoints.map((ep) => developerKitService.deleteGeneratedEndpoint(ep.id)), ) // Kısmi başarıda yalnızca gerçekten silinenleri state'ten düş. const deletedIds = new Set( endpoints.filter((_, i) => results[i].status === 'fulfilled').map((ep) => ep.id), ) if (deletedIds.size) { setGeneratedEndpoints((prev) => prev.filter((ep) => !deletedIds.has(ep.id))) } const failed = results.find((r) => r.status === 'rejected') if (failed) { pushError( getErrorMessage( (failed as PromiseRejectedResult).reason, translate('::App.Platform.ErrorDeleting'), ), ) } else { pushSuccess(translate('::App.Platform.ObjectDeletedSuccessfully')) } } catch (err) { pushError(getErrorMessage(err, translate('::App.Platform.ErrorDeleting'))) } finally { setDeletingAll(null) } }, [getEndpointsForTable, pushError, pushSuccess, translate], ) // Toggle single endpoint const handleToggle = useCallback( async (endpointId: string) => { setTogglingId(endpointId) try { const updated = await developerKitService.toggleGeneratedEndpoint(endpointId) setGeneratedEndpoints((prev) => prev.map((ep) => (ep.id === endpointId ? updated : ep))) } catch (err) { pushError(getErrorMessage(err, translate('::App.Platform.IslemBasarisiz'))) } finally { setTogglingId(null) } }, [pushError, translate], ) const copyToClipboard = useCallback( async (text: string) => { try { await navigator.clipboard.writeText(text) pushSuccess(translate('::App.Platform.Copied')) } catch (err) { pushError(getErrorMessage(err, translate('::App.Platform.CopyFailed'))) } }, [pushError, pushSuccess, translate], ) // Test endpoint helpers const getEndpointParameters = useCallback( (endpoint: CrudEndpoint): ParameterInput[] => { const vals = parameterValues[endpoint.id] const valueOf = (name: string, fallback: string) => vals?.[name] ?? fallback switch (endpoint.operationType) { case 'GetById': case 'Update': case 'Delete': return [{ name: 'id', value: valueOf('id', DEFAULT_ID_PARAM), type: 'path' }] case 'GetList': return [ { name: 'SkipCount', value: valueOf('SkipCount', '0'), type: 'query' }, { name: 'MaxResultCount', value: valueOf('MaxResultCount', '10'), type: 'query' }, ] default: return [] } }, [parameterValues], ) const getRequestBody = useCallback( (ep: CrudEndpoint) => requestBodies[ep.id] ?? DEFAULT_SAMPLE_BODY, [requestBodies], ) const testEndpoint = useCallback( async (endpoint: CrudEndpoint) => { setLoadingEndpoints((prev) => new Set(prev).add(endpoint.id)) try { const params = getEndpointParameters(endpoint) const pathValues = new Map( params.filter((p) => p.type === 'path').map((p) => [p.name, p.value]), ) // Path backend tarafından üretilir ({id} gibi yer tutucular içerir). const basePath = endpoint.path || `/api/app/crudendpoint/${endpoint.entityName}` const url = basePath.replace(/\{(\w+)\}/g, (match, name: string) => { const value = pathValues.get(name) return value === undefined ? match : encodeURIComponent(value) }) const queryParams = Object.fromEntries( params.filter((p) => p.type === 'query').map((p) => [p.name, p.value]), ) let data: unknown if (needsBody(endpoint)) { try { data = JSON.parse(getRequestBody(endpoint)) } catch { data = {} } } // apiService üzerinden gider: Authorization, __tenant ve Accept-Language // interceptor'ları uygulanır. const res = await apiService.fetchData({ method: endpoint.method, url, params: Object.keys(queryParams).length ? queryParams : undefined, timeout: 10000, data, }) setTestResults((prev) => ({ ...prev, [endpoint.id]: { success: true, status: res.status, data: res.data, timestamp: new Date().toISOString(), }, })) } catch (error: unknown) { const axiosErr = error as { response?: { status?: number; data?: unknown } message?: string } setTestResults((prev) => ({ ...prev, [endpoint.id]: { success: false, status: axiosErr.response?.status || 0, error: axiosErr.response?.data ?? axiosErr.message, timestamp: new Date().toISOString(), }, })) } finally { setLoadingEndpoints((prev) => { const next = new Set(prev) next.delete(endpoint.id) return next }) } }, [getEndpointParameters, getRequestBody], ) // Derived stats const totalActiveEndpoints = useMemo( () => generatedEndpoints.reduce((sum, ep) => sum + (ep.isActive ? 1 : 0), 0), [generatedEndpoints], ) const selectedTableEndpoints = useMemo( () => (selectedTable ? getEndpointsForTable(selectedTable.tableName) : EMPTY_ENDPOINTS), [selectedTable, getEndpointsForTable], ) const selectedActiveCount = useMemo( () => selectedTableEndpoints.reduce((sum, ep) => sum + (ep.isActive ? 1 : 0), 0), [selectedTableEndpoints], ) return (
{/* Stats Row */}
{/* Main two-panel layout */}
{/* Left Panel: Table List */}
{/* DataSource selector */}
{/* Search + CRUD filter */}
setTableSearch(e.target.value)} aria-label={translate('::App.DeveloperKitCrudEndpoints.SearchTable')} className="w-full pl-7 pr-3 py-1.5 text-sm border border-slate-300 dark:border-gray-700 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent bg-white dark:bg-gray-900 text-gray-900 dark:text-gray-100" />
{CRUD_FILTERS.map((f) => ( ))}
{/* Table list */}
{loadingTables ? (
{translate('::App.Platform.LoadingWithThreeDot')}
) : filteredTables.length === 0 ? (
{selectedDataSource ? translate('::App.Platform.NoTablesAvailable') : translate('::App.DeveloperKitCrudEndpoints.SelectDataSource')}
) : ( Object.entries(tablesBySchema).map(([schema, tables]) => (
{schema}
{tables.map((table) => { const stat = tableStats.get(table.fullName) const active = stat?.active ?? 0 const total = stat?.total ?? 0 const isSelected = selectedTable?.fullName === table.fullName const hasEndpoints = total > 0 return ( ) })}
)) )}
{/* Right Panel: Endpoint Management */}
{!selectedTable ? (

{translate('::App.DeveloperKitCrudEndpoints.SelectTablePrompt')}

{translate('::App.DeveloperKitCrudEndpoints.SelectTableDescription')}

) : (
{/* Table header */}

{selectedTable.schemaName}.{selectedTable.tableName}

{selectedTableEndpoints.length > 0 && ( )}
{/* Endpoints list */}
{selectedTableEndpoints.length === 0 ? (

{translate('::App.DeveloperKitCrudEndpoints.NoEndpointsYet')}

{translate('::App.DeveloperKitCrudEndpoints.ClickToCreate')}

) : (
{selectedTableEndpoints.map((ep) => { const isExpanded = expandedEndpoint === ep.id const testResult = testResults[ep.id] const isTesting = loadingEndpoints.has(ep.id) const parameters = isExpanded ? getEndpointParameters(ep) : EMPTY_PARAMS const resultPayload = testResult ? JSON.stringify( testResult.success ? testResult.data : testResult.error, null, 2, ) : '' return (
{/* Endpoint row */}
{/* Toggle */} {/* Method badge */} {ep.method} {/* Operation */}
{ep.operationType} {ep.path}
{/* Expand */}
{/* Expanded detail + test */} {isExpanded && (
{/* Parameters */} {parameters.length > 0 && (

{translate('::App.DeveloperKitCrudEndpoints.Parameters')}

{parameters.map((param) => (
{param.name} setParameterValues((prev) => ({ ...prev, [ep.id]: { ...prev[ep.id], [param.name]: e.target.value, }, })) } className="flex-1 px-2 py-1 text-xs border border-slate-300 dark:border-gray-700 rounded focus:ring-1 focus:ring-blue-500 bg-white dark:bg-gray-900 text-gray-900 dark:text-gray-100" />
))}
)} {/* Request body */} {needsBody(ep) && (

{translate('::App.DeveloperKitCrudEndpoints.RequestBody')}