2026-03-01 20:43:25 +00:00
|
|
|
|
import React, { useState, useEffect, useCallback } from 'react'
|
|
|
|
|
|
import axios from 'axios'
|
|
|
|
|
|
import {
|
|
|
|
|
|
FaSearch,
|
|
|
|
|
|
FaGlobe,
|
|
|
|
|
|
FaCopy,
|
|
|
|
|
|
FaCheckCircle,
|
|
|
|
|
|
FaExclamationCircle,
|
|
|
|
|
|
FaDatabase,
|
|
|
|
|
|
FaSyncAlt,
|
|
|
|
|
|
FaPaperPlane,
|
|
|
|
|
|
FaTrash,
|
|
|
|
|
|
FaBolt,
|
|
|
|
|
|
FaTable,
|
|
|
|
|
|
FaToggleOn,
|
|
|
|
|
|
FaToggleOff,
|
|
|
|
|
|
FaChevronRight,
|
|
|
|
|
|
FaChevronDown,
|
|
|
|
|
|
FaServer,
|
|
|
|
|
|
} from 'react-icons/fa'
|
|
|
|
|
|
import { useLocalization } from '@/utils/hooks/useLocalization'
|
|
|
|
|
|
import { getDataSources } from '@/services/data-source.service'
|
|
|
|
|
|
import { sqlObjectManagerService } from '@/services/sql-query-manager.service'
|
|
|
|
|
|
import { developerKitService } from '@/services/developerKit.service'
|
|
|
|
|
|
import type { DataSourceDto } from '@/proxy/data-source'
|
|
|
|
|
|
import type { DatabaseTableDto } from '@/proxy/sql-query-manager/models'
|
2026-03-02 21:34:19 +00:00
|
|
|
|
import type { CrudEndpoint } from '@/proxy/developerKit/models'
|
2026-03-01 20:43:25 +00:00
|
|
|
|
import { Helmet } from 'react-helmet'
|
|
|
|
|
|
import { APP_NAME } from '@/constants/app.constant'
|
|
|
|
|
|
|
|
|
|
|
|
interface TestResult {
|
|
|
|
|
|
success: boolean
|
|
|
|
|
|
status: number
|
|
|
|
|
|
data?: unknown
|
|
|
|
|
|
error?: unknown
|
|
|
|
|
|
timestamp: string
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
interface ParameterInput {
|
|
|
|
|
|
name: string
|
|
|
|
|
|
value: string
|
|
|
|
|
|
type: 'path' | 'query' | 'body'
|
|
|
|
|
|
required: boolean
|
|
|
|
|
|
description?: string
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 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 METHOD_COLOR: Record<string, string> = {
|
|
|
|
|
|
GET: 'bg-blue-100 text-blue-800 border-blue-200',
|
|
|
|
|
|
POST: 'bg-green-100 text-green-800 border-green-200',
|
|
|
|
|
|
PUT: 'bg-yellow-100 text-yellow-800 border-yellow-200',
|
|
|
|
|
|
DELETE: 'bg-red-100 text-red-800 border-red-200',
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const CrudEndpointManager: React.FC = () => {
|
|
|
|
|
|
const { translate } = useLocalization()
|
|
|
|
|
|
|
2026-03-02 21:34:19 +00:00
|
|
|
|
// Endpoint state
|
2026-03-01 20:43:25 +00:00
|
|
|
|
const [generatedEndpoints, setGeneratedEndpoints] = useState<CrudEndpoint[]>([])
|
|
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
2026-03-02 21:34:19 +00:00
|
|
|
|
developerKitService
|
|
|
|
|
|
.getGeneratedListEndpoints()
|
|
|
|
|
|
.then((res) => setGeneratedEndpoints(res.items || []))
|
|
|
|
|
|
.catch((err) => console.error('Failed to load endpoints', err))
|
2026-03-01 20:43:25 +00:00
|
|
|
|
}, [])
|
|
|
|
|
|
|
|
|
|
|
|
// Data source + tables
|
|
|
|
|
|
const [dataSources, setDataSources] = useState<DataSourceDto[]>([])
|
|
|
|
|
|
const [selectedDataSource, setSelectedDataSource] = useState<string | null>(null)
|
|
|
|
|
|
const [dbTables, setDbTables] = useState<DatabaseTableDto[]>([])
|
|
|
|
|
|
const [loadingTables, setLoadingTables] = useState(false)
|
|
|
|
|
|
|
|
|
|
|
|
// Selection
|
|
|
|
|
|
const [selectedTable, setSelectedTable] = useState<DatabaseTableDto | null>(null)
|
|
|
|
|
|
const [tableSearch, setTableSearch] = useState('')
|
|
|
|
|
|
const [crudFilter, setCrudFilter] = useState<'all' | 'with' | 'without'>('all')
|
|
|
|
|
|
|
|
|
|
|
|
// Endpoint management state
|
|
|
|
|
|
const [generatingFor, setGeneratingFor] = useState<string | null>(null)
|
|
|
|
|
|
const [deletingAll, setDeletingAll] = useState<string | null>(null)
|
|
|
|
|
|
const [togglingId, setTogglingId] = useState<string | null>(null)
|
|
|
|
|
|
const [expandedEndpoint, setExpandedEndpoint] = useState<string | null>(null)
|
|
|
|
|
|
|
|
|
|
|
|
// Test state
|
|
|
|
|
|
const [testResults, setTestResults] = useState<Record<string, TestResult>>({})
|
|
|
|
|
|
const [loadingEndpoints, setLoadingEndpoints] = useState<Set<string>>(new Set())
|
|
|
|
|
|
const [parameterValues, setParameterValues] = useState<Record<string, Record<string, string>>>({})
|
|
|
|
|
|
const [requestBodies, setRequestBodies] = useState<Record<string, string>>({})
|
|
|
|
|
|
|
|
|
|
|
|
// Load data sources on mount
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
getDataSources()
|
|
|
|
|
|
.then((res) => {
|
|
|
|
|
|
const items = res.data.items || []
|
|
|
|
|
|
setDataSources(items)
|
|
|
|
|
|
if (items.length > 0) {
|
|
|
|
|
|
setSelectedDataSource(items[0].code ?? null)
|
|
|
|
|
|
}
|
|
|
|
|
|
})
|
|
|
|
|
|
.catch(console.error)
|
|
|
|
|
|
}, [])
|
|
|
|
|
|
|
|
|
|
|
|
// Load tables when datasource changes
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
if (!selectedDataSource) return
|
|
|
|
|
|
setLoadingTables(true)
|
|
|
|
|
|
setDbTables([])
|
|
|
|
|
|
setSelectedTable(null)
|
|
|
|
|
|
sqlObjectManagerService
|
|
|
|
|
|
.getAllObjects(selectedDataSource)
|
|
|
|
|
|
.then((res) => {
|
|
|
|
|
|
setDbTables(res.data.tables || [])
|
|
|
|
|
|
})
|
|
|
|
|
|
.catch(console.error)
|
|
|
|
|
|
.finally(() => setLoadingTables(false))
|
|
|
|
|
|
}, [selectedDataSource])
|
|
|
|
|
|
|
|
|
|
|
|
// Helpers
|
|
|
|
|
|
const getEndpointsForTable = useCallback(
|
|
|
|
|
|
(tableName: string): CrudEndpoint[] => {
|
2026-03-02 21:34:19 +00:00
|
|
|
|
const entityName = toPascalCase(tableName)
|
|
|
|
|
|
return generatedEndpoints.filter((ep) => ep.entityName === entityName)
|
2026-03-01 20:43:25 +00:00
|
|
|
|
},
|
2026-03-02 21:34:19 +00:00
|
|
|
|
[generatedEndpoints],
|
2026-03-01 20:43:25 +00:00
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
const activeEndpointCount = (tableName: string) =>
|
|
|
|
|
|
getEndpointsForTable(tableName).filter((ep) => ep.isActive).length
|
|
|
|
|
|
|
|
|
|
|
|
const allEndpointCount = (tableName: string) => getEndpointsForTable(tableName).length
|
|
|
|
|
|
|
|
|
|
|
|
// Filtered table list
|
|
|
|
|
|
const filteredTables = dbTables.filter((t) => {
|
|
|
|
|
|
const matchesSearch =
|
|
|
|
|
|
t.fullName.toLowerCase().includes(tableSearch.toLowerCase()) ||
|
|
|
|
|
|
t.tableName.toLowerCase().includes(tableSearch.toLowerCase())
|
|
|
|
|
|
const hasCrud = allEndpointCount(t.tableName) > 0
|
|
|
|
|
|
const matchesCrudFilter =
|
|
|
|
|
|
crudFilter === 'all' ||
|
|
|
|
|
|
(crudFilter === 'with' && hasCrud) ||
|
|
|
|
|
|
(crudFilter === 'without' && !hasCrud)
|
|
|
|
|
|
return matchesSearch && matchesCrudFilter
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
// Group by schema
|
|
|
|
|
|
const tablesBySchema = filteredTables.reduce<Record<string, DatabaseTableDto[]>>((acc, t) => {
|
|
|
|
|
|
const schema = t.schemaName || 'dbo'
|
|
|
|
|
|
if (!acc[schema]) acc[schema] = []
|
|
|
|
|
|
acc[schema].push(t)
|
|
|
|
|
|
return acc
|
|
|
|
|
|
}, {})
|
|
|
|
|
|
|
|
|
|
|
|
// Generate CRUD endpoints for selected table
|
|
|
|
|
|
const handleGenerate = async (table: DatabaseTableDto) => {
|
|
|
|
|
|
const key = table.fullName
|
|
|
|
|
|
setGeneratingFor(key)
|
|
|
|
|
|
try {
|
2026-03-02 21:34:19 +00:00
|
|
|
|
const entityName = toPascalCase(table.tableName)
|
|
|
|
|
|
const result = await developerKitService.generateCrudEndpoints(entityName)
|
2026-03-01 20:43:25 +00:00
|
|
|
|
setGeneratedEndpoints((prev) => [
|
2026-03-02 21:34:19 +00:00
|
|
|
|
...prev.filter((ep) => ep.entityName !== entityName),
|
2026-03-01 20:43:25 +00:00
|
|
|
|
...(result.items || []),
|
|
|
|
|
|
])
|
|
|
|
|
|
} catch (err) {
|
|
|
|
|
|
console.error('Generate failed', err)
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
setGeneratingFor(null)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Delete all endpoints for a table
|
|
|
|
|
|
const handleDeleteAll = async (table: DatabaseTableDto) => {
|
|
|
|
|
|
const key = table.fullName
|
|
|
|
|
|
setDeletingAll(key)
|
|
|
|
|
|
try {
|
|
|
|
|
|
const endpoints = getEndpointsForTable(table.tableName)
|
|
|
|
|
|
await Promise.all(endpoints.map((ep) => developerKitService.deleteGeneratedEndpoint(ep.id)))
|
|
|
|
|
|
const deletedIds = new Set(endpoints.map((ep) => ep.id))
|
|
|
|
|
|
setGeneratedEndpoints((prev) => prev.filter((ep) => !deletedIds.has(ep.id)))
|
|
|
|
|
|
} catch (err) {
|
|
|
|
|
|
console.error('Delete failed', err)
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
setDeletingAll(null)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Toggle single endpoint
|
|
|
|
|
|
const handleToggle = async (endpointId: string) => {
|
|
|
|
|
|
setTogglingId(endpointId)
|
|
|
|
|
|
try {
|
|
|
|
|
|
const updated = await developerKitService.toggleGeneratedEndpoint(endpointId)
|
|
|
|
|
|
setGeneratedEndpoints((prev) => prev.map((ep) => (ep.id === endpointId ? updated : ep)))
|
|
|
|
|
|
} catch (err) {
|
|
|
|
|
|
console.error('Toggle failed', err)
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
setTogglingId(null)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Test endpoint helpers
|
|
|
|
|
|
const getEndpointParameters = (endpoint: CrudEndpoint): ParameterInput[] => {
|
|
|
|
|
|
const params: ParameterInput[] = []
|
|
|
|
|
|
const vals = parameterValues[endpoint.id] || {}
|
|
|
|
|
|
switch (endpoint.operationType) {
|
|
|
|
|
|
case 'GetById':
|
|
|
|
|
|
case 'Update':
|
|
|
|
|
|
case 'Delete':
|
|
|
|
|
|
params.push({
|
|
|
|
|
|
name: 'id',
|
|
|
|
|
|
value: vals.id || '3fa85f64-5717-4562-b3fc-2c963f66afa6',
|
|
|
|
|
|
type: 'path',
|
|
|
|
|
|
required: true,
|
|
|
|
|
|
description: 'Entity ID',
|
|
|
|
|
|
})
|
|
|
|
|
|
break
|
|
|
|
|
|
case 'GetList':
|
|
|
|
|
|
params.push(
|
|
|
|
|
|
{
|
|
|
|
|
|
name: 'SkipCount',
|
|
|
|
|
|
value: vals.SkipCount || '0',
|
|
|
|
|
|
type: 'query',
|
|
|
|
|
|
required: false,
|
|
|
|
|
|
description: 'Skip count',
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
name: 'MaxResultCount',
|
|
|
|
|
|
value: vals.MaxResultCount || '10',
|
|
|
|
|
|
type: 'query',
|
|
|
|
|
|
required: false,
|
|
|
|
|
|
description: 'Max records',
|
|
|
|
|
|
},
|
|
|
|
|
|
)
|
|
|
|
|
|
break
|
|
|
|
|
|
}
|
|
|
|
|
|
return params
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const needsBody = (ep: CrudEndpoint) =>
|
|
|
|
|
|
ep.operationType === 'Create' || ep.operationType === 'Update'
|
|
|
|
|
|
|
|
|
|
|
|
const getRequestBody = (ep: CrudEndpoint) => {
|
|
|
|
|
|
if (requestBodies[ep.id]) return requestBodies[ep.id]
|
|
|
|
|
|
return JSON.stringify(
|
|
|
|
|
|
{ name: 'Sample Item', description: 'Description', isActive: true },
|
|
|
|
|
|
null,
|
|
|
|
|
|
2,
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const testEndpoint = async (endpoint: CrudEndpoint) => {
|
|
|
|
|
|
setLoadingEndpoints((prev) => new Set(prev).add(endpoint.id))
|
|
|
|
|
|
try {
|
|
|
|
|
|
let url = `${import.meta.env.VITE_API_URL}/api/app/crudendpoint/${endpoint.entityName?.toLowerCase()}`
|
|
|
|
|
|
const params = getEndpointParameters(endpoint)
|
|
|
|
|
|
const pathParam = params.find((p) => p.type === 'path')
|
|
|
|
|
|
if (pathParam) url += `/${pathParam.value}`
|
|
|
|
|
|
const queryParams = params.filter((p) => p.type === 'query')
|
|
|
|
|
|
if (queryParams.length) {
|
|
|
|
|
|
url += '?' + queryParams.map((p) => `${p.name}=${encodeURIComponent(p.value)}`).join('&')
|
|
|
|
|
|
}
|
|
|
|
|
|
let data = undefined
|
|
|
|
|
|
if (needsBody(endpoint)) {
|
|
|
|
|
|
try {
|
|
|
|
|
|
data = JSON.parse(getRequestBody(endpoint))
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
data = {}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
const res = await axios({
|
|
|
|
|
|
method: endpoint.method,
|
|
|
|
|
|
url,
|
|
|
|
|
|
timeout: 10000,
|
|
|
|
|
|
headers: { 'Content-Type': 'application/json' },
|
|
|
|
|
|
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 s = new Set(prev)
|
|
|
|
|
|
s.delete(endpoint.id)
|
|
|
|
|
|
return s
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Derived stats
|
|
|
|
|
|
const tablesWithEndpoints = dbTables.filter((t) => allEndpointCount(t.tableName) > 0).length
|
|
|
|
|
|
const totalActiveEndpoints = generatedEndpoints.filter((ep) => ep.isActive).length
|
|
|
|
|
|
const selectedTableEndpoints = selectedTable ? getEndpointsForTable(selectedTable.tableName) : []
|
|
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
|
<div className="flex flex-col h-full gap-4">
|
|
|
|
|
|
<Helmet
|
|
|
|
|
|
titleTemplate={`%s | ${APP_NAME}`}
|
|
|
|
|
|
title={translate('::' + 'App.DeveloperKit.CrudEndpoints')}
|
|
|
|
|
|
defaultTitle={APP_NAME}
|
|
|
|
|
|
></Helmet>
|
|
|
|
|
|
|
|
|
|
|
|
{/* Stats Row */}
|
|
|
|
|
|
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 mt-2">
|
|
|
|
|
|
<div className="bg-white rounded-lg border border-slate-200 p-6">
|
|
|
|
|
|
<div className="flex items-center justify-between">
|
|
|
|
|
|
<div>
|
|
|
|
|
|
<p className="text-sm font-medium text-slate-600 mb-1">Toplam Tablo</p>
|
|
|
|
|
|
<p className="text-3xl font-bold text-slate-900">{dbTables.length}</p>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className="p-3 rounded-lg bg-blue-100">
|
|
|
|
|
|
<FaDatabase className="w-6 h-6 text-blue-600" />
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className="bg-white rounded-lg border border-slate-200 p-6">
|
|
|
|
|
|
<div className="flex items-center justify-between">
|
|
|
|
|
|
<div>
|
|
|
|
|
|
<p className="text-sm font-medium text-slate-600 mb-1">Endpoint Kurulu</p>
|
|
|
|
|
|
<p className="text-3xl font-bold text-slate-900">{tablesWithEndpoints}</p>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className="p-3 rounded-lg bg-green-100">
|
|
|
|
|
|
<FaCheckCircle className="w-6 h-6 text-green-600" />
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className="bg-white rounded-lg border border-slate-200 p-6">
|
|
|
|
|
|
<div className="flex items-center justify-between">
|
|
|
|
|
|
<div>
|
|
|
|
|
|
<p className="text-sm font-medium text-slate-600 mb-1">Aktif Endpoint</p>
|
|
|
|
|
|
<p className="text-3xl font-bold text-slate-900">{totalActiveEndpoints}</p>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className="p-3 rounded-lg bg-emerald-100">
|
|
|
|
|
|
<FaBolt className="w-6 h-6 text-emerald-600" />
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className="bg-white rounded-lg border border-slate-200 p-6">
|
|
|
|
|
|
<div className="flex items-center justify-between">
|
|
|
|
|
|
<div>
|
|
|
|
|
|
<p className="text-sm font-medium text-slate-600 mb-1">Veri Kaynagi</p>
|
|
|
|
|
|
<p className="text-3xl font-bold text-slate-900">{dataSources.length}</p>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className="p-3 rounded-lg bg-purple-100">
|
|
|
|
|
|
<FaServer className="w-6 h-6 text-purple-600" />
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
{/* Main two-panel layout */}
|
|
|
|
|
|
<div className="flex gap-4" style={{ height: 'calc(100vh - 250px)', minHeight: 400 }}>
|
|
|
|
|
|
{/* Left Panel: Table List */}
|
|
|
|
|
|
<div className="w-72 flex-shrink-0 flex flex-col bg-white rounded-lg border border-slate-200 overflow-hidden">
|
|
|
|
|
|
{/* DataSource selector */}
|
|
|
|
|
|
<div className="p-3 border-b border-slate-200 bg-slate-50">
|
|
|
|
|
|
<select
|
|
|
|
|
|
value={selectedDataSource || ''}
|
|
|
|
|
|
onChange={(e) => setSelectedDataSource(e.target.value)}
|
|
|
|
|
|
className="w-full px-2 py-1.5 text-sm border border-slate-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent bg-white"
|
|
|
|
|
|
>
|
|
|
|
|
|
{dataSources.map((ds) => (
|
|
|
|
|
|
<option key={ds.id} value={ds.code ?? ''}>
|
|
|
|
|
|
{ds.code}
|
|
|
|
|
|
</option>
|
|
|
|
|
|
))}
|
|
|
|
|
|
{dataSources.length === 0 && <option value="">Yukleniyor...</option>}
|
|
|
|
|
|
</select>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
{/* Search + CRUD filter */}
|
|
|
|
|
|
<div className="p-3 border-b border-slate-200 space-y-2">
|
|
|
|
|
|
<div className="relative">
|
|
|
|
|
|
<FaSearch className="absolute left-2.5 top-1/2 -translate-y-1/2 text-slate-400 text-xs" />
|
|
|
|
|
|
<input
|
|
|
|
|
|
type="text"
|
|
|
|
|
|
placeholder="Tablo ara..."
|
|
|
|
|
|
value={tableSearch}
|
|
|
|
|
|
onChange={(e) => setTableSearch(e.target.value)}
|
|
|
|
|
|
className="w-full pl-7 pr-3 py-1.5 text-sm border border-slate-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
|
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className="flex rounded-lg border border-slate-200 overflow-hidden text-xs font-medium">
|
|
|
|
|
|
{(['all', 'with', 'without'] as const).map((f) => {
|
|
|
|
|
|
const labels = {
|
|
|
|
|
|
all: `Tümü (${dbTables.length})`,
|
|
|
|
|
|
with: `VAR (${dbTables.filter((t) => allEndpointCount(t.tableName) > 0).length})`,
|
|
|
|
|
|
without: `YOK (${dbTables.filter((t) => allEndpointCount(t.tableName) === 0).length})`,
|
|
|
|
|
|
}
|
|
|
|
|
|
const active = crudFilter === f
|
|
|
|
|
|
return (
|
|
|
|
|
|
<button
|
|
|
|
|
|
key={f}
|
|
|
|
|
|
onClick={() => setCrudFilter(f)}
|
|
|
|
|
|
className={`flex-1 py-1.5 transition-colors ${
|
|
|
|
|
|
active
|
|
|
|
|
|
? f === 'with'
|
|
|
|
|
|
? 'bg-green-500 text-white'
|
|
|
|
|
|
: f === 'without'
|
|
|
|
|
|
? 'bg-slate-500 text-white'
|
|
|
|
|
|
: 'bg-blue-500 text-white'
|
|
|
|
|
|
: 'bg-white text-slate-500 hover:bg-slate-50'
|
|
|
|
|
|
}`}
|
|
|
|
|
|
>
|
|
|
|
|
|
{labels[f]}
|
|
|
|
|
|
</button>
|
|
|
|
|
|
)
|
|
|
|
|
|
})}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
{/* Table list */}
|
|
|
|
|
|
<div className="flex-1 overflow-y-auto">
|
|
|
|
|
|
{loadingTables ? (
|
|
|
|
|
|
<div className="flex items-center justify-center p-8 text-slate-400">
|
|
|
|
|
|
<FaSyncAlt className="animate-spin mr-2" />
|
|
|
|
|
|
<span className="text-sm">Yukleniyor...</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
) : filteredTables.length === 0 ? (
|
|
|
|
|
|
<div className="p-6 text-center text-slate-400 text-sm">
|
|
|
|
|
|
{selectedDataSource ? 'Tablo bulunamadi' : 'Veri kaynagi secin'}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
) : (
|
|
|
|
|
|
Object.entries(tablesBySchema).map(([schema, tables]) => (
|
|
|
|
|
|
<div key={schema}>
|
|
|
|
|
|
<div className="px-3 py-1.5 text-xs font-semibold text-slate-400 uppercase tracking-wide bg-slate-50 border-b border-slate-100 sticky top-0">
|
|
|
|
|
|
{schema}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
{tables.map((table) => {
|
|
|
|
|
|
const active = activeEndpointCount(table.tableName)
|
|
|
|
|
|
const total = allEndpointCount(table.tableName)
|
|
|
|
|
|
const isSelected = selectedTable?.fullName === table.fullName
|
|
|
|
|
|
const hasEndpoints = total > 0
|
|
|
|
|
|
return (
|
|
|
|
|
|
<button
|
|
|
|
|
|
key={table.fullName}
|
|
|
|
|
|
onClick={() => setSelectedTable(table)}
|
|
|
|
|
|
className={`w-full flex items-center justify-between px-3 py-1 text-left hover:bg-slate-50 transition-colors border-b border-slate-50 ${
|
|
|
|
|
|
isSelected ? 'bg-blue-50 border-l-2 border-l-blue-500' : ''
|
|
|
|
|
|
}`}
|
|
|
|
|
|
>
|
|
|
|
|
|
<div className="flex items-center gap-2 min-w-0">
|
|
|
|
|
|
<FaTable
|
|
|
|
|
|
className={`flex-shrink-0 text-xs ${
|
|
|
|
|
|
hasEndpoints ? 'text-green-500' : 'text-slate-300'
|
|
|
|
|
|
}`}
|
|
|
|
|
|
/>
|
|
|
|
|
|
<span className="text-sm text-slate-700 truncate">{table.tableName}</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
{hasEndpoints && (
|
|
|
|
|
|
<span
|
|
|
|
|
|
className={`flex-shrink-0 text-xs px-1.5 py-0.5 rounded-full font-medium ${
|
|
|
|
|
|
active > 0
|
|
|
|
|
|
? 'bg-green-100 text-green-700'
|
|
|
|
|
|
: 'bg-slate-100 text-slate-500'
|
|
|
|
|
|
}`}
|
|
|
|
|
|
>
|
|
|
|
|
|
{active}/{total}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
)}
|
|
|
|
|
|
{isSelected && (
|
|
|
|
|
|
<FaChevronRight className="flex-shrink-0 text-blue-400 text-xs ml-1" />
|
|
|
|
|
|
)}
|
|
|
|
|
|
</button>
|
|
|
|
|
|
)
|
|
|
|
|
|
})}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
))
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
{/* Right Panel: Endpoint Management */}
|
|
|
|
|
|
<div className="flex-1 min-w-0 flex flex-col bg-white rounded-lg border border-slate-200 overflow-hidden">
|
|
|
|
|
|
{!selectedTable ? (
|
|
|
|
|
|
<div className="flex-1 flex flex-col items-center justify-center text-slate-400 p-8">
|
|
|
|
|
|
<FaDatabase className="text-4xl mb-3 text-slate-200" />
|
|
|
|
|
|
<p className="text-base font-medium">Soldan bir tablo secin</p>
|
|
|
|
|
|
<p className="text-sm mt-1">
|
|
|
|
|
|
Secilen tablo icin CRUD endpointlerini yonetebilirsiniz
|
|
|
|
|
|
</p>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
) : (
|
|
|
|
|
|
<div className="flex flex-col h-full overflow-hidden">
|
|
|
|
|
|
{/* Table header */}
|
|
|
|
|
|
<div className="flex items-center justify-between p-4 border-b border-slate-200 bg-slate-50">
|
|
|
|
|
|
<div className="flex items-center gap-3">
|
|
|
|
|
|
<div className="bg-blue-100 text-blue-600 p-2 rounded-lg">
|
|
|
|
|
|
<FaTable />
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div>
|
|
|
|
|
|
<h4 className="text-slate-900">{selectedTable.schemaName}.{selectedTable.tableName}</h4>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className="flex items-center gap-2">
|
|
|
|
|
|
{selectedTableEndpoints.length > 0 && (
|
|
|
|
|
|
<button
|
|
|
|
|
|
onClick={() => handleDeleteAll(selectedTable)}
|
|
|
|
|
|
disabled={deletingAll === selectedTable.fullName}
|
|
|
|
|
|
className="flex items-center gap-2 px-3 py-1.5 text-sm text-red-600 border border-red-200 rounded-lg hover:bg-red-50 disabled:opacity-50 transition-colors"
|
|
|
|
|
|
>
|
|
|
|
|
|
{deletingAll === selectedTable.fullName ? (
|
|
|
|
|
|
<FaSyncAlt className="animate-spin" />
|
|
|
|
|
|
) : (
|
|
|
|
|
|
<FaTrash />
|
|
|
|
|
|
)}
|
|
|
|
|
|
Tumunu Sil
|
|
|
|
|
|
</button>
|
|
|
|
|
|
)}
|
|
|
|
|
|
<button
|
|
|
|
|
|
onClick={() => handleGenerate(selectedTable)}
|
|
|
|
|
|
disabled={generatingFor === selectedTable.fullName}
|
|
|
|
|
|
className="flex items-center gap-2 px-4 py-1.5 text-sm font-medium bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50 transition-colors"
|
|
|
|
|
|
>
|
|
|
|
|
|
{generatingFor === selectedTable.fullName ? (
|
|
|
|
|
|
<FaSyncAlt className="animate-spin" />
|
|
|
|
|
|
) : (
|
|
|
|
|
|
<FaBolt />
|
|
|
|
|
|
)}
|
|
|
|
|
|
{selectedTableEndpoints.length > 0
|
|
|
|
|
|
? 'Yeniden Olustur'
|
|
|
|
|
|
: 'CRUD Endpoint Olustur'}
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
{/* Endpoints list */}
|
|
|
|
|
|
<div className="flex-1 overflow-y-auto p-4">
|
|
|
|
|
|
{selectedTableEndpoints.length === 0 ? (
|
|
|
|
|
|
<div className="flex flex-col items-center justify-center py-16 text-slate-400">
|
|
|
|
|
|
<FaBolt className="text-3xl mb-3 text-slate-200" />
|
|
|
|
|
|
<p className="font-medium">Henuz endpoint yok</p>
|
|
|
|
|
|
<p className="text-sm mt-1">
|
2026-03-02 21:34:19 +00:00
|
|
|
|
"CRUD Endpoint Olustur" butonuna tiklayin
|
2026-03-01 20:43:25 +00:00
|
|
|
|
</p>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
) : (
|
|
|
|
|
|
<div className="space-y-3">
|
|
|
|
|
|
{selectedTableEndpoints.map((ep) => {
|
|
|
|
|
|
const isExpanded = expandedEndpoint === ep.id
|
|
|
|
|
|
const testResult = testResults[ep.id]
|
|
|
|
|
|
return (
|
|
|
|
|
|
<div
|
|
|
|
|
|
key={ep.id}
|
|
|
|
|
|
className="border border-slate-200 rounded-lg overflow-hidden"
|
|
|
|
|
|
>
|
|
|
|
|
|
{/* Endpoint row */}
|
|
|
|
|
|
<div className="flex items-center gap-3 p-3 bg-white hover:bg-slate-50 transition-colors">
|
|
|
|
|
|
{/* Toggle */}
|
|
|
|
|
|
<button
|
|
|
|
|
|
onClick={() => handleToggle(ep.id)}
|
|
|
|
|
|
disabled={togglingId === ep.id}
|
|
|
|
|
|
title={ep.isActive ? 'Devre disi birak' : 'Etkinlestir'}
|
|
|
|
|
|
className={`flex-shrink-0 text-xl transition-colors ${
|
|
|
|
|
|
ep.isActive
|
|
|
|
|
|
? 'text-green-500 hover:text-green-700'
|
|
|
|
|
|
: 'text-slate-300 hover:text-slate-500'
|
|
|
|
|
|
} disabled:opacity-40`}
|
|
|
|
|
|
>
|
|
|
|
|
|
{togglingId === ep.id ? (
|
|
|
|
|
|
<FaSyncAlt className="animate-spin text-base" />
|
|
|
|
|
|
) : ep.isActive ? (
|
|
|
|
|
|
<FaToggleOn />
|
|
|
|
|
|
) : (
|
|
|
|
|
|
<FaToggleOff />
|
|
|
|
|
|
)}
|
|
|
|
|
|
</button>
|
|
|
|
|
|
|
|
|
|
|
|
{/* Method badge */}
|
|
|
|
|
|
<span
|
|
|
|
|
|
className={`flex-shrink-0 px-2 py-0.5 text-xs font-bold rounded border ${
|
|
|
|
|
|
METHOD_COLOR[ep.method] ||
|
|
|
|
|
|
'bg-slate-100 text-slate-700 border-slate-200'
|
|
|
|
|
|
}`}
|
|
|
|
|
|
>
|
|
|
|
|
|
{ep.method}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
|
|
|
|
|
|
{/* Operation */}
|
|
|
|
|
|
<div className="flex-1 min-w-0">
|
|
|
|
|
|
<span className="font-medium text-slate-800 text-sm">
|
|
|
|
|
|
{ep.operationType}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
<code className="ml-3 text-xs text-slate-500 bg-slate-100 px-2 py-0.5 rounded">
|
|
|
|
|
|
{ep.path}
|
|
|
|
|
|
</code>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
{/* Expand */}
|
|
|
|
|
|
<button
|
|
|
|
|
|
onClick={() => setExpandedEndpoint(isExpanded ? null : ep.id)}
|
|
|
|
|
|
className="p-1.5 text-slate-400 hover:text-slate-700 transition-colors"
|
|
|
|
|
|
title="Test et / detaylar"
|
|
|
|
|
|
>
|
|
|
|
|
|
{isExpanded ? (
|
|
|
|
|
|
<FaChevronDown className="text-xs" />
|
|
|
|
|
|
) : (
|
|
|
|
|
|
<FaChevronRight className="text-xs" />
|
|
|
|
|
|
)}
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
{/* Expanded detail + test */}
|
|
|
|
|
|
{isExpanded && (
|
|
|
|
|
|
<div className="border-t border-slate-200 bg-slate-50 p-4 space-y-4">
|
|
|
|
|
|
{/* Parameters */}
|
|
|
|
|
|
{getEndpointParameters(ep).length > 0 && (
|
|
|
|
|
|
<div>
|
|
|
|
|
|
<p className="text-xs font-semibold text-slate-600 mb-2">
|
|
|
|
|
|
Parametreler
|
|
|
|
|
|
</p>
|
|
|
|
|
|
<div className="space-y-2">
|
|
|
|
|
|
{getEndpointParameters(ep).map((param) => (
|
|
|
|
|
|
<div key={param.name} className="flex items-center gap-2">
|
|
|
|
|
|
<span
|
|
|
|
|
|
className={`text-xs px-1.5 py-0.5 rounded font-mono ${
|
|
|
|
|
|
param.type === 'path'
|
|
|
|
|
|
? 'bg-blue-100 text-blue-700'
|
|
|
|
|
|
: 'bg-green-100 text-green-700'
|
|
|
|
|
|
}`}
|
|
|
|
|
|
>
|
|
|
|
|
|
{param.name}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
<input
|
|
|
|
|
|
type="text"
|
|
|
|
|
|
value={
|
|
|
|
|
|
parameterValues[ep.id]?.[param.name] ?? param.value
|
|
|
|
|
|
}
|
|
|
|
|
|
onChange={(e) =>
|
|
|
|
|
|
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 rounded focus:ring-1 focus:ring-blue-500"
|
|
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
|
|
{/* Request body */}
|
|
|
|
|
|
{needsBody(ep) && (
|
|
|
|
|
|
<div>
|
|
|
|
|
|
<p className="text-xs font-semibold text-slate-600 mb-2">
|
|
|
|
|
|
Request Body (JSON)
|
|
|
|
|
|
</p>
|
|
|
|
|
|
<textarea
|
|
|
|
|
|
value={getRequestBody(ep)}
|
|
|
|
|
|
onChange={(e) =>
|
|
|
|
|
|
setRequestBodies((prev) => ({
|
|
|
|
|
|
...prev,
|
|
|
|
|
|
[ep.id]: e.target.value,
|
|
|
|
|
|
}))
|
|
|
|
|
|
}
|
|
|
|
|
|
rows={5}
|
|
|
|
|
|
className="w-full px-2 py-1.5 text-xs border border-slate-300 rounded font-mono focus:ring-1 focus:ring-blue-500 bg-white"
|
|
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
|
|
{/* Test button */}
|
|
|
|
|
|
<div className="flex items-center gap-2">
|
|
|
|
|
|
<button
|
|
|
|
|
|
onClick={() => testEndpoint(ep)}
|
|
|
|
|
|
disabled={loadingEndpoints.has(ep.id)}
|
|
|
|
|
|
className="flex items-center gap-2 bg-blue-600 text-white px-4 py-1.5 text-sm rounded-lg hover:bg-blue-700 disabled:opacity-50 transition-colors"
|
|
|
|
|
|
>
|
|
|
|
|
|
{loadingEndpoints.has(ep.id) ? (
|
|
|
|
|
|
<FaSyncAlt className="animate-spin" />
|
|
|
|
|
|
) : (
|
|
|
|
|
|
<FaPaperPlane />
|
|
|
|
|
|
)}
|
|
|
|
|
|
{loadingEndpoints.has(ep.id) ? 'Gonderiliyor...' : 'Test Et'}
|
|
|
|
|
|
</button>
|
|
|
|
|
|
{testResult && (
|
|
|
|
|
|
<button
|
|
|
|
|
|
onClick={() =>
|
|
|
|
|
|
setTestResults((prev) => {
|
|
|
|
|
|
const n = { ...prev }
|
|
|
|
|
|
delete n[ep.id]
|
|
|
|
|
|
return n
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
className="text-xs text-slate-500 hover:text-slate-700 px-2 py-1.5"
|
|
|
|
|
|
>
|
|
|
|
|
|
Temizle
|
|
|
|
|
|
</button>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
{/* Test result */}
|
|
|
|
|
|
{testResult && (
|
|
|
|
|
|
<div
|
|
|
|
|
|
className={`rounded-lg border p-3 ${
|
|
|
|
|
|
testResult.success
|
|
|
|
|
|
? 'border-green-200 bg-green-50'
|
|
|
|
|
|
: 'border-red-200 bg-red-50'
|
|
|
|
|
|
}`}
|
|
|
|
|
|
>
|
|
|
|
|
|
<div className="flex items-center gap-2 mb-2">
|
|
|
|
|
|
{testResult.success ? (
|
|
|
|
|
|
<FaCheckCircle className="text-green-500" />
|
|
|
|
|
|
) : (
|
|
|
|
|
|
<FaExclamationCircle className="text-red-500" />
|
|
|
|
|
|
)}
|
|
|
|
|
|
<span className="text-sm font-medium">
|
|
|
|
|
|
HTTP {testResult.status}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
<span className="text-xs text-slate-500 ml-auto">
|
|
|
|
|
|
{new Date(testResult.timestamp).toLocaleTimeString()}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className="relative">
|
|
|
|
|
|
<pre className="text-xs bg-white border border-slate-200 rounded p-2 overflow-x-auto max-h-48">
|
|
|
|
|
|
{JSON.stringify(
|
|
|
|
|
|
testResult.success ? testResult.data : testResult.error,
|
|
|
|
|
|
null,
|
|
|
|
|
|
2,
|
|
|
|
|
|
)}
|
|
|
|
|
|
</pre>
|
|
|
|
|
|
<button
|
|
|
|
|
|
onClick={() =>
|
|
|
|
|
|
navigator.clipboard.writeText(
|
|
|
|
|
|
JSON.stringify(
|
|
|
|
|
|
testResult.success ? testResult.data : testResult.error,
|
|
|
|
|
|
null,
|
|
|
|
|
|
2,
|
|
|
|
|
|
),
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
className="absolute top-1.5 right-1.5 p-1 text-slate-400 hover:text-slate-700"
|
|
|
|
|
|
>
|
|
|
|
|
|
<FaCopy className="text-xs" />
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
|
|
{/* C# Code Preview */}
|
|
|
|
|
|
{ep.csharpCode && (
|
|
|
|
|
|
<div>
|
|
|
|
|
|
<div className="flex items-center justify-between mb-1">
|
|
|
|
|
|
<p className="text-xs font-semibold text-slate-600">C# Kodu</p>
|
|
|
|
|
|
<button
|
|
|
|
|
|
onClick={() => navigator.clipboard.writeText(ep.csharpCode)}
|
|
|
|
|
|
className="text-xs text-slate-400 hover:text-slate-700 flex items-center gap-1"
|
|
|
|
|
|
>
|
|
|
|
|
|
<FaCopy /> Kopyala
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<pre className="text-xs bg-slate-800 text-green-300 rounded-lg p-3 overflow-x-auto max-h-48 font-mono">
|
|
|
|
|
|
{ep.csharpCode}
|
|
|
|
|
|
</pre>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)
|
|
|
|
|
|
})}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
{/* Footer info */}
|
|
|
|
|
|
{selectedTableEndpoints.length > 0 && (
|
|
|
|
|
|
<div className="border-t border-slate-200 px-2 py-1 bg-slate-50 flex items-center gap-4 text-xs text-slate-500">
|
|
|
|
|
|
<span className="flex items-center gap-1">
|
|
|
|
|
|
<FaCheckCircle className="text-green-400" />
|
|
|
|
|
|
{selectedTableEndpoints.filter((e) => e.isActive).length} aktif
|
|
|
|
|
|
</span>
|
|
|
|
|
|
<span>{selectedTableEndpoints.filter((e) => !e.isActive).length} devre disi</span>
|
|
|
|
|
|
<span className="ml-auto">
|
|
|
|
|
|
5 endpoint: GetList, GetById, Create, Update, Delete
|
|
|
|
|
|
</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export default CrudEndpointManager
|