sozsoft-platform/ui/src/views/developerKit/CrudEndpointManager.tsx

1037 lines
45 KiB
TypeScript
Raw Normal View History

import React, { useCallback, useEffect, useMemo, useState } from 'react'
2026-03-01 20:43:25 +00:00
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'
2026-05-12 12:49:25 +00:00
import Widget from '@/components/common/Widget'
2026-03-01 20:43:25 +00:00
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'
2026-03-01 20:43:25 +00:00
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'
2026-03-01 20:43:25 +00:00
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<string, string> = {
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<CrudFilter, string> = {
all: '!bg-blue-500 text-white',
with: '!bg-green-500 text-white',
without: '!bg-slate-500 text-white',
2026-03-01 20:43:25 +00:00
}
// 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
)
2026-03-01 20:43:25 +00:00
}
const needsBody = (ep: CrudEndpoint) =>
ep.operationType === 'Create' || ep.operationType === 'Update'
2026-03-01 20:43:25 +00:00
const CrudEndpointManager: React.FC = () => {
const { translate } = useLocalization()
// Endpoints
2026-03-01 20:43:25 +00:00
const [generatedEndpoints, setGeneratedEndpoints] = useState<CrudEndpoint[]>([])
// 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<CrudFilter>('all')
2026-03-01 20:43:25 +00:00
// Endpoint management state
const [generatingFor, setGeneratingFor] = useState<string | null>(null)
const [deletingAll, setDeletingAll] = useState<string | null>(null)
const [confirmDeleteTable, setConfirmDeleteTable] = useState<DatabaseTableDto | null>(null)
2026-03-01 20:43:25 +00:00
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>>({})
const pushError = useCallback((message: string) => {
toast.push(
<Notification type="danger" duration={3000}>
{message}
</Notification>,
)
}, [])
const pushSuccess = useCallback((message: string) => {
toast.push(
<Notification type="success" duration={2000}>
{message}
</Notification>,
)
}, [])
// Load endpoints + data sources on mount
2026-03-01 20:43:25 +00:00
useEffect(() => {
let cancelled = false
developerKitService
.getGeneratedListEndpoints()
.then((res) => {
if (!cancelled) setGeneratedEndpoints(res.items || [])
})
.catch((err) => {
if (!cancelled) pushError(getErrorMessage(err, translate('::App.Platform.ErrorLoading')))
})
2026-03-01 20:43:25 +00:00
getDataSources()
.then((res) => {
if (cancelled) return
2026-03-01 20:43:25 +00:00
const items = res.data.items || []
setDataSources(items)
setSelectedDataSource((current) => current ?? items[0]?.code ?? null)
2026-03-01 20:43:25 +00:00
})
.catch((err) => {
if (!cancelled)
pushError(getErrorMessage(err, translate('::App.Platform.FailedtoloadDatasources')))
})
return () => {
cancelled = true
}
}, [pushError, translate])
2026-03-01 20:43:25 +00:00
// Load tables when datasource changes
useEffect(() => {
if (!selectedDataSource) return
let cancelled = false
2026-03-01 20:43:25 +00:00
setLoadingTables(true)
setDbTables([])
setSelectedTable(null)
2026-03-01 20:43:25 +00:00
sqlObjectManagerService
.getAllObjects(selectedDataSource)
.then((res) => {
if (!cancelled) setDbTables(res.data.tables || [])
})
.catch((err) => {
if (!cancelled)
pushError(getErrorMessage(err, translate('::App.Platform.FailedToLoadObjects')))
2026-03-01 20:43:25 +00:00
})
.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<string, CrudEndpoint[]>()
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])
2026-03-01 20:43:25 +00:00
const getEndpointsForTable = useCallback(
(tableName: string): CrudEndpoint[] =>
endpointsByEntity.get(toPascalCase(tableName)) ?? EMPTY_ENDPOINTS,
[endpointsByEntity],
2026-03-01 20:43:25 +00:00
)
// fullName -> { total, active }
const tableStats = useMemo(() => {
const map = new Map<string, TableStat>()
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),
})
2026-03-01 20:43:25 +00:00
}
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<Record<string, DatabaseTableDto[]>>((acc, t) => {
const schema = t.schemaName || 'dbo'
;(acc[schema] ??= []).push(t)
return acc
}, {}),
[filteredTables],
)
const filterLabels = useMemo<Record<CrudFilter, string>>(
() => ({
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],
)
2026-03-01 20:43:25 +00:00
// 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],
)
2026-03-01 20:43:25 +00:00
// 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],
)
2026-03-01 20:43:25 +00:00
// 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)
2026-03-01 20:43:25 +00:00
})
const queryParams = Object.fromEntries(
params.filter((p) => p.type === 'query').map((p) => [p.name, p.value]),
2026-03-01 20:43:25 +00:00
)
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
2026-03-01 20:43:25 +00:00
}
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
})
2026-03-01 20:43:25 +00:00
}
},
[getEndpointParameters, getRequestBody],
)
2026-03-01 20:43:25 +00:00
// 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],
)
2026-03-01 20:43:25 +00:00
return (
2026-05-19 20:22:25 +00:00
<div className="flex flex-col h-full gap-4 bg-gray-50 dark:bg-gray-950">
<PageTitle title={translate('::App.DeveloperKit.CrudEndpoints')} />
2026-03-01 20:43:25 +00:00
{/* Stats Row */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 mt-2">
2026-05-12 12:49:25 +00:00
<Widget
title={translate('::App.DeveloperKitCrudEndpoints.TotalTables')}
2026-05-12 12:49:25 +00:00
value={dbTables.length}
color="blue"
icon="FaDatabase"
valueClassName="text-3xl"
subTitle={translate('::App.DeveloperKitCrudEndpoints.TotalTablesDescription')}
2026-05-12 12:49:25 +00:00
/>
<Widget
title={translate('::App.DeveloperKitCrudEndpoints.EndpointInstalled')}
value={withCrudCount}
2026-05-12 12:49:25 +00:00
color="green"
icon="FaCheckCircle"
valueClassName="text-3xl"
subTitle={translate('::App.DeveloperKitCrudEndpoints.EndpointInstalledDescription')}
2026-05-12 12:49:25 +00:00
/>
<Widget
title={translate('::App.DeveloperKitCrudEndpoints.ActiveEndpoint')}
2026-05-12 12:49:25 +00:00
value={totalActiveEndpoints}
color="teal"
icon="FaBolt"
valueClassName="text-3xl"
subTitle={translate('::App.DeveloperKitCrudEndpoints.ActiveEndpointDescription')}
2026-05-12 12:49:25 +00:00
/>
<Widget
title={translate('::App.Platform.DataSource')}
2026-05-12 12:49:25 +00:00
value={dataSources.length}
color="purple"
icon="FaServer"
valueClassName="text-3xl"
subTitle={translate('::App.DeveloperKitCrudEndpoints.DataSourceDescription')}
2026-05-12 12:49:25 +00:00
/>
2026-03-01 20:43:25 +00:00
</div>
{/* Main two-panel layout */}
2026-03-18 05:36:36 +00:00
<div
2026-05-19 20:22:25 +00:00
className="flex flex-col gap-4 lg:flex-row bg-gray-50 dark:bg-gray-950"
2026-03-18 05:36:36 +00:00
style={{ minHeight: 400, height: 'calc(100vh - 250px)' }}
>
2026-03-01 20:43:25 +00:00
{/* Left Panel: Table List */}
2026-05-19 20:22:25 +00:00
<div className="w-full lg:w-72 lg:flex-shrink-0 flex flex-col bg-white dark:bg-gray-900 rounded-lg border border-slate-200 dark:border-gray-700 overflow-hidden h-[38vh] min-h-[260px] lg:h-auto lg:min-h-0">
2026-03-01 20:43:25 +00:00
{/* DataSource selector */}
2026-05-19 20:22:25 +00:00
<div className="p-3 border-b border-slate-200 dark:border-gray-700 bg-slate-50 dark:bg-gray-800">
2026-03-01 20:43:25 +00:00
<select
value={selectedDataSource || ''}
onChange={(e) => setSelectedDataSource(e.target.value)}
aria-label={translate('::App.Platform.DataSource')}
2026-05-19 20:22:25 +00:00
className="w-full px-2 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"
2026-03-01 20:43:25 +00:00
>
{dataSources.length === 0 && (
<option value="">{translate('::App.Platform.LoadingWithThreeDot')}</option>
)}
2026-03-01 20:43:25 +00:00
{dataSources.map((ds) => (
<option key={ds.id} value={ds.code ?? ''}>
{ds.code}
</option>
))}
</select>
</div>
{/* Search + CRUD filter */}
2026-05-19 20:22:25 +00:00
<div className="p-3 border-b border-slate-200 dark:border-gray-700 space-y-2">
2026-03-01 20:43:25 +00:00
<div className="relative">
<FaSearch className="absolute left-2.5 top-1/2 -translate-y-1/2 text-slate-400 text-xs" />
<input
type="search"
placeholder={translate('::App.DeveloperKitCrudEndpoints.SearchTable')}
2026-03-01 20:43:25 +00:00
value={tableSearch}
onChange={(e) => setTableSearch(e.target.value)}
aria-label={translate('::App.DeveloperKitCrudEndpoints.SearchTable')}
2026-05-19 20:22:25 +00:00
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"
2026-03-01 20:43:25 +00:00
/>
</div>
2026-05-19 20:22:25 +00:00
<div className="flex rounded-lg border border-slate-200 dark:border-gray-700 overflow-hidden text-xs font-medium">
{CRUD_FILTERS.map((f) => (
<Button
type="button"
key={f}
onClick={() => setCrudFilter(f)}
variant="plain"
shape="none"
aria-pressed={crudFilter === f}
className={`flex-1 !h-auto !rounded-none !px-0 !py-1.5 transition-colors ${
crudFilter === f
? FILTER_ACTIVE_CLASS[f]
: '!bg-white text-slate-500 hover:!bg-slate-50 dark:!bg-gray-900 dark:text-gray-400 dark:hover:!bg-gray-800'
}`}
>
{filterLabels[f]}
</Button>
))}
2026-03-01 20:43:25 +00:00
</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">{translate('::App.Platform.LoadingWithThreeDot')}</span>
2026-03-01 20:43:25 +00:00
</div>
) : filteredTables.length === 0 ? (
<div className="p-6 text-center text-slate-400 text-sm">
{selectedDataSource
? translate('::App.Platform.NoTablesAvailable')
: translate('::App.DeveloperKitCrudEndpoints.SelectDataSource')}
2026-03-01 20:43:25 +00:00
</div>
) : (
Object.entries(tablesBySchema).map(([schema, tables]) => (
<div key={schema}>
2026-05-19 20:22:25 +00:00
<div className="px-3 py-1.5 text-xs font-semibold text-slate-400 dark:text-gray-400 uppercase tracking-wide bg-slate-50 dark:bg-gray-800 border-b border-slate-100 dark:border-gray-700 sticky top-0">
2026-03-01 20:43:25 +00:00
{schema}
</div>
{tables.map((table) => {
const stat = tableStats.get(table.fullName)
const active = stat?.active ?? 0
const total = stat?.total ?? 0
2026-03-01 20:43:25 +00:00
const isSelected = selectedTable?.fullName === table.fullName
const hasEndpoints = total > 0
return (
<Button
type="button"
2026-03-01 20:43:25 +00:00
key={table.fullName}
onClick={() => setSelectedTable(table)}
variant="plain"
shape="none"
className={`w-full !h-auto !items-center !justify-between !rounded-none border-b border-slate-50 !px-3 !py-1 text-left transition-colors hover:!bg-slate-50 dark:border-gray-800 dark:hover:!bg-gray-800 ${
isSelected
? '!bg-blue-50 border-l-2 border-l-blue-500 dark:!bg-blue-900/20 dark:border-l-blue-400'
: ''
2026-03-01 20:43:25 +00:00
}`}
>
<span className="flex w-full min-w-0 items-center justify-between gap-2">
<span className="flex min-w-0 items-center gap-2">
<FaTable
className={`flex-shrink-0 text-xs ${
hasEndpoints ? 'text-green-500' : 'text-slate-300'
}`}
/>
<span className="truncate text-sm text-slate-700 dark:text-gray-100">
{table.tableName}
</span>
</span>
<span className="flex flex-shrink-0 items-center gap-1">
{hasEndpoints && (
<span
className={`rounded-full px-1.5 py-0.5 text-xs font-medium ${
active > 0
? 'bg-green-100 text-green-700 dark:bg-green-900/20 dark:text-green-400'
: 'bg-slate-100 text-slate-500 dark:bg-gray-800 dark:text-gray-400'
}`}
>
{active}/{total}
</span>
)}
{isSelected && (
<FaChevronRight className="flex-shrink-0 text-xs text-blue-400" />
)}
2026-03-01 20:43:25 +00:00
</span>
</span>
</Button>
2026-03-01 20:43:25 +00:00
)
})}
</div>
))
)}
</div>
</div>
{/* Right Panel: Endpoint Management */}
2026-05-19 20:22:25 +00:00
<div className="flex-1 min-w-0 flex flex-col bg-white dark:bg-gray-900 rounded-lg border border-slate-200 dark:border-gray-700 overflow-hidden min-h-0">
2026-03-01 20:43:25 +00:00
{!selectedTable ? (
2026-05-19 20:22:25 +00:00
<div className="flex-1 flex flex-col items-center justify-center text-slate-400 dark:text-gray-500 p-8">
<FaDatabase className="text-4xl mb-3 text-slate-200 dark:text-gray-700" />
<p className="text-base font-medium dark:text-gray-300">
{translate('::App.DeveloperKitCrudEndpoints.SelectTablePrompt')}
</p>
2026-05-19 20:22:25 +00:00
<p className="text-sm mt-1 dark:text-gray-400">
{translate('::App.DeveloperKitCrudEndpoints.SelectTableDescription')}
2026-03-01 20:43:25 +00:00
</p>
</div>
) : (
<div className="flex flex-col h-full overflow-hidden">
{/* Table header */}
2026-05-19 20:22:25 +00:00
<div className="flex flex-col gap-3 p-4 border-b border-slate-200 dark:border-gray-700 bg-slate-50 dark:bg-gray-800 sm:flex-row sm:items-center sm:justify-between">
<div className="flex items-center gap-3">
<div className="bg-blue-100 dark:bg-blue-900/20 text-blue-600 dark:text-blue-400 p-2 rounded-lg">
<FaTable />
</div>
<h4 className="text-slate-900 dark:text-gray-100">
{selectedTable.schemaName}.{selectedTable.tableName}
</h4>
</div>
2026-03-18 05:36:36 +00:00
<div className="flex flex-wrap items-center gap-2">
2026-03-01 20:43:25 +00:00
{selectedTableEndpoints.length > 0 && (
<Button
type="button"
onClick={() => setConfirmDeleteTable(selectedTable)}
2026-03-01 20:43:25 +00:00
disabled={deletingAll === selectedTable.fullName}
variant="plain"
shape="none"
icon={
deletingAll === selectedTable.fullName ? (
<FaSyncAlt className="animate-spin" />
) : (
<FaTrash />
)
}
className="!inline-flex !h-auto !items-center !justify-center gap-2 whitespace-nowrap !rounded-lg border border-red-200 !px-3 !py-1.5 text-sm text-red-600 transition-colors hover:!bg-red-50 disabled:opacity-50 dark:border-red-700 dark:hover:!bg-red-900/20"
2026-03-01 20:43:25 +00:00
>
{translate('::App.DeveloperKitCrudEndpoints.DeleteAll')}
</Button>
2026-03-01 20:43:25 +00:00
)}
<Button
type="button"
2026-03-01 20:43:25 +00:00
onClick={() => handleGenerate(selectedTable)}
disabled={
generatingFor === selectedTable.fullName ||
deletingAll === selectedTable.fullName
}
variant="solid"
icon={
generatingFor === selectedTable.fullName ? (
<FaSyncAlt className="animate-spin" />
) : (
<FaBolt />
)
}
className="!inline-flex !h-auto !items-center !justify-center gap-2 whitespace-nowrap !rounded-lg !px-4 !py-1.5 text-sm font-medium text-white transition-colors hover:!bg-blue-700 disabled:opacity-50 dark:!bg-blue-700 dark:hover:!bg-blue-800"
2026-03-01 20:43:25 +00:00
>
{selectedTableEndpoints.length > 0
? translate('::App.DeveloperKitCrudEndpoints.Regenerate')
: translate('::App.DeveloperKitCrudEndpoints.CreateCrudEndpoint')}
</Button>
2026-03-01 20:43:25 +00:00
</div>
</div>
{/* Endpoints list */}
<div className="flex-1 overflow-y-auto p-4">
{selectedTableEndpoints.length === 0 ? (
2026-05-19 20:22:25 +00:00
<div className="flex flex-col items-center justify-center py-16 text-slate-400 dark:text-gray-500">
<FaBolt className="text-3xl mb-3 text-slate-200 dark:text-gray-700" />
<p className="font-medium dark:text-gray-300">
{translate('::App.DeveloperKitCrudEndpoints.NoEndpointsYet')}
</p>
2026-05-19 20:22:25 +00:00
<p className="text-sm mt-1 dark:text-gray-400">
{translate('::App.DeveloperKitCrudEndpoints.ClickToCreate')}
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]
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,
)
: ''
2026-03-01 20:43:25 +00:00
return (
<div
key={ep.id}
2026-05-19 20:22:25 +00:00
className="border border-slate-200 dark:border-gray-700 rounded-lg overflow-hidden"
2026-03-01 20:43:25 +00:00
>
{/* Endpoint row */}
2026-05-19 20:22:25 +00:00
<div className="flex items-center gap-3 p-3 bg-white dark:bg-gray-900 hover:bg-slate-50 dark:hover:bg-gray-800 transition-colors">
2026-03-01 20:43:25 +00:00
{/* Toggle */}
<Button
type="button"
2026-03-01 20:43:25 +00:00
onClick={() => handleToggle(ep.id)}
disabled={togglingId === ep.id}
title={
ep.isActive
? translate('::App.DeveloperKitCrudEndpoints.Disable')
: translate('::App.DeveloperKitCrudEndpoints.Enable')
}
variant="plain"
shape="none"
className={`!h-8 !w-8 flex-shrink-0 !rounded !p-0 text-xl transition-colors hover:!bg-transparent ${
2026-03-01 20:43:25 +00:00
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>
2026-03-01 20:43:25 +00:00
{/* Method badge */}
<span
className={`flex-shrink-0 px-2 py-0.5 text-xs font-bold rounded border ${
METHOD_COLOR[ep.method] ||
2026-05-19 20:22:25 +00:00
'bg-slate-100 dark:bg-gray-800 text-slate-700 dark:text-gray-300 border-slate-200 dark:border-gray-700'
2026-03-01 20:43:25 +00:00
}`}
>
{ep.method}
</span>
{/* Operation */}
<div className="flex-1 min-w-0">
2026-05-19 20:22:25 +00:00
<span className="font-medium text-slate-800 dark:text-gray-100 text-sm">
2026-03-01 20:43:25 +00:00
{ep.operationType}
</span>
2026-05-19 20:22:25 +00:00
<code className="ml-3 text-xs text-slate-500 dark:text-gray-400 bg-slate-100 dark:bg-gray-800 px-2 py-0.5 rounded">
2026-03-01 20:43:25 +00:00
{ep.path}
</code>
</div>
{/* Expand */}
<Button
type="button"
2026-03-01 20:43:25 +00:00
onClick={() => setExpandedEndpoint(isExpanded ? null : ep.id)}
variant="plain"
shape="none"
aria-expanded={isExpanded}
className="!h-7 !w-7 !rounded !p-0 text-slate-400 transition-colors hover:!bg-transparent hover:text-slate-700 dark:text-gray-400 dark:hover:text-gray-200"
title={translate('::App.DeveloperKitCrudEndpoints.TestDetails')}
2026-03-01 20:43:25 +00:00
>
{isExpanded ? (
<FaChevronDown className="text-xs" />
) : (
<FaChevronRight className="text-xs" />
)}
</Button>
2026-03-01 20:43:25 +00:00
</div>
{/* Expanded detail + test */}
{isExpanded && (
2026-05-19 20:22:25 +00:00
<div className="border-t border-slate-200 dark:border-gray-700 bg-slate-50 dark:bg-gray-800 p-4 space-y-4">
2026-03-01 20:43:25 +00:00
{/* Parameters */}
{parameters.length > 0 && (
2026-03-01 20:43:25 +00:00
<div>
2026-05-19 20:22:25 +00:00
<p className="text-xs font-semibold text-slate-600 dark:text-gray-300 mb-2">
{translate('::App.DeveloperKitCrudEndpoints.Parameters')}
2026-03-01 20:43:25 +00:00
</p>
<div className="space-y-2">
{parameters.map((param) => (
2026-03-01 20:43:25 +00:00
<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 dark:bg-blue-900/30 dark:text-blue-300'
: 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-300'
2026-03-01 20:43:25 +00:00
}`}
>
{param.name}
</span>
<input
type="text"
value={param.value}
aria-label={param.name}
2026-03-01 20:43:25 +00:00
onChange={(e) =>
setParameterValues((prev) => ({
...prev,
[ep.id]: {
...prev[ep.id],
[param.name]: e.target.value,
},
}))
}
2026-05-19 20:22:25 +00:00
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"
2026-03-01 20:43:25 +00:00
/>
</div>
))}
</div>
</div>
)}
{/* Request body */}
{needsBody(ep) && (
<div>
2026-05-19 20:22:25 +00:00
<p className="text-xs font-semibold text-slate-600 dark:text-gray-300 mb-2">
{translate('::App.DeveloperKitCrudEndpoints.RequestBody')}
2026-03-01 20:43:25 +00:00
</p>
<textarea
value={getRequestBody(ep)}
aria-label={translate(
'::App.DeveloperKitCrudEndpoints.RequestBody',
)}
2026-03-01 20:43:25 +00:00
onChange={(e) =>
setRequestBodies((prev) => ({
...prev,
[ep.id]: e.target.value,
}))
}
rows={5}
spellCheck={false}
2026-05-19 20:22:25 +00:00
className="w-full px-2 py-1.5 text-xs border border-slate-300 dark:border-gray-700 rounded font-mono focus:ring-1 focus:ring-blue-500 bg-white dark:bg-gray-900 text-gray-900 dark:text-gray-100"
2026-03-01 20:43:25 +00:00
/>
</div>
)}
{/* Test button */}
<div className="flex items-center gap-2">
<Button
type="button"
2026-03-01 20:43:25 +00:00
onClick={() => testEndpoint(ep)}
disabled={isTesting}
variant="solid"
icon={
isTesting ? (
<FaSyncAlt className="animate-spin" />
) : (
<FaPaperPlane />
)
}
className="!inline-flex !h-auto !items-center !justify-center gap-2 whitespace-nowrap !rounded-lg !px-4 !py-1.5 text-sm text-white transition-colors hover:!bg-blue-700 disabled:opacity-50"
2026-03-01 20:43:25 +00:00
>
{isTesting
? translate('::App.DeveloperKitEndpoint.SendLoading')
: translate('::App.DeveloperKitCrudEndpoints.Test')}
</Button>
2026-03-01 20:43:25 +00:00
{testResult && (
<Button
type="button"
2026-03-01 20:43:25 +00:00
onClick={() =>
setTestResults((prev) => {
const next = { ...prev }
delete next[ep.id]
return next
2026-03-01 20:43:25 +00:00
})
}
variant="plain"
shape="none"
className="!h-auto !rounded !px-2 !py-1.5 text-xs text-slate-500 hover:!bg-transparent hover:text-slate-700"
2026-03-01 20:43:25 +00:00
>
{translate('::App.Platform.Clear')}
</Button>
2026-03-01 20:43:25 +00:00
)}
</div>
{/* Test result */}
{testResult && (
<div
className={`rounded-lg border p-3 ${
testResult.success
2026-05-19 20:22:25 +00:00
? 'border-green-200 dark:border-green-700 bg-green-50 dark:bg-green-900/20'
: 'border-red-200 dark:border-red-700 bg-red-50 dark:bg-red-900/20'
2026-03-01 20:43:25 +00:00
}`}
>
<div className="flex items-center gap-2 mb-2">
{testResult.success ? (
2026-05-19 20:22:25 +00:00
<FaCheckCircle className="text-green-500 dark:text-green-400" />
2026-03-01 20:43:25 +00:00
) : (
2026-05-19 20:22:25 +00:00
<FaExclamationCircle className="text-red-500 dark:text-red-400" />
2026-03-01 20:43:25 +00:00
)}
<span className="text-sm font-medium">
HTTP {testResult.status}
</span>
2026-05-19 20:22:25 +00:00
<span className="text-xs text-slate-500 dark:text-gray-400 ml-auto">
2026-03-01 20:43:25 +00:00
{new Date(testResult.timestamp).toLocaleTimeString()}
</span>
</div>
<div className="relative">
2026-05-19 20:22:25 +00:00
<pre className="text-xs bg-white dark:bg-gray-900 border border-slate-200 dark:border-gray-700 rounded p-2 overflow-x-auto max-h-48 text-gray-900 dark:text-gray-100">
{resultPayload}
2026-03-01 20:43:25 +00:00
</pre>
<Button
type="button"
onClick={() => copyToClipboard(resultPayload)}
variant="plain"
shape="none"
title={translate('::App.Platform.CopyToClipboard')}
icon={<FaCopy className="text-xs" />}
className="absolute right-1.5 top-1.5 !h-6 !w-6 !rounded !p-0 text-slate-400 hover:!bg-transparent hover:text-slate-700 dark:text-gray-400 dark:hover:text-gray-200"
/>
2026-03-01 20:43:25 +00:00
</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 dark:text-gray-300">
{translate('::App.DeveloperKitCrudEndpoints.CsharpCode')}
</p>
<Button
type="button"
onClick={() => copyToClipboard(ep.csharpCode)}
variant="plain"
shape="none"
icon={<FaCopy />}
className="!inline-flex !h-auto !items-center !justify-center gap-1 whitespace-nowrap !rounded-none !px-0 !py-0 text-xs text-slate-400 hover:!bg-transparent hover:text-slate-700 dark:text-gray-400 dark:hover:text-gray-200"
2026-03-01 20:43:25 +00:00
>
{translate('::App.Platform.Copy')}
</Button>
2026-03-01 20:43:25 +00:00
</div>
2026-05-19 20:22:25 +00:00
<pre className="text-xs bg-slate-800 dark:bg-gray-900 text-green-300 rounded-lg p-3 overflow-x-auto max-h-48 font-mono">
2026-03-01 20:43:25 +00:00
{ep.csharpCode}
</pre>
</div>
)}
</div>
)}
</div>
)
})}
</div>
)}
</div>
{/* Footer info */}
{selectedTableEndpoints.length > 0 && (
2026-05-19 20:22:25 +00:00
<div className="border-t border-slate-200 dark:border-gray-700 px-2 py-1 bg-slate-50 dark:bg-gray-800 flex items-center gap-4 text-xs text-slate-500 dark:text-gray-400">
2026-03-01 20:43:25 +00:00
<span className="flex items-center gap-1">
<FaCheckCircle className="text-green-400" />
{selectedActiveCount} {translate('::App.Listform.ListformField.IsActive')}
</span>
<span>
{selectedTableEndpoints.length - selectedActiveCount}{' '}
{translate('::App.DeveloperKitCrudEndpoints.InactiveCount')}
2026-03-01 20:43:25 +00:00
</span>
<span className="ml-auto">
{translate('::App.DeveloperKitCrudEndpoints.EndpointSummary')}
2026-03-01 20:43:25 +00:00
</span>
</div>
)}
</div>
)}
</div>
</div>
<ConfirmDialog
isOpen={!!confirmDeleteTable}
type="danger"
title={translate('::App.Platform.DeleteConfirmation')}
confirmText={translate('::App.DeveloperKitCrudEndpoints.DeleteAll')}
cancelText={translate('::App.Platform.Cancel')}
confirmButtonColor="red-600"
onCancel={() => setConfirmDeleteTable(null)}
onConfirm={() => {
const table = confirmDeleteTable
setConfirmDeleteTable(null)
if (table) handleDeleteAll(table)
}}
>
<p>{translate('::App.Platform.DeleteConfirmationMessage')}</p>
</ConfirmDialog>
2026-03-01 20:43:25 +00:00
</div>
)
}
export default CrudEndpointManager