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

3549 lines
130 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 React, { useState, useCallback, useMemo, useEffect, useRef } from 'react'
import { createPortal } from 'react-dom'
import { Button, Checkbox, Dialog, Notification, Select, toast } from '@/components/ui'
import {
FaPlus,
FaTrash,
FaArrowUp,
FaArrowDown,
FaTable,
FaCloudUploadAlt,
FaCheck,
FaLink,
FaEdit,
FaTimes,
FaArrowRight,
FaChevronDown,
FaChevronRight,
FaKey,
FaLayerGroup,
FaRegClipboard,
FaUsers,
FaHistory,
FaProjectDiagram,
FaTasks,
FaChartBar,
FaRegCalendarAlt,
} from 'react-icons/fa'
import { sqlObjectManagerService } from '@/services/sql-query-manager.service'
import { getMenus } from '@/services/menu.service'
import { useLocalization } from '@/utils/hooks/useLocalization'
import { CascadeBehavior, SqlTableRelation, RelationshipType } from '@/proxy/developerKit/models'
import { useNavigationIcons } from '@/proxy/menus/navigation-icon.config'
import { MenuItem } from '@/proxy/menus/menu'
import {
MenuTreeNode,
buildMenuTree,
filterNonLinkNodes,
} from '@/views/admin/listForm/wizard/menuTree'
import { MenuAddDialog } from '../shared/MenuAddDialog'
import { useStoreActions, useStoreState } from '@/store'
import { usePermission } from '@/utils/hooks/usePermission'
import { developerKitService } from '@/services/developerKit.service'
import Input from '@/components/ui/Input'
import {
CRUD_ENDPOINT_PERMISSION,
CRUD_OPERATION_TYPES,
type CrudOperationType,
getErrorMessage,
getOperationMeta,
toEntityName,
} from './CrudEndpointDialog'
// ─── Types ────────────────────────────────────────────────────────────────────
type SqlDataType =
| 'nvarchar'
| 'nvarchar(MAX)'
| 'int'
| 'bigint'
| 'decimal'
| 'float'
| 'bit'
| 'datetime'
| 'datetime2'
| 'date'
| 'uniqueidentifier'
| 'money'
interface ColumnDefinition {
id: string
columnName: string
dataType: SqlDataType
maxLength: string
isNullable: boolean
defaultValue: string
description: string
}
interface TableSettings {
menuValue: string
menuPrefix: string
entityName: string
tableName: string
}
interface TableDesignerDialogProps {
isOpen: boolean
onClose: () => void
dataSource: string | null
onDeployed?: (table: { schemaName: string; tableName: string }) => void | Promise<void>
initialTableData?: { schemaName: string; tableName: string } | null
/**
* Henuz var olmayan bir tablonun adi. Olusturma modu korunur; yalnizca ad, modul ve entity
* alanlari on doldurulur. `initialTableData` verildiginde yok sayilir.
*/
initialTableName?: string | null
}
type IndexType = 'PrimaryKey' | 'UniqueKey' | 'Index'
interface IndexColumnEntry {
columnName: string
order: 'ASC' | 'DESC'
}
interface TableIndex {
id: string
indexName: string
indexType: IndexType
isClustered: boolean
columns: IndexColumnEntry[]
description: string
}
// ─── Constants ────────────────────────────────────────────────────────────────
const REL_TYPES: { value: RelationshipType; label: string; desc: string }[] = [
{ value: 'OneToMany', label: '1 → N', desc: 'App.DeveloperKitTableDesigner.OneToMany' },
{ value: 'OneToOne', label: '1 → 1', desc: 'App.DeveloperKitTableDesigner.OneToOne' },
]
const CASCADE_OPTIONS: { value: CascadeBehavior; label: string }[] = [
{ value: 'NoAction', label: 'No Action' },
{ value: 'Cascade', label: 'Cascade' },
{ value: 'SetNull', label: 'Set Null' },
{ value: 'Restrict', label: 'Restrict' },
]
const EMPTY_FK: Omit<SqlTableRelation, 'id'> = {
relationshipType: 'OneToMany',
fkColumnName: '',
referencedTable: '',
referencedColumn: 'Id',
cascadeDelete: 'NoAction',
cascadeUpdate: 'Cascade',
isRequired: false,
description: '',
}
const EMPTY_INDEX: Omit<TableIndex, 'id'> = {
indexName: '',
indexType: 'Index',
isClustered: false,
columns: [],
description: '',
}
const INDEX_TYPES: { value: IndexType; label: string; desc: string }[] = [
{
value: 'PrimaryKey',
label: 'Primary Key',
desc: 'App.SqlQueryManager.IndexTypePrimaryKeyDesc',
},
{ value: 'UniqueKey', label: 'Unique Key', desc: 'App.SqlQueryManager.IndexTypeUniqueKeyDesc' },
{ value: 'Index', label: 'Index', desc: 'App.SqlQueryManager.IndexTypeIndexDesc' },
]
const DATA_TYPES: { value: SqlDataType; label: string }[] = [
{ value: 'nvarchar', label: 'String (nvarchar)' },
{ value: 'nvarchar(MAX)', label: 'Text (nvarchar MAX)' },
{ value: 'int', label: 'Int (int)' },
{ value: 'bigint', label: 'Long (bigint)' },
{ value: 'decimal', label: 'Decimal (decimal 18,4)' },
{ value: 'float', label: 'Float (float)' },
{ value: 'bit', label: 'Bool (bit)' },
{ value: 'datetime', label: 'DateTime (datetime)' },
{ value: 'datetime2', label: 'DateTime (datetime2)' },
{ value: 'date', label: 'Date (date)' },
{ value: 'uniqueidentifier', label: 'Guid (uniqueidentifier)' },
{ value: 'money', label: 'Money (money)' },
]
const FULL_AUDIT_COLUMNS: ColumnDefinition[] = [
{
id: '__Id',
columnName: 'Id',
dataType: 'uniqueidentifier',
maxLength: '',
isNullable: false,
defaultValue: 'NEWID()',
description: 'Primary key',
},
{
id: '__CreationTime',
columnName: 'CreationTime',
dataType: 'datetime2',
maxLength: '',
isNullable: false,
defaultValue: 'GETUTCDATE()',
description: 'Record creation time',
},
{
id: '__CreatorId',
columnName: 'CreatorId',
dataType: 'uniqueidentifier',
maxLength: '',
isNullable: true,
defaultValue: '',
description: 'Creator user ID',
},
{
id: '__LastModificationTime',
columnName: 'LastModificationTime',
dataType: 'datetime2',
maxLength: '',
isNullable: true,
defaultValue: '',
description: 'Last modification time',
},
{
id: '__LastModifierId',
columnName: 'LastModifierId',
dataType: 'uniqueidentifier',
maxLength: '',
isNullable: true,
defaultValue: '',
description: 'Last modifier user ID',
},
{
id: '__IsDeleted',
columnName: 'IsDeleted',
dataType: 'bit',
maxLength: '',
isNullable: false,
defaultValue: '0',
description: 'Soft delete flag',
},
{
id: '__DeletionTime',
columnName: 'DeletionTime',
dataType: 'datetime2',
maxLength: '',
isNullable: true,
defaultValue: '',
description: 'Deletion time',
},
{
id: '__DeleterId',
columnName: 'DeleterId',
dataType: 'uniqueidentifier',
maxLength: '',
isNullable: true,
defaultValue: '',
description: 'Deleter user ID',
},
]
const TENANT_COLUMN: ColumnDefinition = {
id: '__TenantId',
columnName: 'TenantId',
dataType: 'uniqueidentifier',
maxLength: '',
isNullable: true,
defaultValue: '',
description: 'Tenant ID for multi-tenancy',
}
const WORKFLOW_COLUMNS: ColumnDefinition[] = [
{
id: '__WorkflowId',
columnName: 'Id',
dataType: 'uniqueidentifier',
maxLength: '',
isNullable: false,
defaultValue: 'NEWID()',
description: 'Workflow primary key',
},
{
id: '__UserId',
columnName: 'UserName',
dataType: 'nvarchar',
maxLength: '256',
isNullable: true,
defaultValue: '',
description: 'Workflow approval user name',
},
{
id: '__Status',
columnName: 'Status',
dataType: 'nvarchar',
maxLength: '50',
isNullable: true,
defaultValue: '',
description: 'Workflow approval status',
},
{
id: '__Date',
columnName: 'Date',
dataType: 'datetime',
maxLength: '',
isNullable: true,
defaultValue: '',
description: 'Workflow approval date',
},
{
id: '__Description',
columnName: 'Description',
dataType: 'nvarchar',
maxLength: '200',
isNullable: true,
defaultValue: '',
description: 'Workflow approval description',
},
]
const TODO_COLUMNS: ColumnDefinition[] = [
{
id: '__TodoId',
columnName: 'Id',
dataType: 'uniqueidentifier',
maxLength: '',
isNullable: false,
defaultValue: 'NEWID()',
description: 'Todo primary key',
},
{
id: '__TodoTitle',
columnName: 'Title',
dataType: 'nvarchar',
maxLength: '300',
isNullable: false,
defaultValue: '',
description: 'Todo title',
},
{
id: '__TodoStatus',
columnName: 'Status',
dataType: 'nvarchar',
maxLength: '50',
isNullable: false,
defaultValue: "N'Backlog'",
description: 'Todo board column/status',
},
{
id: '__TodoDescription',
columnName: 'Description',
dataType: 'nvarchar(MAX)',
maxLength: '',
isNullable: true,
defaultValue: '',
description: 'Todo description',
},
{
id: '__TodoDueDate',
columnName: 'DueDate',
dataType: 'datetime2',
maxLength: '',
isNullable: true,
defaultValue: '',
description: 'Todo deadline',
},
{
id: '__TodoTags',
columnName: 'Tags',
dataType: 'nvarchar',
maxLength: '500',
isNullable: true,
defaultValue: '',
description: 'Comma-separated todo tags',
},
{
id: '__TodoAssignees',
columnName: 'Assignees',
dataType: 'nvarchar',
maxLength: '500',
isNullable: true,
defaultValue: '',
description: 'Comma-separated todo assignees',
},
{
id: '__TodoPriority',
columnName: 'Priority',
dataType: 'nvarchar',
maxLength: '50',
isNullable: true,
defaultValue: '',
description: 'Todo priority',
},
{
id: '__TodoCompleted',
columnName: 'Completed',
dataType: 'bit',
maxLength: '',
isNullable: false,
defaultValue: '0',
description: 'Todo completion flag',
},
{
id: '__TodoSortOrder',
columnName: 'SortOrder',
dataType: 'int',
maxLength: '',
isNullable: false,
defaultValue: '0',
description: 'Todo order inside its board column',
},
{
id: '__TodoTimesheets',
columnName: 'Timesheets',
dataType: 'nvarchar(MAX)',
maxLength: '',
isNullable: true,
defaultValue: '',
description: 'Todo timesheet rows as JSON array',
},
{
id: '__TodoSubTasks',
columnName: 'SubTasks',
dataType: 'nvarchar(MAX)',
maxLength: '',
isNullable: true,
defaultValue: '',
description: 'Todo sub task rows as JSON array',
},
]
const GANTT_COLUMNS: ColumnDefinition[] = [
{
id: '__GanttId',
columnName: 'Id',
dataType: 'uniqueidentifier',
maxLength: '',
isNullable: false,
defaultValue: 'NEWID()',
description: 'Gantt task primary key',
},
{
id: '__GanttTitle',
columnName: 'Title',
dataType: 'nvarchar',
maxLength: '300',
isNullable: false,
defaultValue: '',
description: 'Gantt task title',
},
{
id: '__GanttParentId',
columnName: 'ParentId',
dataType: 'uniqueidentifier',
maxLength: '',
isNullable: true,
defaultValue: '',
description: 'Parent Gantt task ID',
},
{
id: '__GanttStartDate',
columnName: 'StartDate',
dataType: 'datetime2',
maxLength: '',
isNullable: false,
defaultValue: '',
description: 'Gantt task start date',
},
{
id: '__GanttEndDate',
columnName: 'EndDate',
dataType: 'datetime2',
maxLength: '',
isNullable: false,
defaultValue: '',
description: 'Gantt task end date',
},
{
id: '__GanttProgress',
columnName: 'Progress',
dataType: 'int',
maxLength: '',
isNullable: false,
defaultValue: '0',
description: 'Gantt task completion percentage',
},
]
const SCHEDULER_COLUMNS: ColumnDefinition[] = [
{
id: '__SchedulerId',
columnName: 'Id',
dataType: 'uniqueidentifier',
maxLength: '',
isNullable: false,
defaultValue: 'NEWID()',
description: 'Scheduler appointment primary key',
},
{
id: '__SchedulerUserName',
columnName: 'UserName',
dataType: 'nvarchar',
maxLength: '256',
isNullable: false,
defaultValue: '',
description: 'Scheduler appointment userName',
},
{
id: '__SchedulerText',
columnName: 'Text',
dataType: 'nvarchar',
maxLength: '512',
isNullable: false,
defaultValue: '',
description: 'Scheduler appointment text',
},
{
id: '__SchedulerDescription',
columnName: 'Description',
dataType: 'nvarchar(MAX)',
maxLength: '',
isNullable: false,
defaultValue: '',
description: 'Scheduler appointment description',
},
{
id: '__SchedulerStartDate',
columnName: 'StartDate',
dataType: 'datetime2',
maxLength: '',
isNullable: false,
defaultValue: '',
description: 'Scheduler appointment start date',
},
{
id: '__SchedulerEndDate',
columnName: 'EndDate',
dataType: 'datetime2',
maxLength: '',
isNullable: false,
defaultValue: '',
description: 'Scheduler appointment end date',
},
{
id: '__SchedulerAllDay',
columnName: 'AllDay',
dataType: 'bit',
maxLength: '',
isNullable: false,
defaultValue: '0',
description: 'Scheduler all-day appointment flag',
},
{
id: '__SchedulerRecurrenceRule',
columnName: 'RecurrenceRule',
dataType: 'nvarchar',
maxLength: '512',
isNullable: true,
defaultValue: '',
description: 'Scheduler recurrence rule',
},
{
id: '__SchedulerRecurrenceException',
columnName: 'RecurrenceException',
dataType: 'nvarchar(MAX)',
maxLength: '',
isNullable: true,
defaultValue: '',
description: 'Scheduler recurrence exceptions',
},
]
// ─── T-SQL Generator ──────────────────────────────────────────────────────────
function colToSqlLine(col: ColumnDefinition, addComma = true): string {
let typeSql: string
switch (col.dataType) {
case 'nvarchar':
typeSql = `nvarchar(${col.maxLength || '100'})`
break
case 'nvarchar(MAX)':
typeSql = 'nvarchar(MAX)'
break
case 'decimal':
typeSql = 'decimal(18, 4)'
break
default:
typeSql = col.dataType
}
const nullPart = col.isNullable ? 'NULL' : 'NOT NULL'
const defaultPart = col.defaultValue ? ` DEFAULT ${col.defaultValue}` : ''
return ` [${col.columnName}] ${typeSql} ${nullPart}${defaultPart}${addComma ? ',' : ''}`
}
function buildCreateIndexIfNotExistsSql(
fullTableName: string,
indexName: string,
isClustered: boolean,
colsSql: string,
): string[] {
const escapedFullTableName = fullTableName.replace(/'/g, "''")
const escapedIndexName = indexName.replace(/'/g, "''")
return [
`IF INDEXPROPERTY(OBJECT_ID(N'${escapedFullTableName}'), '${escapedIndexName}', 'IndexID') IS NULL`,
`BEGIN`,
` CREATE ${isClustered ? 'CLUSTERED ' : ''}INDEX [${indexName}] ON ${fullTableName} (${colsSql});`,
`END`,
]
}
function generateCreateTableSql(
columns: ColumnDefinition[],
settings: TableSettings,
relationships: SqlTableRelation[],
indexes: TableIndex[],
): string {
const tableName = settings.tableName || 'NewTable'
const fullTableName = `[dbo].[${tableName}]`
const escapedFullTableName = fullTableName.replace(/'/g, "''")
const userCols = columns.filter((c) => c.columnName.trim())
const bodyLines = userCols.map((c) => colToSqlLine(c, false))
// Inline PRIMARY KEY constraint inside CREATE TABLE
const pkIndex = indexes.find((idx) => idx.indexType === 'PrimaryKey' && idx.columns.length > 0)
const pkConstraintLines: string[] = []
if (pkIndex) {
const clustered = pkIndex.isClustered ? 'CLUSTERED' : 'NONCLUSTERED'
const colsSql = pkIndex.columns.map((c) => ` [${c.columnName}] ${c.order}`).join(',\n')
pkConstraintLines.push(
` CONSTRAINT [${pkIndex.indexName}] PRIMARY KEY ${clustered}`,
` (`,
colsSql,
` )WITH (PAD_INDEX = OFF, STATISTICS_NORECOMPUTE = OFF, IGNORE_DUP_KEY = OFF, ALLOW_ROW_LOCKS = ON, ALLOW_PAGE_LOCKS = ON, OPTIMIZE_FOR_SEQUENTIAL_KEY = OFF) ON [PRIMARY]`,
)
}
// Build all column + pk lines with commas
const allInnerLines: string[] = []
for (let i = 0; i < bodyLines.length; i++) {
allInnerLines.push(bodyLines[i] + ',')
}
if (pkConstraintLines.length > 0) {
for (let i = 0; i < pkConstraintLines.length; i++) {
allInnerLines.push(pkConstraintLines[i])
}
} else if (allInnerLines.length > 0) {
// Remove trailing comma from last column if no PK
allInnerLines[allInnerLines.length - 1] = allInnerLines[allInnerLines.length - 1].replace(
/,$/,
'',
)
}
// Non-PK index lines (UNIQUE, regular INDEX) — outside CREATE TABLE
const extraIndexLines: string[] = []
for (const idx of indexes) {
if (idx.columns.length === 0 || idx.indexType === 'PrimaryKey') continue
const clustered = idx.isClustered ? 'CLUSTERED' : 'NONCLUSTERED'
const colsSql = idx.columns.map((c) => `[${c.columnName}] ${c.order}`).join(', ')
extraIndexLines.push('')
if (idx.indexType === 'UniqueKey') {
extraIndexLines.push(`-- 🔒 Unique Key: [${idx.indexName}]`)
extraIndexLines.push(`ALTER TABLE ${fullTableName}`)
extraIndexLines.push(
` ADD CONSTRAINT [${idx.indexName}] UNIQUE ${clustered} (${colsSql});`,
)
} else {
extraIndexLines.push(`-- 📋 Index: [${idx.indexName}]`)
extraIndexLines.push(
...buildCreateIndexIfNotExistsSql(fullTableName, idx.indexName, idx.isClustered, colsSql),
)
}
}
// FK constraint lines
const fkLines: string[] = []
for (const rel of relationships) {
if (!rel.fkColumnName.trim() || !rel.referencedTable.trim()) continue
const constraintName = `FK_${tableName}_${rel.fkColumnName}`
const cascadeDelete =
rel.cascadeDelete === 'NoAction'
? 'NO ACTION'
: rel.cascadeDelete
.replace(/([A-Z])/g, ' $1')
.trim()
.toUpperCase()
const cascadeUpdate =
rel.cascadeUpdate === 'NoAction'
? 'NO ACTION'
: rel.cascadeUpdate
.replace(/([A-Z])/g, ' $1')
.trim()
.toUpperCase()
fkLines.push('')
fkLines.push(`ALTER TABLE ${fullTableName}`)
fkLines.push(` ADD CONSTRAINT [${constraintName}]`)
fkLines.push(` FOREIGN KEY ([${rel.fkColumnName}])`)
fkLines.push(
` REFERENCES [dbo].[${rel.referencedTable}] ([${rel.referencedColumn || 'Id'}])`,
)
fkLines.push(` ON DELETE ${cascadeDelete}`)
fkLines.push(` ON UPDATE ${cascadeUpdate};`)
}
const lines: string[] = [
`IF OBJECT_ID(N'${escapedFullTableName}', 'U') IS NULL`,
`BEGIN`,
` CREATE TABLE ${fullTableName}`,
` (`,
...allInnerLines,
` ) ON [PRIMARY]`,
`END`,
`GO`,
...extraIndexLines,
...(fkLines.length > 0 ? ['', '/* Foreign Key Constraints */'] : []),
...fkLines,
]
return lines.join('\n')
}
// ─── Helpers ──────────────────────────────────────────────────────────────────
/** Convert a DatabaseColumnDto (from API) to a ColumnDefinition for the grid */
function dbColToColumnDef(col: {
columnName: string
dataType: string
isNullable: boolean
maxLength?: number
}): ColumnDefinition {
const dt = col.dataType.toLowerCase().trim()
let dataType: SqlDataType = 'nvarchar'
let maxLength = ''
if (dt === 'nvarchar' || dt === 'varchar' || dt === 'char' || dt === 'nchar') {
dataType = col.maxLength === -1 ? 'nvarchar(MAX)' : 'nvarchar'
maxLength = col.maxLength && col.maxLength > 0 ? String(col.maxLength) : ''
} else if (dt === 'int') {
dataType = 'int'
} else if (dt === 'bigint') {
dataType = 'bigint'
} else if (dt === 'decimal' || dt === 'numeric') {
dataType = 'decimal'
} else if (dt === 'float' || dt === 'real') {
dataType = 'float'
} else if (dt === 'bit') {
dataType = 'bit'
} else if (dt === 'datetime') {
dataType = 'datetime'
} else if (dt.startsWith('datetime') || dt === 'smalldatetime') {
dataType = 'datetime2'
} else if (dt === 'date') {
dataType = 'date'
} else if (dt === 'uniqueidentifier') {
dataType = 'uniqueidentifier'
} else if (dt === 'money' || dt === 'smallmoney') {
dataType = 'money'
}
return {
id: crypto.randomUUID(),
columnName: col.columnName,
dataType,
maxLength,
isNullable: col.isNullable,
defaultValue: '',
description: '',
}
}
function splitByTopLevelComma(input: string): string[] {
const parts: string[] = []
let current = ''
let depth = 0
for (let i = 0; i < input.length; i++) {
const ch = input[i]
if (ch === '(') depth++
if (ch === ')') depth = Math.max(0, depth - 1)
if (ch === ',' && depth === 0) {
if (current.trim()) parts.push(current.trim())
current = ''
continue
}
current += ch
}
if (current.trim()) parts.push(current.trim())
return parts
}
function unwrapDefaultExpression(value: string): string {
let result = value.trim()
while (result.startsWith('(') && result.endsWith(')')) {
result = result.slice(1, -1).trim()
}
return result
}
function mapSqlTypeToDesigner(
dataTypeRaw: string,
lengthRaw: string,
): {
dataType: SqlDataType
maxLength: string
} {
const dt = dataTypeRaw.toLowerCase()
const len = lengthRaw.toLowerCase()
if (dt === 'nvarchar' || dt === 'varchar' || dt === 'char' || dt === 'nchar') {
if (len === 'max') {
return { dataType: 'nvarchar(MAX)', maxLength: '' }
}
return { dataType: 'nvarchar', maxLength: len || '100' }
}
if (dt === 'int') return { dataType: 'int', maxLength: '' }
if (dt === 'bigint') return { dataType: 'bigint', maxLength: '' }
if (dt === 'decimal' || dt === 'numeric') return { dataType: 'decimal', maxLength: '' }
if (dt === 'float' || dt === 'real') return { dataType: 'float', maxLength: '' }
if (dt === 'bit') return { dataType: 'bit', maxLength: '' }
if (dt === 'datetime') return { dataType: 'datetime', maxLength: '' }
if (dt.startsWith('datetime') || dt === 'smalldatetime' || dt === 'time') {
return { dataType: 'datetime2', maxLength: '' }
}
if (dt === 'date') return { dataType: 'date', maxLength: '' }
if (dt === 'uniqueidentifier') return { dataType: 'uniqueidentifier', maxLength: '' }
if (dt === 'money' || dt === 'smallmoney') return { dataType: 'money', maxLength: '' }
return { dataType: 'nvarchar', maxLength: '100' }
}
function parseCreateTableColumns(script: string): ColumnDefinition[] {
if (!script?.trim()) return []
const createIdx = script.search(/\bCREATE\s+TABLE\b/i)
if (createIdx < 0) return []
const openParenIdx = script.indexOf('(', createIdx)
if (openParenIdx < 0) return []
let depth = 0
let closeParenIdx = -1
for (let i = openParenIdx; i < script.length; i++) {
const ch = script[i]
if (ch === '(') depth++
if (ch === ')') {
depth--
if (depth === 0) {
closeParenIdx = i
break
}
}
}
if (closeParenIdx < 0) return []
const body = script.slice(openParenIdx + 1, closeParenIdx)
const defs = splitByTopLevelComma(body)
const columns: ColumnDefinition[] = []
for (const def of defs) {
const trimmed = def.trim()
if (!trimmed) continue
if (/^(CONSTRAINT|PRIMARY\s+KEY|UNIQUE\s+KEY|UNIQUE|FOREIGN\s+KEY|CHECK)\b/i.test(trimmed)) {
continue
}
const colMatch = trimmed.match(/^\[([^\]]+)\]\s+(.+)$/i)
if (!colMatch) continue
const columnName = colMatch[1].trim()
const rest = colMatch[2].trim()
const typeMatch = rest.match(/^\[?([A-Za-z0-9_]+)\]?\s*(?:\(([^)]*)\))?/)
if (!typeMatch) continue
const sqlType = typeMatch[1] ?? 'nvarchar'
const sqlLength = (typeMatch[2] ?? '').trim()
const mapped = mapSqlTypeToDesigner(sqlType, sqlLength)
const isNullable = !/\bNOT\s+NULL\b/i.test(rest)
const defaultMatch = rest.match(/\bDEFAULT\b\s+(.+)$/i)
const defaultValue = defaultMatch ? unwrapDefaultExpression(defaultMatch[1]) : ''
columns.push({
id: crypto.randomUUID(),
columnName,
dataType: mapped.dataType,
maxLength: mapped.maxLength,
isNullable,
defaultValue,
description: '',
})
}
return columns
}
/** Generate ALTER TABLE diff SQL (edit mode) */
function generateAlterTableSql(
originalCols: ColumnDefinition[],
currentCols: ColumnDefinition[],
tableName: string,
relationships: SqlTableRelation[],
originalRelationships: SqlTableRelation[],
indexes: TableIndex[],
originalIndexes: TableIndex[],
translate: (key: string, params?: Record<string, string | number>) => string,
): string {
const fullTableName = `[dbo].[${tableName}]`
const lines: string[] = [`/* ── ALTER TABLE: ${fullTableName} ── */`, '']
let hasChanges = false
// Build id-based maps for O(1) lookup
const origById = new Map<string, ColumnDefinition>()
originalCols.forEach((c) => origById.set(c.id, c))
const curById = new Map<string, ColumnDefinition>()
currentCols.filter((c) => c.columnName.trim()).forEach((c) => curById.set(c.id, c))
// Added columns (id exists in current but NOT in original)
currentCols
.filter((c) => c.columnName.trim())
.forEach((col) => {
if (!origById.has(col.id)) {
hasChanges = true
lines.push(
`-- ${translate('::App.TableDesignerDiff.AddedColumn', { name: col.columnName })}`,
)
lines.push(`ALTER TABLE ${fullTableName}`)
lines.push(` ADD ${colToSqlLine(col, false).trimStart()};`)
lines.push('')
}
})
// ❌ Silinen sütunlar (id exists in original but NOT in current)
originalCols.forEach((col) => {
if (!curById.has(col.id)) {
hasChanges = true
lines.push(
`-- ❌ ${translate('::App.TableDesignerDiff.DeletedColumn', { name: col.columnName })}`,
)
lines.push(`ALTER TABLE ${fullTableName}`)
lines.push(` DROP COLUMN [${col.columnName}];`)
lines.push('')
}
})
// ✏️ Renamed / Modified columns (same id in both)
currentCols
.filter((c) => c.columnName.trim())
.forEach((col) => {
const orig = origById.get(col.id)
if (!orig) return // new column, already handled above
const nameChanged =
orig.columnName.trim().toLowerCase() !== col.columnName.trim().toLowerCase()
const typeChanged = orig.dataType !== col.dataType || orig.maxLength !== col.maxLength
const nullChanged = orig.isNullable !== col.isNullable
// Rename: use sp_rename to preserve data
if (nameChanged) {
hasChanges = true
lines.push(
`-- 🔄 ${translate('::App.TableDesignerDiff.RenamedColumn', {
from: orig.columnName,
to: col.columnName,
})}`,
)
lines.push(
`EXEC sp_rename 'dbo.${tableName}.${orig.columnName}', '${col.columnName}', 'COLUMN';`,
)
lines.push('')
}
// Type or nullability change: ALTER COLUMN (uses new name if also renamed)
if (typeChanged || nullChanged) {
hasChanges = true
const label = nameChanged
? `-- ✏️ ${translate('::App.TableDesignerDiff.ModifiedColumnAfterRename', { name: col.columnName })}`
: `-- ✏️ ${translate('::App.TableDesignerDiff.ModifiedColumn', { name: col.columnName })}`
lines.push(label)
lines.push(`ALTER TABLE ${fullTableName}`)
lines.push(
` ALTER COLUMN ${colToSqlLine({ ...col, defaultValue: '' }, false).trimStart()};`,
)
lines.push('')
}
})
// 🔗 FK Diff: drop removed / drop+re-add modified / add new
const fkCascadeSql = (b: CascadeBehavior) =>
b === 'NoAction'
? 'NO ACTION'
: b
.replace(/([A-Z])/g, ' $1')
.trim()
.toUpperCase()
const addFkSql = (rel: SqlTableRelation) => {
const cname = rel.constraintName ?? `FK_${tableName}_${rel.fkColumnName}`
lines.push(
`-- 🔗 ${translate('::App.TableDesignerDiff.AddFk', {
column: rel.fkColumnName,
table: rel.referencedTable,
})}`,
)
lines.push(`ALTER TABLE ${fullTableName}`)
lines.push(` ADD CONSTRAINT [${cname}]`)
lines.push(` FOREIGN KEY ([${rel.fkColumnName}])`)
lines.push(` REFERENCES [dbo].[${rel.referencedTable}] ([${rel.referencedColumn || 'Id'}])`)
lines.push(` ON DELETE ${fkCascadeSql(rel.cascadeDelete)}`)
lines.push(` ON UPDATE ${fkCascadeSql(rel.cascadeUpdate)};`)
lines.push('')
}
const origRelById = new Map<string, SqlTableRelation>()
originalRelationships.forEach((r) => origRelById.set(r.id, r))
const curRelById = new Map<string, SqlTableRelation>()
relationships.forEach((r) => curRelById.set(r.id, r))
// Removed FKs → DROP CONSTRAINT
originalRelationships.forEach((orig) => {
if (!curRelById.has(orig.id)) {
hasChanges = true
const cname = orig.constraintName ?? `FK_${tableName}_${orig.fkColumnName}`
lines.push(
`-- ❌ ${translate('::App.TableDesignerDiff.DropFk', { name: orig.constraintName ?? cname })}`,
)
lines.push(`ALTER TABLE ${fullTableName}`)
lines.push(` DROP CONSTRAINT [${cname}];`)
lines.push('')
}
})
// Changed FKs → DROP old + ADD new
relationships.forEach((rel) => {
if (!rel.fkColumnName.trim() || !rel.referencedTable.trim()) return
const orig = origRelById.get(rel.id)
if (orig) {
// Existing FK — check if anything changed
const changed =
orig.fkColumnName !== rel.fkColumnName ||
orig.referencedTable !== rel.referencedTable ||
orig.referencedColumn !== rel.referencedColumn ||
orig.cascadeDelete !== rel.cascadeDelete ||
orig.cascadeUpdate !== rel.cascadeUpdate
if (changed) {
hasChanges = true
const cname = orig.constraintName ?? `FK_${tableName}_${orig.fkColumnName}`
lines.push(`-- ✏️ ${translate('::App.TableDesignerDiff.UpdateFk', { name: cname })}`)
lines.push(`ALTER TABLE ${fullTableName}`)
lines.push(` DROP CONSTRAINT [${cname}];`)
lines.push('')
addFkSql(rel)
}
} else {
// New FK
hasChanges = true
addFkSql(rel)
}
})
// 🔑 Index / Key Diff
const origIdxById = new Map<string, TableIndex>()
originalIndexes.forEach((ix) => origIdxById.set(ix.id, ix))
const curIdxById = new Map<string, TableIndex>()
indexes.forEach((ix) => curIdxById.set(ix.id, ix))
const dropIndexSql = (ix: TableIndex) => {
if (ix.indexType === 'PrimaryKey' || ix.indexType === 'UniqueKey') {
lines.push(`-- ❌ ${translate('::App.TableDesignerDiff.DropIndex', { name: ix.indexName })}`)
lines.push(`ALTER TABLE ${fullTableName}`)
lines.push(` DROP CONSTRAINT [${ix.indexName}];`)
} else {
lines.push(`-- ❌ ${translate('::App.TableDesignerDiff.DropIndex', { name: ix.indexName })}`)
lines.push(`DROP INDEX [${ix.indexName}] ON ${fullTableName};`)
}
lines.push('')
}
const addIndexSql = (ix: TableIndex) => {
if (ix.columns.length === 0) return
const clustered = ix.isClustered ? 'CLUSTERED' : 'NONCLUSTERED'
const colsSql = ix.columns.map((c) => `[${c.columnName}] ${c.order}`).join(', ')
if (ix.indexType === 'PrimaryKey') {
lines.push(`-- 🔑 Primary Key: [${ix.indexName}]`)
lines.push(`ALTER TABLE ${fullTableName}`)
lines.push(` ADD CONSTRAINT [${ix.indexName}] PRIMARY KEY ${clustered} (${colsSql});`)
} else if (ix.indexType === 'UniqueKey') {
lines.push(`-- 🔒 Unique Key: [${ix.indexName}]`)
lines.push(`ALTER TABLE ${fullTableName}`)
lines.push(` ADD CONSTRAINT [${ix.indexName}] UNIQUE ${clustered} (${colsSql});`)
} else {
lines.push(`-- 📋 Index: [${ix.indexName}]`)
lines.push(
...buildCreateIndexIfNotExistsSql(fullTableName, ix.indexName, ix.isClustered, colsSql),
)
}
lines.push('')
}
// Removed indexes
originalIndexes.forEach((orig) => {
if (!curIdxById.has(orig.id)) {
hasChanges = true
dropIndexSql(orig)
}
})
// New and modified indexes
indexes.forEach((cur) => {
if (cur.columns.length === 0) return
const orig = origIdxById.get(cur.id)
if (!orig) {
hasChanges = true
addIndexSql(cur)
} else {
const changed =
orig.indexName !== cur.indexName ||
orig.indexType !== cur.indexType ||
orig.isClustered !== cur.isClustered ||
JSON.stringify(orig.columns) !== JSON.stringify(cur.columns)
if (changed) {
hasChanges = true
lines.push(
`-- ✏️ ${translate('::App.TableDesignerDiff.UpdateIndex', { name: orig.indexName })}`,
)
dropIndexSql(orig)
addIndexSql(cur)
}
}
})
if (!hasChanges) {
lines.push(`/* ${translate('::App.TableDesignerDiff.NoChanges')} */`)
}
return lines.join('\n')
}
/**
* Adim sirasi. CRUD adimi yalnizca `App.SqlQueryManager.CrudEndpoints` izni olan
* kullanicilarda gosterildigi icin index'ler sabit degil; anahtarla eslestirilir.
*/
type StepKey = 'columns' | 'settings' | 'indexes' | 'relationships' | 'crud' | 'sql'
function StepContentWrapper({ children }: { children: React.ReactNode }) {
return <div className="min-h-[420px] flex flex-col">{children}</div>
}
// ─── Simple Menu Tree (read-only selection) ───────────────────────────────────
interface SimpleMenuTreeNodeProps {
node: MenuTreeNode & { moduleId?: string }
depth: number
selectedCode: string
onSelect: (code: string) => void
expanded: Set<string>
onToggle: (code: string) => void
}
function SimpleMenuTreeNode({
node,
depth,
selectedCode,
onSelect,
expanded,
onToggle,
}: SimpleMenuTreeNodeProps) {
const hasChildren = node.children.length > 0
const isExpanded = expanded.has(node.code)
const isSelected = node.code === selectedCode
const navigationIcon = useNavigationIcons()
const NodeIcon = node.icon ? navigationIcon[node.icon] : null
const { translate } = useLocalization()
return (
<div>
<div
className={`flex items-center gap-1 px-2 py-1.5 rounded mx-1 cursor-pointer ${
isSelected
? 'bg-indigo-500 text-white'
: 'hover:bg-gray-100 dark:hover:bg-gray-700 text-gray-700 dark:text-gray-200'
}`}
style={{ paddingLeft: `${8 + depth * 16}px` }}
onClick={() => onSelect(node.code)}
>
<span>
{hasChildren ? (
isExpanded ? (
<FaChevronDown className="text-gray-400" />
) : (
<FaChevronRight className="text-gray-400" />
)
) : null}
</span>
{NodeIcon && (
<NodeIcon
className={`text-base shrink-0 ${isSelected ? 'text-indigo-200' : 'text-gray-400'}`}
/>
)}
<span className="flex-1 text-sm truncate">{translate('::' + node.code)}</span>
{node.moduleId && (
<span
className={`text-xs shrink-0 font-mono px-1.5 py-0.5 rounded ${
isSelected
? 'bg-indigo-400 text-indigo-100'
: 'bg-gray-100 dark:bg-gray-700 text-gray-500 dark:text-gray-400'
}`}
>
{node.moduleId}
</span>
)}
</div>
{isExpanded &&
node.children.map((child: MenuTreeNode) => (
<SimpleMenuTreeNode
key={child.code}
node={child as MenuTreeNode & { moduleId?: string }}
depth={depth + 1}
selectedCode={selectedCode}
onSelect={onSelect}
expanded={expanded}
onToggle={onToggle}
/>
))}
</div>
)
}
interface SimpleMenuTreeSelectProps {
selectedCode: string
onSelect: (code: string) => void
nodes: MenuTreeNode[]
isLoading: boolean
invalid?: boolean
}
function SimpleMenuTreeSelect({
selectedCode,
onSelect,
nodes,
isLoading,
invalid,
}: SimpleMenuTreeSelectProps) {
const [expanded, setExpanded] = useState<Set<string>>(new Set())
const toggle = (code: string) =>
setExpanded((prev) => {
const n = new Set(prev)
if (n.has(code)) {
n.delete(code)
} else {
n.add(code)
}
return n
})
return (
<div
className={`rounded-lg border ${
invalid ? 'border-red-500 dark:border-red-700' : 'border-gray-300 dark:border-gray-600'
} bg-white dark:bg-gray-800 overflow-hidden`}
>
<div className="h-56 overflow-y-auto py-1">
{isLoading ? (
<div className="px-4 py-3 text-sm text-gray-400">Loading</div>
) : nodes.length === 0 ? (
<div className="px-4 py-3 text-sm text-gray-400">No menus available</div>
) : (
nodes.map((node) => (
<SimpleMenuTreeNode
key={node.code}
node={node as MenuTreeNode & { moduleId?: string }}
depth={0}
selectedCode={selectedCode}
onSelect={onSelect}
expanded={expanded}
onToggle={toggle}
/>
))
)}
</div>
</div>
)
}
const createEmptyColumn = (): ColumnDefinition => ({
id: crypto.randomUUID(),
columnName: '',
dataType: 'nvarchar',
maxLength: '100',
isNullable: true,
defaultValue: '',
description: '',
})
const DEFAULT_SETTINGS: TableSettings = {
menuValue: '',
menuPrefix: '',
entityName: '',
tableName: '',
}
// ─── Component ────────────────────────────────────────────────────────────────
const SqlTableDesignerDialog = ({
isOpen,
onClose,
dataSource,
onDeployed,
initialTableData,
initialTableName,
}: TableDesignerDialogProps) => {
const { translate } = useLocalization()
const lastCreateTableScript = useStoreState(
(state) => state.client.developerKit.lastCreateTableScript,
)
const setLastCreateTableScript = useStoreActions(
(actions) => actions.client.developerKit.setLastCreateTableScript,
)
const isEditMode = !!initialTableData
const { checkPermission } = usePermission()
const canManageCrudEndpoints = checkPermission(CRUD_ENDPOINT_PERMISSION)
/** Izin yoksa CRUD adimi hic gosterilmez. */
const stepKeys = useMemo<StepKey[]>(
() => [
'columns',
'settings',
'indexes',
'relationships',
...(canManageCrudEndpoints ? (['crud'] as StepKey[]) : []),
'sql',
],
[canManageCrudEndpoints],
)
const lastStep = stepKeys.length - 1
const [step, setStep] = useState(0)
const currentStepKey = stepKeys[step]
const [isDeploying, setIsDeploying] = useState(false)
/** Deploy sonrasi CRUD endpoint uretilsin mi; varsayilan kapali, kullanici acar. */
const [generateCrud, setGenerateCrud] = useState(false)
const [crudOperations, setCrudOperations] = useState<CrudOperationType[]>([
...CRUD_OPERATION_TYPES,
])
/** Duzenlenen tablonun kayitli endpoint'leri; adim mevcut durumu gosterir. */
const [crudLoading, setCrudLoading] = useState(false)
const [columns, setColumns] = useState<ColumnDefinition[]>([createEmptyColumn()])
const [originalColumns, setOriginalColumns] = useState<ColumnDefinition[]>([])
const [colsLoading, setColsLoading] = useState(false)
/** Hazir kolon sablonlari panelinin acik/kapali durumu. */
const [templatesOpen, setTemplatesOpen] = useState(false)
const templatesRef = useRef<HTMLDivElement | null>(null)
const [settings, setSettings] = useState<TableSettings>(DEFAULT_SETTINGS)
const [rawMenuItems, setRawMenuItems] = useState<MenuItem[]>([])
const [menuTree, setMenuTree] = useState<MenuTreeNode[]>([])
const [selectedMenuCode, setSelectedMenuCode] = useState('')
const [menuAddDialogOpen, setMenuAddDialogOpen] = useState(false)
const [menuLoading, setMenuLoading] = useState(false)
const [relationships, setRelationships] = useState<SqlTableRelation[]>([])
const [originalRelationships, setOriginalRelationships] = useState<SqlTableRelation[]>([])
const [fksLoading, setFksLoading] = useState(false)
const [fkModalOpen, setFkModalOpen] = useState(false)
const [editingFkId, setEditingFkId] = useState<string | null>(null)
const [fkForm, setFkForm] = useState<Omit<SqlTableRelation, 'id'>>(EMPTY_FK)
const [dbTables, setDbTables] = useState<{ schemaName: string; tableName: string }[]>([])
const [targetTableColumns, setTargetTableColumns] = useState<string[]>([])
const [targetTableKeyColumns, setTargetTableKeyColumns] = useState<string[]>([])
const dataTypeOptions = DATA_TYPES
const cascadeOptions = CASCADE_OPTIONS
const indexOrderOptions: { value: 'ASC' | 'DESC'; label: string }[] = [
{ value: 'ASC', label: 'ASC' },
{ value: 'DESC', label: 'DESC' },
]
const fkColumnOptions = columns
.filter((c) => c.columnName.trim())
.map((c) => ({ value: c.columnName, label: c.columnName }))
const referencedTableOptions = dbTables.map((t) => ({
value: t.tableName,
label: t.tableName,
}))
const referencedColumnOptions = targetTableColumns.map((col) => {
const isKey = targetTableKeyColumns.some((k) => k.toLowerCase() === col.toLowerCase())
return {
value: col,
label: isKey ? `${col} (PK/UNIQUE)` : `${col} (Not Key)`,
isDisabled: !isKey,
}
})
const [targetColsLoading, setTargetColsLoading] = useState(false)
const [indexes, setIndexes] = useState<TableIndex[]>([])
const [originalIndexes, setOriginalIndexes] = useState<TableIndex[]>([])
const [indexesLoading, setIndexesLoading] = useState(false)
const [indexModalOpen, setIndexModalOpen] = useState(false)
const [editingIndexId, setEditingIndexId] = useState<string | null>(null)
const [indexForm, setIndexForm] = useState<Omit<TableIndex, 'id'>>(EMPTY_INDEX)
const reloadMenus = (onLoaded?: (items: MenuItem[]) => void) => {
setMenuLoading(true)
getMenus(0, 1000)
.then((res) => {
const items = (res.data?.items ?? []) as MenuItem[]
const filtered = items.filter((m) => !!m.moduleId?.trim())
setRawMenuItems(filtered)
const tree = filterNonLinkNodes(buildMenuTree(filtered))
setMenuTree(tree)
onLoaded?.(filtered)
})
.catch(() => {})
.finally(() => setMenuLoading(false))
}
useEffect(() => {
if (!isOpen) return
const knownTableName = initialTableData?.tableName ?? initialTableName
reloadMenus((items) => {
// Adi bilinen tabloda (duzenleme ya da eksik tablo onarimi) modul menusu kendiliginden secilir.
if (knownTableName) {
const derivedModuleId = knownTableName.split('_')[0] ?? ''
const match = items.find((m) => m.moduleId === derivedModuleId)
if (match?.code) setSelectedMenuCode(match.code)
}
})
// Eksik tablo onarimi: tablo hedefte yok, olusturma modu korunur; yalnizca ad alanlari dolar.
if (!initialTableData && initialTableName) {
const parts = initialTableName.split('_')
setSettings((s) => ({
...s,
tableName: initialTableName,
menuValue: parts[0] ?? '',
menuPrefix: parts[0] ?? '',
entityName: parts[parts.length - 1] ?? initialTableName,
displayName: parts[parts.length - 1] ?? initialTableName,
}))
}
if (dataSource) {
sqlObjectManagerService
.getAllObjects(dataSource)
.then((res) => setDbTables(res.data?.tables ?? []))
.catch(() => {})
}
// Edit mode: kayitli CRUD endpoint'leri okunur; varsa adim mevcut durumla acilir.
if (initialTableData && canManageCrudEndpoints) {
setCrudLoading(true)
developerKitService
.getEndpointsByEntity(toEntityName(initialTableData.tableName))
.then((endpoints) => {
if (endpoints.length === 0) return
setGenerateCrud(true)
const active = endpoints
.filter((e) => e.isActive)
.map((e) => e.operationType as CrudOperationType)
setCrudOperations(CRUD_OPERATION_TYPES.filter((op) => active.includes(op)))
})
.catch(() => {})
.finally(() => setCrudLoading(false))
}
// Edit mode: load table's existing columns + FK constraints
if (initialTableData && dataSource) {
setColsLoading(true)
sqlObjectManagerService
.getTableColumns(dataSource, initialTableData.schemaName, initialTableData.tableName)
.then((res) => {
const defs = (res.data ?? []).map(dbColToColumnDef)
setColumns(defs.length > 0 ? defs : [createEmptyColumn()])
setOriginalColumns(defs)
setSettings((s) => ({ ...s, tableName: initialTableData.tableName }))
})
.catch(() => {})
.finally(() => setColsLoading(false))
// Derive settings from table name (e.g. "Sas_D_EntityName" → menu: "Sas", entity: "EntityName")
const parts = initialTableData.tableName.split('_')
const derivedMenu = parts[0] ?? ''
const derivedEntity = parts[parts.length - 1] ?? initialTableData.tableName
setSettings((s) => ({
...s,
menuValue: derivedMenu,
menuPrefix: derivedMenu,
entityName: derivedEntity,
displayName: derivedEntity,
}))
// Load existing FK constraints
const fkQuery = [
'SELECT',
' fk.name AS constraintName,',
' col.name AS fkColumnName,',
' ref_t.name AS referencedTable,',
' ref_c.name AS referencedColumn,',
' fk.delete_referential_action_desc AS cascadeDelete,',
' fk.update_referential_action_desc AS cascadeUpdate',
'FROM sys.foreign_keys fk',
'INNER JOIN sys.foreign_key_columns fkc ON fk.object_id = fkc.constraint_object_id',
'INNER JOIN sys.columns col ON fkc.parent_object_id = col.object_id AND fkc.parent_column_id = col.column_id',
'INNER JOIN sys.tables ref_t ON fkc.referenced_object_id = ref_t.object_id',
'INNER JOIN sys.columns ref_c ON fkc.referenced_object_id = ref_c.object_id AND fkc.referenced_column_id = ref_c.column_id',
`WHERE fk.parent_object_id = OBJECT_ID('${initialTableData.schemaName}.${initialTableData.tableName}')`,
].join('\n')
const mapCascade = (v: string): CascadeBehavior => {
if (v === 'CASCADE') return 'Cascade'
if (v === 'SET_NULL') return 'SetNull'
if (v === 'SET_DEFAULT') return 'Restrict'
return 'NoAction'
}
setFksLoading(true)
sqlObjectManagerService
.executeQuery({ queryText: fkQuery, dataSourceCode: dataSource })
.then((res) => {
const rows: any[] = res.data?.data ?? []
const fkDefs: SqlTableRelation[] = rows.map((r) => ({
id: crypto.randomUUID(),
constraintName: r.constraintName,
relationshipType: 'OneToMany' as RelationshipType,
fkColumnName: r.fkColumnName,
referencedTable: r.referencedTable,
referencedColumn: r.referencedColumn,
cascadeDelete: mapCascade(r.cascadeDelete),
cascadeUpdate: mapCascade(r.cascadeUpdate),
isRequired: false,
description: '',
}))
setRelationships(fkDefs)
setOriginalRelationships(fkDefs)
})
.catch(() => {})
.finally(() => setFksLoading(false))
// Load existing indexes / keys
const idxQuery = [
'SELECT',
' i.name AS indexName,',
' CASE',
" WHEN i.is_primary_key = 1 THEN 'PrimaryKey'",
" WHEN i.is_unique_constraint = 1 THEN 'UniqueKey'",
" ELSE 'Index'",
' END AS indexType,',
' CAST(CASE WHEN i.type = 1 THEN 1 ELSE 0 END AS BIT) AS isClustered,',
' col.name AS columnName,',
' ic.is_descending_key AS isDescending,',
' ic.key_ordinal AS keyOrdinal',
'FROM sys.indexes i',
'INNER JOIN sys.index_columns ic ON i.object_id = ic.object_id AND i.index_id = ic.index_id',
'INNER JOIN sys.columns col ON ic.object_id = col.object_id AND ic.column_id = col.column_id',
`WHERE i.object_id = OBJECT_ID('${initialTableData.schemaName}.${initialTableData.tableName}')`,
' AND ic.is_included_column = 0',
'ORDER BY i.name, ic.key_ordinal',
].join('\n')
setIndexesLoading(true)
sqlObjectManagerService
.executeQuery({ queryText: idxQuery, dataSourceCode: dataSource })
.then((res) => {
const rows: any[] = res.data?.data ?? []
const idxMap = new Map<string, TableIndex>()
rows.forEach((row) => {
if (!idxMap.has(row.indexName)) {
idxMap.set(row.indexName, {
id: crypto.randomUUID(),
indexName: row.indexName,
indexType: row.indexType as IndexType,
isClustered: row.isClustered === true || row.isClustered === 1,
columns: [],
description: '',
})
}
idxMap.get(row.indexName)!.columns.push({
columnName: row.columnName,
order: row.isDescending ? 'DESC' : 'ASC',
})
})
const idxDefs = Array.from(idxMap.values())
setIndexes(idxDefs)
setOriginalIndexes(idxDefs)
})
.catch(() => {})
.finally(() => setIndexesLoading(false))
}
}, [isOpen, dataSource, initialTableData, initialTableName, canManageCrudEndpoints])
const generatedSql = useMemo(
() =>
isEditMode
? generateAlterTableSql(
originalColumns,
columns,
settings.tableName || initialTableData?.tableName || '',
relationships,
originalRelationships,
indexes,
originalIndexes,
translate,
)
: generateCreateTableSql(columns, settings, relationships, indexes),
[
translate,
isEditMode,
originalColumns,
columns,
settings,
initialTableData,
relationships,
originalRelationships,
indexes,
originalIndexes,
],
)
// ── Column operations ──────────────────────────────────────────────────────
const [newRowId, setNewRowId] = useState<string | null>(null)
const addColumn = () => {
const col = createEmptyColumn()
setColumns((prev) => [...prev, col])
setNewRowId(col.id)
}
const clearAllColumns = () => setColumns([createEmptyColumn()])
const addAutomaticPrimaryKey = (preset: string, description: string) => {
const tableName = settings.tableName || initialTableData?.tableName || 'Table'
setIndexes((prev) => {
if (prev.some((index) => index.indexType === 'PrimaryKey')) return prev
return [
...prev,
{
id: `auto-${preset}-${crypto.randomUUID()}`,
indexName: `PK_${tableName}`,
indexType: 'PrimaryKey',
isClustered: false,
columns: [{ columnName: 'Id', order: 'ASC' }],
description,
},
]
})
}
const addFullAuditedColumns = () => {
const existingNames = new Set(columns.map((c) => c.columnName.trim().toLowerCase()))
const toAdd = FULL_AUDIT_COLUMNS.filter((c) => !existingNames.has(c.columnName.toLowerCase()))
setColumns((prev) => {
const nonEmpty = prev.filter((c) => c.columnName.trim() !== '')
return [...nonEmpty, ...toAdd.map((c) => ({ ...c })), createEmptyColumn()]
})
addAutomaticPrimaryKey('full-audit', 'Full audited primary key (auto)')
}
const addMultiTenantColumns = () => {
const existingNames = new Set(columns.map((c) => c.columnName.trim().toLowerCase()))
if (!existingNames.has(TENANT_COLUMN.columnName.toLowerCase())) {
setColumns((prev) => {
const nonEmpty = prev.filter((c) => c.columnName.trim() !== '')
return [...nonEmpty, { ...TENANT_COLUMN }, createEmptyColumn()]
})
}
}
const addWorkflowColumns = () => {
const existingNames = new Set(columns.map((c) => c.columnName.trim().toLowerCase()))
const toAdd = WORKFLOW_COLUMNS.filter((c) => !existingNames.has(c.columnName.toLowerCase()))
if (toAdd.length > 0) {
setColumns((prev) => {
const nonEmpty = prev.filter((c) => c.columnName.trim() !== '')
return [...nonEmpty, ...toAdd.map((c) => ({ ...c })), createEmptyColumn()]
})
}
addAutomaticPrimaryKey('workflow', 'Workflow primary key (auto)')
}
const addTodoColumns = () => {
const existingNames = new Set(columns.map((c) => c.columnName.trim().toLowerCase()))
const toAdd = TODO_COLUMNS.filter((c) => !existingNames.has(c.columnName.toLowerCase()))
if (toAdd.length > 0) {
setColumns((prev) => {
const nonEmpty = prev.filter((c) => c.columnName.trim() !== '')
return [...nonEmpty, ...toAdd.map((c) => ({ ...c })), createEmptyColumn()]
})
}
addAutomaticPrimaryKey('todo', 'Todo primary key (auto)')
}
const addGanttColumns = () => {
const existingNames = new Set(columns.map((c) => c.columnName.trim().toLowerCase()))
const toAdd = GANTT_COLUMNS.filter((c) => !existingNames.has(c.columnName.toLowerCase()))
if (toAdd.length > 0) {
setColumns((prev) => {
const nonEmpty = prev.filter((c) => c.columnName.trim() !== '')
return [...nonEmpty, ...toAdd.map((c) => ({ ...c })), createEmptyColumn()]
})
}
addAutomaticPrimaryKey('gantt', 'Gantt primary key (auto)')
}
const addSchedulerColumns = () => {
const existingNames = new Set(columns.map((c) => c.columnName.trim().toLowerCase()))
const toAdd = SCHEDULER_COLUMNS.filter((c) => !existingNames.has(c.columnName.toLowerCase()))
if (toAdd.length > 0) {
setColumns((prev) => {
const nonEmpty = prev.filter((c) => c.columnName.trim() !== '')
return [...nonEmpty, ...toAdd.map((c) => ({ ...c })), createEmptyColumn()]
})
}
addAutomaticPrimaryKey('scheduler', 'Scheduler primary key (auto)')
}
const importColumnsFromRememberedCreateTable = async () => {
let script = ''
try {
const clipboardText = await navigator.clipboard.readText()
if (/\bCREATE\s+TABLE\b/i.test(clipboardText)) {
script = clipboardText
setLastCreateTableScript(clipboardText)
}
} catch {
// Clipboard read can fail due to browser permissions; fallback to saved script.
}
if (!script) {
script = lastCreateTableScript
}
if (!script.trim()) {
toast.push(
<Notification type="warning" title={translate('::App.Platform.Warning')}>
{translate('::App.DeveloperKitTableDesigner.CreateScriptNotFound')}
</Notification>,
{ placement: 'bottom-end' },
)
return
}
const parsedColumns = parseCreateTableColumns(script)
if (parsedColumns.length === 0) {
toast.push(
<Notification type="warning" title={translate('::App.Platform.Warning')}>
{translate('::App.DeveloperKitTableDesigner.ColumnsNotParsed')}
</Notification>,
{ placement: 'bottom-end' },
)
return
}
let addedCount = 0
let skippedCount = 0
setColumns((prev) => {
const existingNonEmpty = prev.filter((c) => c.columnName.trim() !== '')
const existingNames = new Set(existingNonEmpty.map((c) => c.columnName.trim().toLowerCase()))
const toAdd: ColumnDefinition[] = []
for (const col of parsedColumns) {
const key = col.columnName.trim().toLowerCase()
if (!key) {
skippedCount++
continue
}
if (existingNames.has(key)) {
skippedCount++
continue
}
existingNames.add(key)
toAdd.push(col)
}
addedCount = toAdd.length
return [...existingNonEmpty, ...toAdd, createEmptyColumn()]
})
toast.push(
<Notification type="success" title={translate('::App.Platform.Success')}>
{addedCount > 0
? skippedCount > 0
? translate('::App.DeveloperKitTableDesigner.ColumnsAddedWithSkipped', {
added: addedCount,
skipped: skippedCount,
})
: translate('::App.DeveloperKitTableDesigner.ColumnsAdded', { added: addedCount })
: translate('::App.DeveloperKitTableDesigner.NoColumnsAdded')}
</Notification>,
{ placement: 'bottom-end' },
)
}
const removeColumn = (id: string) => setColumns((prev) => prev.filter((c) => c.id !== id))
const moveColumn = (id: string, direction: 'up' | 'down') => {
setColumns((prev) => {
const idx = prev.findIndex((c) => c.id === id)
if (idx < 0) return prev
const next = [...prev]
const swap = direction === 'up' ? idx - 1 : idx + 1
if (swap < 0 || swap >= next.length) return prev
;[next[idx], next[swap]] = [next[swap], next[idx]]
return next
})
}
const updateColumn = useCallback(
<K extends keyof ColumnDefinition>(id: string, field: K, value: ColumnDefinition[K]) => {
setColumns((prev) => prev.map((c) => (c.id === id ? { ...c, [field]: value } : c)))
},
[],
)
// ── Settings helpers ───────────────────────────────────────────────────────
const hasTenantIdColumn = (cols: ColumnDefinition[]) =>
cols.some((c) => c.columnName.trim().toLowerCase() === 'tenantid')
const buildTableName = (prefix: string, entity: string) =>
prefix && entity ? `${prefix}_${hasTenantIdColumn(columns) ? 'T' : 'D'}_${entity}` : ''
const syncAutoPkName = (newTableName: string) => {
if (!newTableName) return
setIndexes((prev) =>
prev.map((ix) => {
if (!ix.id.startsWith('auto-') || ix.indexType !== 'PrimaryKey') return ix
return { ...ix, indexName: `PK_${newTableName}` }
}),
)
}
const onMenuCodeSelect = (code: string) => {
if (isEditMode) return
const item = rawMenuItems.find((m) => m.code === code)
const prefix = item?.moduleId ?? ''
setSelectedMenuCode(code)
const newTableName = buildTableName(prefix, settings.entityName)
setSettings((s) => ({
...s,
menuValue: prefix,
menuPrefix: prefix,
tableName: newTableName,
}))
syncAutoPkName(newTableName)
}
// Strip spaces and special chars — only alphanumeric + underscore allowed
const onEntityNameChange = (value: string) => {
const sanitized = value.replace(/[^A-Za-z0-9_]/g, '')
const newTableName = buildTableName(settings.menuPrefix, sanitized)
setSettings((s) => ({
...s,
entityName: sanitized,
tableName: newTableName,
displayName: sanitized, // always mirrors entity name
}))
syncAutoPkName(newTableName)
}
useEffect(() => {
if (isEditMode) return
if (!settings.menuPrefix || !settings.entityName) return
const recalculatedTableName = buildTableName(settings.menuPrefix, settings.entityName)
if (!recalculatedTableName || recalculatedTableName === settings.tableName) return
setSettings((s) => ({ ...s, tableName: recalculatedTableName }))
syncAutoPkName(recalculatedTableName)
}, [isEditMode, columns, settings.menuPrefix, settings.entityName, settings.tableName])
// ── FK Relationship handlers ───────────────────────────────────────────────
const loadTargetColumns = (tableName: string) => {
if (!tableName || !dataSource) {
setTargetTableColumns([])
setTargetTableKeyColumns([])
return
}
const tbl = dbTables.find((t) => t.tableName === tableName)
if (!tbl) return
setTargetColsLoading(true)
const objectName = `[${tbl.schemaName}].[${tbl.tableName}]`
const keyColsQuery = [
'SELECT DISTINCT c.name AS columnName',
'FROM sys.indexes i',
'INNER JOIN sys.index_columns ic ON i.object_id = ic.object_id AND i.index_id = ic.index_id',
'INNER JOIN sys.columns c ON ic.object_id = c.object_id AND ic.column_id = c.column_id',
`WHERE i.object_id = OBJECT_ID('${objectName}')`,
' AND ic.is_included_column = 0',
' AND (i.is_primary_key = 1 OR i.is_unique = 1 OR i.is_unique_constraint = 1)',
'ORDER BY c.name',
].join('\n')
Promise.all([
sqlObjectManagerService.getTableColumns(dataSource, tbl.schemaName, tbl.tableName),
sqlObjectManagerService.executeQuery({ queryText: keyColsQuery, dataSourceCode: dataSource }),
])
.then(([colsRes, keyColsRes]) => {
const allCols = (colsRes.data ?? []).map((c) => c.columnName)
const keyCols = ((keyColsRes.data?.data ?? []) as any[]).map((r) => r.columnName)
setTargetTableColumns(allCols)
setTargetTableKeyColumns(keyCols)
})
.catch(() => {
setTargetTableColumns([])
setTargetTableKeyColumns([])
})
.finally(() => setTargetColsLoading(false))
}
const openAddFk = () => {
setEditingFkId(null)
setFkForm(EMPTY_FK)
setTargetTableColumns([])
setTargetTableKeyColumns([])
setFkModalOpen(true)
}
const openEditFk = (rel: SqlTableRelation) => {
setEditingFkId(rel.id)
const { ...rest } = rel
setFkForm(rest)
loadTargetColumns(rest.referencedTable)
setFkModalOpen(true)
}
const saveFk = () => {
if (
!fkForm.fkColumnName.trim() ||
!fkForm.referencedTable.trim() ||
!fkForm.referencedColumn.trim()
) {
return
}
const isTargetKeyColumn = targetTableKeyColumns.some(
(c) => c.toLowerCase() === fkForm.referencedColumn.trim().toLowerCase(),
)
if (!isTargetKeyColumn) {
toast.push(
<Notification type="warning" title={translate('::App.Platform.Warning')}>
{translate('::App.DeveloperKitTableDesigner.ReferenceColumnMustBeKey')}
</Notification>,
{ placement: 'bottom-end' },
)
return
}
if (editingFkId) {
setRelationships((prev) =>
prev.map((r) => (r.id === editingFkId ? { ...fkForm, id: editingFkId } : r)),
)
} else {
setRelationships((prev) => [...prev, { ...fkForm, id: crypto.randomUUID() }])
}
setFkModalOpen(false)
}
const deleteFk = (id: string) => {
setRelationships((prev) => prev.filter((r) => r.id !== id))
}
// ── Index / Key handlers ─────────────────────────────────────────────────
const buildIndexName = (type: IndexType, cols: IndexColumnEntry[]): string => {
const prefix = type === 'PrimaryKey' ? 'PK' : type === 'UniqueKey' ? 'UQ' : 'IX'
const entityName = settings.entityName || initialTableData?.tableName || ''
const colPart = cols.map((c) => c.columnName).join('_')
if (entityName && colPart) return `${prefix}_${entityName}_${colPart}`
if (entityName) return `${prefix}_${entityName}`
if (colPart) return `${prefix}_${colPart}`
return prefix
}
const openAddIndex = () => {
setEditingIndexId(null)
const entityName = settings.entityName || initialTableData?.tableName || ''
setIndexForm({ ...EMPTY_INDEX, indexName: entityName ? `IX_${entityName}` : '' })
setIndexModalOpen(true)
}
const openEditIndex = (idx: TableIndex) => {
setEditingIndexId(idx.id)
const { ...rest } = idx
setIndexForm(rest)
setIndexModalOpen(true)
}
const saveIndex = () => {
if (!indexForm.indexName.trim() || indexForm.columns.length === 0) return
if (editingIndexId) {
setIndexes((prev) =>
prev.map((ix) => (ix.id === editingIndexId ? { ...indexForm, id: editingIndexId } : ix)),
)
} else {
setIndexes((prev) => [...prev, { ...indexForm, id: crypto.randomUUID() }])
}
setIndexModalOpen(false)
}
const deleteIndex = (id: string) => {
setIndexes((prev) => prev.filter((ix) => ix.id !== id))
}
// ── Navigation ─────────────────────────────────────────────────────────────
// Compute duplicate column names (lowercased)
const duplicateColumnNames = useMemo(() => {
const names = columns.map((c) => c.columnName.trim().toLowerCase()).filter(Boolean)
return new Set(names.filter((n, i) => names.indexOf(n) !== i))
}, [columns])
const canGoNext = (): boolean => {
if (currentStepKey === 'columns') {
const hasNamed = columns.some((c) => c.columnName.trim().length > 0)
return hasNamed && duplicateColumnNames.size === 0
}
if (currentStepKey === 'settings') {
if (isEditMode) return true // table name is locked in edit mode
const baseOk =
!!settings.tableName.trim() && !!settings.entityName.trim() && !!settings.menuValue
return baseOk
}
if (currentStepKey === 'crud') {
// Uretim isteniyorsa en az bir operasyon secilmis olmali.
return !generateCrud || crudOperations.length > 0
}
return true
}
const handleNext = () => {
if (step < lastStep) setStep((s) => s + 1)
}
const handleBack = () => {
if (step > 0) setStep((s) => s - 1)
}
const toggleCrudOperation = (operation: CrudOperationType) => {
setCrudOperations((prev) =>
prev.includes(operation)
? prev.filter((op) => op !== operation)
: CRUD_OPERATION_TYPES.filter((op) => op === operation || prev.includes(op)),
)
}
// ── Deploy ─────────────────────────────────────────────────────────────────
const handleDeploy = async () => {
if (!dataSource) {
toast.push(
<Notification type="warning" title={translate('::App.Platform.Warning')}>
{translate('::App.SqlQueryManager.PleaseSelectDataSource')}
</Notification>,
{ placement: 'bottom-end' },
)
return
}
setIsDeploying(true)
try {
// Strip GO batch separators — they are SSMS-only and not valid T-SQL
const executableSql = generatedSql
.split(/^\s*GO\s*$/im)
.map((b) => b.trim())
.filter(Boolean)
.join('\n')
const result = await sqlObjectManagerService.executeQuery({
queryText: executableSql,
dataSourceCode: dataSource,
})
if (result.data.success) {
const deployedTable = settings.tableName || initialTableData?.tableName || ''
toast.push(
<Notification type="success" title={translate('::App.Platform.Success')}>
{`${translate(isEditMode ? '::App.SqlQueryManager.TableUpdated' : '::App.SqlQueryManager.TableCreated')}: [dbo].[${deployedTable}]`}
</Notification>,
{ placement: 'bottom-end' },
)
// Save seed file to the seeds folder (configs/seeds/SqlData) after successful deploy.
// Always use the full CREATE TABLE script (not the ALTER diff) so the seed file
// can recreate the table from scratch when the database is wiped.
try {
const seedSql = generateCreateTableSql(columns, settings, relationships, indexes)
await sqlObjectManagerService.saveTableScript({
fileName: deployedTable,
sqlScript: seedSql,
})
} catch {
// Non-blocking: seed file save failure does not affect deploy success
}
// Kullanici CRUD adiminda istediyse endpoint'leri uretir; secilmeyen
// operasyonlar pasif olarak kaydedilir. Hata deploy'u geri almaz.
if (canManageCrudEndpoints && generateCrud && crudOperations.length > 0 && deployedTable) {
try {
await developerKitService.generateCrudEndpoints(
toEntityName(deployedTable),
crudOperations,
)
toast.push(
<Notification type="success" title={translate('::App.Platform.Success')}>
{`${translate('::App.SqlQueryManager.CrudEndpoints')}: ${crudOperations.join(', ')}`}
</Notification>,
{ placement: 'bottom-end' },
)
} catch (crudError) {
toast.push(
<Notification type="warning" title={translate('::App.Platform.Warning')}>
{getErrorMessage(crudError, translate('::App.Platform.IslemBasarisiz'))}
</Notification>,
{ placement: 'bottom-end' },
)
}
}
await onDeployed?.({
schemaName: initialTableData?.schemaName || 'dbo',
tableName: deployedTable,
})
handleClose()
} else {
toast.push(
<Notification type="danger" title={translate('::App.Platform.Error')}>
{result.data.message || translate('::App.SqlQueryManager.TableCreationFailed')}
</Notification>,
{ placement: 'bottom-end' },
)
}
} catch (error: any) {
toast.push(
<Notification type="danger" title={translate('::App.Platform.Error')}>
{error.response?.data?.error?.message ||
translate('::App.SqlQueryManager.TableDeployFailed')}
</Notification>,
{ placement: 'bottom-end' },
)
} finally {
setIsDeploying(false)
}
}
const handleClose = () => {
setStep(0)
setColumns([createEmptyColumn()])
setOriginalColumns([])
setSettings(DEFAULT_SETTINGS)
setRelationships([])
setOriginalRelationships([])
setIndexes([])
setOriginalIndexes([])
setDbTables([])
setTargetTableColumns([])
setTargetTableKeyColumns([])
setSelectedMenuCode('')
setMenuAddDialogOpen(false)
setTemplatesOpen(false)
setGenerateCrud(false)
setCrudOperations([...CRUD_OPERATION_TYPES])
onClose()
}
// ── Step Indicator ─────────────────────────────────────────────────────────
const STEP_LABEL: Record<StepKey, string> = {
columns: translate('::App.SqlQueryManager.ColumnDesign'),
settings: translate('::App.SqlQueryManager.EntitySettings'),
indexes: translate('::App.SqlQueryManager.IndexKeys'),
relationships: translate('::App.SqlQueryManager.Relationships'),
crud: translate('::App.SqlQueryManager.CrudEndpoints'),
sql: translate('::App.SqlQueryManager.TSqlPreview'),
}
const STEP_LABELS = stepKeys.map((key) => STEP_LABEL[key])
const renderStepIndicator = () => (
<div className="flex items-center justify-between mb-2">
{STEP_LABELS.map((label, i) => {
const isDone = i < step
const isActive = i === step
return (
<div key={i} className="flex items-center flex-1">
<div className="flex items-center gap-2">
<div
className={`w-7 h-7 rounded-full flex items-center justify-center text-xs font-bold transition-colors
${isDone ? 'bg-green-500 text-white' : isActive ? 'bg-blue-600 text-white' : 'bg-gray-200 text-gray-500 dark:bg-gray-700 dark:text-gray-400'}`}
>
{isDone ? <FaCheck /> : i + 1}
</div>
<span
className={`text-sm font-medium whitespace-nowrap ${isActive ? 'text-blue-600 dark:text-blue-400' : 'text-gray-500'}`}
>
{label}
</span>
</div>
{i < STEP_LABELS.length - 1 && (
<div className="flex-1 mx-3 h-px bg-gray-200 dark:bg-gray-700" />
)}
</div>
)
})}
</div>
)
// ── Step 0: Column Designer ────────────────────────────────────────────────
/** Hazir kolon setleri; buton kalabaligi yerine tek bir sablon panelinde toplanir. */
const columnTemplates: {
key: string
label: string
hint: string
icon: React.ReactNode
color: string
apply: () => void
}[] = [
{
key: 'multiTenant',
label: translate('::App.SqlQueryManager.AddMultiTenantColumns'),
hint: 'TenantId',
icon: <FaUsers />,
color: 'bg-emerald-500',
apply: addMultiTenantColumns,
},
{
key: 'fullAudited',
label: translate('::App.SqlQueryManager.AddFullAuditedColumns'),
hint: 'Creation / Modification / Deletion',
icon: <FaHistory />,
color: 'bg-blue-500',
apply: addFullAuditedColumns,
},
{
key: 'workflow',
label: translate('::App.SqlQueryManager.AddWorkflowColumns'),
hint: 'Workflow state columns',
icon: <FaProjectDiagram />,
color: 'bg-indigo-500',
apply: addWorkflowColumns,
},
{
key: 'todo',
label: 'Todo Columns',
hint: 'Status / Priority / DueDate',
icon: <FaTasks />,
color: 'bg-cyan-500',
apply: addTodoColumns,
},
{
key: 'gantt',
label: 'Gantt Columns',
hint: 'Start / End / Progress',
icon: <FaChartBar />,
color: 'bg-violet-500',
apply: addGanttColumns,
},
{
key: 'scheduler',
label: 'Scheduler Columns',
hint: 'Event start / end / recurrence',
icon: <FaRegCalendarAlt />,
color: 'bg-teal-500',
apply: addSchedulerColumns,
},
]
useEffect(() => {
if (!templatesOpen) return
const onDocClick = (e: MouseEvent) => {
if (templatesRef.current && !templatesRef.current.contains(e.target as Node)) {
setTemplatesOpen(false)
}
}
const onEsc = (e: KeyboardEvent) => {
if (e.key === 'Escape') setTemplatesOpen(false)
}
document.addEventListener('mousedown', onDocClick)
document.addEventListener('keydown', onEsc)
return () => {
document.removeEventListener('mousedown', onDocClick)
document.removeEventListener('keydown', onEsc)
}
}, [templatesOpen])
const renderColumnDesigner = () => (
<div className="flex flex-col">
{colsLoading && (
<div className="flex items-center justify-center py-12 text-gray-500">
<span className="animate-spin mr-2">&#9696;</span>{' '}
{translate('::App.SqlQueryManager.LoadingColumns')}
</div>
)}
{!colsLoading && (
<>
<div className="flex flex-wrap items-center justify-between gap-2 py-2">
<div className="flex items-center gap-2">
<div className="relative" ref={templatesRef}>
<Button
size="xs"
variant="solid"
color="blue-600"
icon={<FaLayerGroup />}
onClick={() => setTemplatesOpen((o) => !o)}
>
<span className="inline-flex items-center gap-1.5">
{translate('::App.DeveloperKitTableDesigner.ColumnTemplates')}
<FaChevronDown
className={`text-[9px] transition-transform ${templatesOpen ? 'rotate-180' : ''}`}
/>
</span>
</Button>
{templatesOpen && (
<div className="absolute left-0 top-full z-50 mt-1.5 w-[440px] max-w-[85vw] rounded-lg border border-gray-200 bg-white p-2 shadow-xl dark:border-gray-700 dark:bg-gray-800">
<div className="px-1.5 pb-1.5 text-[10px] font-semibold uppercase tracking-wider text-gray-400">
{translate('::App.DeveloperKitTableDesigner.ColumnTemplatesHint')}
</div>
<div className="grid grid-cols-2 gap-0.5">
{columnTemplates.map((tpl) => (
<button
key={tpl.key}
type="button"
className="flex items-start gap-2 rounded-md p-2 text-left transition-colors hover:bg-gray-100 dark:hover:bg-gray-700/60"
onClick={() => {
tpl.apply()
setTemplatesOpen(false)
}}
>
<span
className={`mt-0.5 flex h-6 w-6 shrink-0 items-center justify-center rounded-md text-[11px] text-white ${tpl.color}`}
>
{tpl.icon}
</span>
<span className="min-w-0">
<span className="block truncate text-xs font-semibold text-gray-700 dark:text-gray-100">
{tpl.label}
</span>
<span className="block truncate text-[10px] leading-snug text-gray-400">
{tpl.hint}
</span>
</span>
</button>
))}
</div>
</div>
)}
</div>
<Button
size="xs"
variant="plain"
icon={<FaRegClipboard />}
onClick={importColumnsFromRememberedCreateTable}
disabled={isEditMode}
>
{translate('::App.DeveloperKitTableDesigner.PasteCreateTableColumns')}
</Button>
</div>
<div className="flex items-center gap-2">
<span className="rounded-full bg-gray-100 px-2 py-0.5 text-[10px] font-medium text-gray-500 dark:bg-gray-700 dark:text-gray-300">
{columns.length} {translate('::App.SqlQueryManager.ColumnName')}
</span>
<Button
size="xs"
variant="plain"
icon={<FaTrash />}
onClick={clearAllColumns}
title={translate('::App.SqlQueryManager.ClearAllColumns')}
>
{translate('::App.SqlQueryManager.ClearAllColumns')}
</Button>
<Button
size="xs"
variant="solid"
color="blue-600"
icon={<FaPlus />}
onClick={addColumn}
>
{translate('::App.SqlQueryManager.AddColumn')}
</Button>
</div>
</div>
{/* Header row */}
<div className="grid grid-cols-12 gap-1 px-1 py-1 bg-gray-50 dark:bg-gray-800 rounded text-xs font-semibold text-gray-600 dark:text-gray-300">
<div className="col-span-4">{translate('::App.SqlQueryManager.ColumnName')}</div>
<div className="col-span-3">{translate('::App.SqlQueryManager.DataType')}</div>
<div className="col-span-1 text-center">{translate('::App.SqlQueryManager.Max')}</div>
<div className="col-span-1 text-center">
{translate('::App.SqlQueryManager.Nullable')}
</div>
<div className="col-span-2">{translate('::Abp.Mailing.Default')}</div>
<div className="col-span-1 text-center">
{translate('::App.SqlQueryManager.Actions')}
</div>
</div>
{/* Editable column rows */}
{duplicateColumnNames.size > 0 && (
<div className="px-1 py-1.5 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded text-xs text-red-600 dark:text-red-400">
{translate('::App.DeveloperKitTableDesigner.DuplicateColumnName')}:{' '}
{[...duplicateColumnNames].map((n) => (
<code key={n} className="bg-red-100 dark:bg-red-900/40 px-1 rounded mr-1">
{n}
</code>
))}
</div>
)}
<div className="flex flex-col gap-1 pr-1">
{columns.map((col, idx) => {
const isDuplicate =
col.columnName.trim() !== '' &&
duplicateColumnNames.has(col.columnName.trim().toLowerCase())
const isNewRow = isEditMode && !originalColumns.some((o) => o.id === col.id)
const origRow = isEditMode ? originalColumns.find((o) => o.id === col.id) : undefined
const isRenamedRow =
isEditMode &&
!!origRow &&
origRow.columnName.trim().toLowerCase() !== col.columnName.trim().toLowerCase()
const isModifiedRow =
isEditMode &&
!!origRow &&
!isNewRow &&
(origRow.dataType !== col.dataType ||
origRow.maxLength !== col.maxLength ||
origRow.isNullable !== col.isNullable)
const rowBg = isDuplicate
? 'outline outline-1 outline-red-400'
: isNewRow
? 'bg-green-50 dark:bg-green-900/20 outline outline-1 outline-green-400'
: isRenamedRow && isModifiedRow
? 'bg-purple-50 dark:bg-purple-900/20 outline outline-1 outline-purple-400'
: isRenamedRow
? 'bg-blue-50 dark:bg-blue-900/20 outline outline-1 outline-blue-400'
: isModifiedRow
? 'bg-yellow-50 dark:bg-yellow-900/20 outline outline-1 outline-yellow-400'
: ''
return (
<div
key={col.id}
className={`grid grid-cols-12 gap-2 bg-white dark:bg-gray-800 rounded items-center ${rowBg}`}
>
<div className="col-span-4">
<Input
size="sm"
type="text"
className={isDuplicate ? 'w-full border-red-400' : 'w-full'}
placeholder={translate('::App.SqlQueryManager.ColumnNamePlaceholder')}
value={col.columnName}
onChange={(e) => updateColumn(col.id, 'columnName', e.target.value)}
ref={(el) => {
if (el && col.id === newRowId) {
el.focus()
setNewRowId(null)
}
}}
/>
</div>
<div className="col-span-3">
<Select
size="sm"
className="w-full"
maxMenuHeight={200}
menuPortalTarget={document.body}
options={dataTypeOptions}
value={dataTypeOptions.filter((option) => option.value === col.dataType)}
onChange={(option) => {
if (!option) return
const dt = option.value as SqlDataType
updateColumn(col.id, 'dataType', dt)
if (dt !== 'nvarchar') updateColumn(col.id, 'maxLength', '')
else if (!col.maxLength) updateColumn(col.id, 'maxLength', '100')
}}
/>
</div>
<div className="col-span-1">
<Input
size="sm"
type="number"
className="w-full text-center"
placeholder="-"
value={col.maxLength}
disabled={col.dataType !== 'nvarchar'}
onChange={(e) => updateColumn(col.id, 'maxLength', e.target.value)}
/>
</div>
<div className="col-span-1 flex justify-center">
<Checkbox
checked={col.isNullable}
onChange={(checked) => updateColumn(col.id, 'isNullable', checked as boolean)}
/>
</div>
<div className="col-span-2">
<Input
size="sm"
type="text"
className="w-full"
placeholder={translate('::Abp.Mailing.Default')}
value={col.defaultValue}
onChange={(e) => updateColumn(col.id, 'defaultValue', e.target.value)}
/>
</div>
<div className="col-span-1 flex items-center justify-center gap-1">
<Button
onClick={() => moveColumn(col.id, 'up')}
disabled={idx === 0}
size="xs"
variant="plain"
shape="circle"
icon={<FaArrowUp className="text-xs" />}
className="!h-6 !w-6 !px-0 hover:!bg-gray-100 dark:hover:!bg-gray-700 disabled:opacity-30 text-gray-500"
title={translate('::App.Platform.MoveUp')}
/>
<Button
onClick={() => moveColumn(col.id, 'down')}
disabled={idx === columns.length - 1}
size="xs"
variant="plain"
shape="circle"
icon={<FaArrowDown className="text-xs" />}
className="!h-6 !w-6 !px-0 hover:!bg-gray-100 dark:hover:!bg-gray-700 disabled:opacity-30 text-gray-500"
title={translate('::App.Platform.MoveDown')}
/>
<Button
onClick={() => removeColumn(col.id)}
size="xs"
variant="plain"
shape="circle"
icon={<FaTrash className="text-xs" />}
className="!h-6 !w-6 !px-0 hover:!bg-red-50 dark:hover:!bg-red-900/30 text-red-500"
title={translate('::App.Platform.Delete')}
/>
</div>
</div>
)
})}
</div>
{/* PK guidance */}
{!isEditMode && !columns.some((c) => c.columnName.trim().toLowerCase() === 'id') && (
<div className="px-2 py-1.5 mt-2 bg-orange-50 dark:bg-orange-900/20 border border-orange-300 dark:border-orange-700 rounded text-xs text-orange-700 dark:text-orange-300">
{translate('::App.DeveloperKitTableDesigner.PkIdHint')}
</div>
)}
</>
)}
</div>
)
// ── Step 1: Entity Settings ────────────────────────────────────────────────
const renderEntitySettings = () => (
<div className="flex flex-col gap-2">
<div className="grid grid-cols-2 gap-2">
{/* Menu Name */}
<div className="col-span-2">
<div className="flex items-center justify-between mb-1">
<label className="block text-sm font-medium">
{translate('::App.SqlQueryManager.MenuName')} <span className="text-red-500">*</span>
</label>
<div className="flex items-center gap-2">
<Button
type="button"
disabled={isEditMode}
onClick={() => setMenuAddDialogOpen(true)}
size="xs"
variant="solid"
color="green-500"
className="flex items-center gap-2 !h-auto !px-2 !py-1 hover:!bg-green-600 disabled:opacity-40 disabled:cursor-not-allowed"
>
<FaPlus className="text-xs" /> {translate('::App.Platform.AddNewMenu')}
</Button>
{settings.menuValue && !isEditMode && (
<Button
type="button"
onClick={() => {
setSelectedMenuCode('')
setSettings((s) => ({ ...s, menuValue: '', menuPrefix: '', tableName: '' }))
}}
size="xs"
variant="default"
className="flex items-center gap-2!h-auto !px-2 !py-1 border-gray-300 dark:border-gray-600 text-gray-500 hover:text-red-500 hover:border-red-400"
>
<FaTimes className="text-xs" /> {translate('::App.Platform.ClearSelection')}
</Button>
)}
</div>
</div>
<SimpleMenuTreeSelect
selectedCode={selectedMenuCode}
onSelect={isEditMode ? () => {} : onMenuCodeSelect}
nodes={menuTree}
isLoading={menuLoading}
invalid={!settings.menuValue && !isEditMode && !menuLoading}
/>
{settings.menuValue && (
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
<span className="font-mono font-semibold text-indigo-600 dark:text-indigo-400">
{settings.menuValue}
</span>
</p>
)}
<MenuAddDialog
isOpen={menuAddDialogOpen}
onClose={() => setMenuAddDialogOpen(false)}
initialParentCode={selectedMenuCode}
initialOrder={999}
rawItems={rawMenuItems}
onSaved={(menu) =>
reloadMenus((items) => {
const savedMenu = items.find((item) => item.code === menu.code)
if (savedMenu?.code && savedMenu.moduleId) {
onMenuCodeSelect(savedMenu.code)
}
})
}
/>
</div>
{/* Entity Name */}
<div>
<label className="block text-sm font-medium mb-1">
{translate('::App.SqlQueryManager.EntityName')} <span className="text-red-500">*</span>
</label>
<Input
size="sm"
type="text"
disabled={isEditMode} // Entity name (and thus table name) cannot be changed in edit mode
className="w-full"
value={settings.entityName}
onChange={(e) => onEntityNameChange(e.target.value)}
placeholder={translate('::App.SqlQueryManager.EntityNamePlaceholder')}
/>
</div>
{/* Table Name (readonly, auto-generated) */}
<div>
<label className="block text-sm font-medium mb-1">
{translate('::App.Listform.ListformField.TableName')}
</label>
<Input
size="sm"
type="text"
readOnly
className="w-full bg-gray-50 cursor-not-allowed"
value={isEditMode ? (initialTableData?.tableName ?? '') : settings.tableName}
placeholder={translate('::App.SqlQueryManager.TableNameAutoGenerated')}
/>
</div>
</div>
{/* Warning: no Id column */}
{!isEditMode && !columns.some((c) => c.columnName.trim().toLowerCase() === 'id') && (
<div className="px-3 py-2 bg-amber-50 dark:bg-amber-900/20 border border-amber-300 dark:border-amber-700 rounded text-xs text-amber-700 dark:text-amber-300">
{translate('::App.DeveloperKitTableDesigner.ManualPkHint')}
</div>
)}
</div>
)
// ── Step 2: Relationships ─────────────────────────────────────────────────
const renderRelationships = () => (
<div className="space-y-3">
{/* Loading indicator for edit mode */}
{fksLoading && (
<div className="flex items-center justify-center py-10 text-gray-500 text-sm gap-2">
<span className="animate-spin">&#9696;</span>{' '}
{translate('::App.SqlQueryManager.LoadingFkConstraints')}
</div>
)}
{!fksLoading && (
<>
{/* Header */}
<div className="flex items-center justify-between">
<span className="text-xs text-gray-500">
{relationships.length === 0
? translate('::App.SqlQueryManager.NoRelationshipsDefined')
: `${relationships.length} ${translate('::App.SqlQueryManager.Relationship')}`}
</span>
<Button
onClick={openAddFk}
size="xs"
variant="solid"
color="indigo-600"
className="flex items-center gap-2 !h-auto !px-2 !py-1 !rounded-lg hover:!bg-indigo-700 transition-colors"
>
<FaPlus className="w-2.5 h-2.5" />{' '}
{translate('::App.SqlQueryManager.AddRelationship')}
</Button>
</div>
{/* Empty state */}
{relationships.length === 0 && (
<div className="py-8 text-center border-2 border-dashed border-gray-200 dark:border-gray-700 rounded-xl bg-gray-50 dark:bg-gray-800/50">
<FaLink className="text-3xl mx-auto text-gray-300 dark:text-gray-600 mb-2" />
<p className="text-sm text-gray-500">
{translate('::App.SqlQueryManager.NoRelationshipsYet')}
</p>
<p className="text-xs text-gray-400 mt-1">
{translate('::App.SqlQueryManager.StepIsOptional')}
</p>
</div>
)}
{/* Relationship cards */}
<div className="space-y-2 max-h-72 overflow-y-auto pr-1">
{relationships.map((rel) => (
<div
key={rel.id}
className="border border-slate-200 dark:border-gray-700 rounded-xl px-4 py-3 flex items-start gap-3 bg-white dark:bg-gray-800"
>
<div className="p-2 rounded-lg mt-0.5 flex-shrink-0 bg-indigo-50 dark:bg-indigo-900/20 text-indigo-600 dark:text-indigo-400">
<FaLink className="w-3 h-3" />
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<code className="text-sm font-semibold text-slate-900 dark:text-white">
[{rel.fkColumnName}]
</code>
<FaArrowRight className="w-3 h-3 text-gray-400 flex-shrink-0" />
<code className="text-sm font-semibold text-slate-900 dark:text-white">
[dbo].[{rel.referencedTable}].[{rel.referencedColumn || 'Id'}]
</code>
<span className="text-xs px-2 py-0.5 rounded-full bg-indigo-100 dark:bg-indigo-900/30 text-indigo-700 dark:text-indigo-300 font-medium">
{REL_TYPES.find((t) => t.value === rel.relationshipType)?.label}
</span>
</div>
<div className="flex items-center gap-3 mt-1 text-xs text-gray-500 flex-wrap">
<span>
ON DELETE: <strong>{rel.cascadeDelete}</strong>
</span>
<span>
ON UPDATE: <strong>{rel.cascadeUpdate}</strong>
</span>
{rel.isRequired && (
<span className="text-orange-600 dark:text-orange-400">
{translate('::App.Listform.ListformField.Required')}
</span>
)}
{rel.description && (
<span className="text-gray-400 italic">{rel.description}</span>
)}
</div>
</div>
<div className="flex items-center gap-1 flex-shrink-0">
<Button
onClick={() => openEditFk(rel)}
title={translate('::App.Platform.Edit')}
size="xs"
variant="plain"
shape="circle"
icon={<FaEdit className="w-3.5 h-3.5" />}
className="!h-7 !w-7 !px-0 text-gray-400 hover:text-indigo-600 hover:!bg-indigo-50 dark:hover:!bg-indigo-900/20 transition-colors"
/>
<Button
onClick={() => deleteFk(rel.id)}
title={translate('::App.Platform.Delete')}
size="xs"
variant="plain"
shape="circle"
icon={<FaTrash className="w-3.5 h-3.5" />}
className="!h-7 !w-7 !px-0 text-gray-400 hover:text-red-600 hover:!bg-red-50 dark:hover:!bg-red-900/20 transition-colors"
/>
</div>
</div>
))}
</div>
</>
)}
{/* FK Add/Edit Modal */}
{fkModalOpen &&
createPortal(
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50 backdrop-blur-sm">
<div className="bg-white dark:bg-gray-800 rounded-2xl shadow-2xl w-full max-w-lg flex flex-col">
{/* Modal Header */}
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-200 dark:border-gray-700">
<h2 className="text-base font-bold text-gray-900 dark:text-white">
{editingFkId
? translate('::App.SqlQueryManager.EditRelationship')
: translate('::App.SqlQueryManager.AddNewRelationship')}
</h2>
<Button
onClick={() => setFkModalOpen(false)}
size="sm"
variant="plain"
shape="circle"
icon={<FaTimes className="w-4 h-4" />}
className="!h-8 !w-8 !px-0 text-gray-400 hover:text-gray-600 transition-colors"
/>
</div>
{/* Modal Body */}
<div className="flex-1 overflow-y-auto px-6 py-4 space-y-4">
{/* Relationship type */}
<div>
<label className="block text-xs font-semibold text-gray-700 dark:text-gray-300 mb-2">
{translate('::App.SqlQueryManager.RelationshipType')}
</label>
<div className="flex gap-2">
{REL_TYPES.map((t) => (
<Button
key={t.value}
type="button"
onClick={() => setFkForm((f) => ({ ...f, relationshipType: t.value }))}
variant="plain"
shape="none"
className={`flex-1 !h-auto !rounded-lg border !py-2 !px-3 text-sm font-medium transition-all ${
fkForm.relationshipType === t.value
? 'bg-indigo-600 border-indigo-600 text-white'
: 'bg-white dark:bg-gray-700 border-gray-200 dark:border-gray-600 text-gray-600 dark:text-gray-300 hover:border-indigo-300'
}`}
>
{t.label}
<span className="block text-xs opacity-70">{translate('::' + t.desc)}</span>
</Button>
))}
</div>
</div>
{/* FK column / Referenced table & column */}
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-xs font-semibold text-gray-700 dark:text-gray-300 mb-1.5">
{translate('::App.SqlQueryManager.FkColumnInThisTable')}
</label>
<Select
size="sm"
className="w-full"
isClearable
placeholder={translate('::App.Platform.Select')}
menuPortalTarget={document.body}
options={fkColumnOptions}
value={fkColumnOptions.filter(
(option) => option.value === fkForm.fkColumnName,
)}
onChange={(option) =>
setFkForm((f) => ({ ...f, fkColumnName: option?.value ?? '' }))
}
/>
</div>
<div>
<label className="block text-xs font-semibold text-gray-700 dark:text-gray-300 mb-1.5">
{translate('::App.SqlQueryManager.TargetTable')}
</label>
<Select
size="sm"
className="w-full"
isClearable
placeholder={translate('::App.Platform.Select')}
menuPortalTarget={document.body}
options={referencedTableOptions}
value={referencedTableOptions.filter(
(option) => option.value === fkForm.referencedTable,
)}
onChange={(option) => {
const val = option?.value ?? ''
setFkForm((f) => ({ ...f, referencedTable: val, referencedColumn: '' }))
loadTargetColumns(val)
}}
/>
</div>
</div>
<div>
<label className="block text-xs font-semibold text-gray-700 dark:text-gray-300 mb-1.5">
{translate('::App.SqlQueryManager.TargetColumn')}
{targetColsLoading
? `${translate('::App.Platform.LoadingWithThreeDot')}`
: ''}
</label>
<Select
size="sm"
className="w-full"
isClearable
isDisabled={targetColsLoading || targetTableColumns.length === 0}
placeholder={translate('::App.SqlQueryManager.SelectTargetTableFirst')}
menuPortalTarget={document.body}
options={referencedColumnOptions}
value={referencedColumnOptions.filter(
(option) => option.value === fkForm.referencedColumn,
)}
onChange={(option) =>
setFkForm((f) => ({ ...f, referencedColumn: option?.value ?? '' }))
}
/>
{fkForm.referencedTable &&
!targetColsLoading &&
targetTableKeyColumns.length === 0 && (
<p className="mt-1 text-xs text-amber-600 dark:text-amber-400">
{translate('::App.DeveloperKitTableDesigner.NoKeyColumnInTarget')}
</p>
)}
</div>
{/* Cascade */}
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-xs font-semibold text-gray-700 dark:text-gray-300 mb-1.5">
{translate('::App.SqlQueryManager.CascadeUpdate')}
</label>
<Select
size="sm"
className="w-full"
menuPortalTarget={document.body}
options={cascadeOptions}
value={cascadeOptions.filter(
(option) => option.value === fkForm.cascadeUpdate,
)}
onChange={(option) =>
option &&
setFkForm((f) => ({
...f,
cascadeUpdate: option.value as CascadeBehavior,
}))
}
/>
</div>
<div>
<label className="block text-xs font-semibold text-gray-700 dark:text-gray-300 mb-1.5">
{translate('::App.SqlQueryManager.CascadeDelete')}
</label>
<Select
size="sm"
className="w-full"
menuPortalTarget={document.body}
options={cascadeOptions}
value={cascadeOptions.filter(
(option) => option.value === fkForm.cascadeDelete,
)}
onChange={(option) =>
option &&
setFkForm((f) => ({
...f,
cascadeDelete: option.value as CascadeBehavior,
}))
}
/>
</div>
</div>
{/* Options */}
<div className="flex items-center gap-6">
<label className="flex items-center gap-2 cursor-pointer">
<Input
size="sm"
type="checkbox"
checked={fkForm.isRequired}
onChange={(e) => setFkForm((f) => ({ ...f, isRequired: e.target.checked }))}
className="w-4 text-indigo-600"
/>
<span className="text-sm text-gray-700 dark:text-gray-300">
{translate('::App.Listform.ListformField.Required')}
</span>
</label>
</div>
<div>
<label className="block text-xs font-semibold text-gray-700 dark:text-gray-300 mb-1.5">
{translate('::App.Listform.ListformField.Description')}
</label>
<textarea
value={fkForm.description}
onChange={(e) => setFkForm((f) => ({ ...f, description: e.target.value }))}
rows={2}
placeholder={translate('::App.SqlQueryManager.OptionalDescriptionPlaceholder')}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg text-sm dark:bg-gray-700 dark:text-white focus:ring-2 focus:ring-indigo-500 resize-none"
/>
</div>
</div>
{/* Modal Footer */}
<div className="flex items-center justify-end gap-2 px-6 py-4 border-t border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800/50 rounded-b-2xl">
<Button
onClick={() => setFkModalOpen(false)}
size="sm"
variant="plain"
className="text-gray-600 dark:text-gray-300 hover:!bg-gray-100 dark:hover:!bg-gray-700 !rounded-lg transition-colors"
>
{translate('::App.Platform.Cancel')}
</Button>
<Button
onClick={saveFk}
disabled={
!fkForm.fkColumnName.trim() ||
!fkForm.referencedTable.trim() ||
!fkForm.referencedColumn.trim() ||
!targetTableKeyColumns.some(
(c) => c.toLowerCase() === fkForm.referencedColumn.trim().toLowerCase(),
)
}
size="sm"
variant="solid"
color="indigo-600"
className="hover:!bg-indigo-700 disabled:opacity-50 !rounded-lg transition-colors"
>
{translate('::App.Platform.Save')}
</Button>
</div>
</div>
</div>,
document.body,
)}
</div>
)
// ── Step 3: Index / Key ────────────────────────────────────────────────────
const INDEX_TYPE_STYLES: Record<IndexType, { icon: string; badge: string }> = {
PrimaryKey: {
icon: 'bg-yellow-50 dark:bg-yellow-900/20 text-yellow-600 dark:text-yellow-400',
badge: 'bg-yellow-100 dark:bg-yellow-900/30 text-yellow-700 dark:text-yellow-300',
},
UniqueKey: {
icon: 'bg-green-50 dark:bg-green-900/20 text-green-600 dark:text-green-400',
badge: 'bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-300',
},
Index: {
icon: 'bg-blue-50 dark:bg-blue-900/20 text-blue-600 dark:text-blue-400',
badge: 'bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-300',
},
}
const renderIndexes = () => (
<div className="space-y-3">
{indexesLoading && (
<div className="flex items-center justify-center py-10 text-gray-500 text-sm gap-2">
<span className="animate-spin">&#9696;</span>{' '}
{translate('::App.Platform.LoadingWithThreeDot')}
</div>
)}
{!indexesLoading && (
<>
{/* Header */}
<div className="flex items-center justify-between">
<span className="text-xs text-gray-500">
{indexes.length === 0
? translate('::App.SqlQueryManager.NoIndexesDefined')
: `${indexes.length} ${translate('::App.SqlQueryManager.IndexKey')}`}
</span>
<Button
onClick={openAddIndex}
size="xs"
variant="solid"
color="indigo-600"
className="flex items-center gap-2 !h-auto !px-2 !py-1 !rounded-lg hover:!bg-indigo-700 transition-colors"
>
<FaPlus className="w-2.5 h-2.5" /> {translate('::App.SqlQueryManager.AddIndexKey')}
</Button>
</div>
{/* Empty state */}
{indexes.length === 0 && (
<div className="py-8 text-center border-2 border-dashed border-gray-200 dark:border-gray-700 rounded-xl bg-gray-50 dark:bg-gray-800/50">
<FaKey className="text-3xl mx-auto text-gray-300 dark:text-gray-600 mb-2" />
<p className="text-sm text-gray-500">
{translate('::App.SqlQueryManager.NoIndexesDefined')}
</p>
<p className="text-xs text-gray-400 mt-1">
{translate('::App.SqlQueryManager.StepIsOptional')}
</p>
</div>
)}
{/* Index cards */}
<div className="space-y-2 max-h-72 overflow-y-auto pr-1">
{indexes.map((idx) => {
const styles = INDEX_TYPE_STYLES[idx.indexType]
return (
<div
key={idx.id}
className="border border-slate-200 dark:border-gray-700 rounded-xl px-4 py-3 flex items-start gap-3 bg-white dark:bg-gray-800"
>
<div className={`p-2 rounded-lg mt-0.5 flex-shrink-0 ${styles.icon}`}>
<FaKey className="w-3 h-3" />
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<code className="text-sm font-semibold text-slate-900 dark:text-white">
[{idx.indexName}]
</code>
<span
className={`text-xs px-2 py-0.5 rounded-full font-medium ${styles.badge}`}
>
{idx.indexType}
</span>
<span className="text-xs text-gray-500">
{idx.isClustered ? 'CLUSTERED' : 'NONCLUSTERED'}
</span>
</div>
<div className="flex items-center gap-1 mt-1 flex-wrap">
{idx.columns.map((col, ci) => (
<span
key={ci}
className="text-xs bg-gray-100 dark:bg-gray-700 px-1.5 py-0.5 rounded font-mono"
>
{col.columnName} {col.order}
</span>
))}
</div>
{idx.description && (
<p className="text-xs text-gray-400 mt-1 italic">{idx.description}</p>
)}
</div>
<div className="flex items-center gap-1 flex-shrink-0">
<Button
onClick={() => openEditIndex(idx)}
title={translate('::App.Platform.Edit')}
size="xs"
variant="plain"
shape="circle"
icon={<FaEdit className="w-3.5 h-3.5" />}
className="!h-7 !w-7 !px-0 text-gray-400 hover:text-indigo-600 hover:!bg-indigo-50 dark:hover:!bg-indigo-900/20 transition-colors"
/>
<Button
onClick={() => deleteIndex(idx.id)}
title={translate('::App.Platform.Delete')}
size="xs"
variant="plain"
shape="circle"
icon={<FaTrash className="w-3.5 h-3.5" />}
className="!h-7 !w-7 !px-0 text-gray-400 hover:text-red-600 hover:!bg-red-50 dark:hover:!bg-red-900/20 transition-colors"
/>
</div>
</div>
)
})}
</div>
</>
)}
{/* Index Add/Edit Modal */}
{indexModalOpen &&
createPortal(
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50 backdrop-blur-sm">
<div className="bg-white dark:bg-gray-800 rounded-2xl shadow-2xl w-full max-w-lg flex flex-col max-h-[90vh]">
{/* Modal Header */}
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-200 dark:border-gray-700">
<h2 className="text-base font-bold text-gray-900 dark:text-white">
{editingIndexId
? translate('::App.SqlQueryManager.EditIndexKey')
: translate('::App.SqlQueryManager.AddNewIndexKey')}
</h2>
<Button
onClick={() => setIndexModalOpen(false)}
size="sm"
variant="plain"
shape="circle"
icon={<FaTimes className="w-4 h-4" />}
className="!h-8 !w-8 !px-0 text-gray-400 hover:text-gray-600 transition-colors"
/>
</div>
{/* Modal Body */}
<div className="flex-1 overflow-y-auto px-6 py-4 space-y-4">
{/* Index Type */}
<div>
<label className="block text-xs font-semibold text-gray-700 dark:text-gray-300 mb-2">
{translate('::App.SqlQueryManager.IndexKeyType')}
</label>
<div className="flex gap-2">
{INDEX_TYPES.map((t) => (
<Button
key={t.value}
type="button"
onClick={() =>
setIndexForm((f) => ({
...f,
indexType: t.value,
indexName: buildIndexName(t.value, f.columns),
}))
}
variant="plain"
shape="none"
className={`flex-1 !h-auto !rounded-lg border !py-2 !px-3 text-sm font-medium transition-all ${
indexForm.indexType === t.value
? 'bg-indigo-600 border-indigo-600 text-white'
: 'bg-white dark:bg-gray-700 border-gray-200 dark:border-gray-600 text-gray-600 dark:text-gray-300 hover:border-indigo-300'
}`}
>
{t.label}
<span className="block text-xs opacity-70">{translate('::' + t.desc)}</span>
</Button>
))}
</div>
</div>
{/* Index / Constraint Name */}
<div>
<label className="block text-xs font-semibold text-gray-700 dark:text-gray-300 mb-1.5">
{translate('::App.SqlQueryManager.IndexConstraintName')}
</label>
<Input
size="sm"
type="text"
value={indexForm.indexName}
onChange={(e) => setIndexForm((f) => ({ ...f, indexName: e.target.value }))}
placeholder={
buildIndexName(indexForm.indexType, indexForm.columns) || `PK_EntityName_Id`
}
className="w-full"
/>
</div>
{/* Clustered */}
<div className="flex items-center gap-3">
<label className="flex items-center gap-2 cursor-pointer">
<Input
size="sm"
type="checkbox"
checked={indexForm.isClustered}
onChange={(e) =>
setIndexForm((f) => ({ ...f, isClustered: e.target.checked }))
}
className="w-4 text-indigo-600"
/>
<span className="text-sm text-gray-700 dark:text-gray-300">Clustered</span>
</label>
</div>
{/* Column picker */}
<div>
<label className="block text-xs font-semibold text-gray-700 dark:text-gray-300 mb-2">
{translate('::App.Platform.Columns')}
</label>
<div className="border border-gray-200 dark:border-gray-600 rounded-lg overflow-hidden">
<div className="grid grid-cols-12 gap-2 px-3 py-1.5 bg-gray-50 dark:bg-gray-700 text-xs font-semibold text-gray-500">
<div className="col-span-1" />
<div className="col-span-7">
{translate('::App.Listform.ListformField.Column')}
</div>
<div className="col-span-4">
{translate('::App.SqlQueryManager.SortOrder')}
</div>
</div>
<div className="max-h-44 overflow-y-auto divide-y divide-gray-100 dark:divide-gray-700">
{columns
.filter((c) => c.columnName.trim())
.map((col) => {
const existing = indexForm.columns.find(
(ic) => ic.columnName === col.columnName,
)
return (
<div
key={col.id}
className="grid grid-cols-12 gap-2 px-3 py-1.5 items-center"
>
<div className="col-span-1">
<Input
size="sm"
type="checkbox"
checked={!!existing}
onChange={(e) => {
if (e.target.checked) {
setIndexForm((f) => {
const newCols = [
...f.columns,
{ columnName: col.columnName, order: 'ASC' as const },
]
return {
...f,
columns: newCols,
indexName: buildIndexName(f.indexType, newCols),
}
})
} else {
setIndexForm((f) => {
const newCols = f.columns.filter(
(ic) => ic.columnName !== col.columnName,
)
return {
...f,
columns: newCols,
indexName: buildIndexName(f.indexType, newCols),
}
})
}
}}
className="w-4 text-indigo-600"
/>
</div>
<div className="col-span-7 text-sm font-mono text-gray-800 dark:text-gray-200">
{col.columnName}
</div>
<div className="col-span-4">
{existing && (
<Select
size="sm"
className="w-full"
menuPortalTarget={document.body}
options={indexOrderOptions}
value={indexOrderOptions.filter(
(option) => option.value === existing.order,
)}
onChange={(option) =>
option &&
setIndexForm((f) => ({
...f,
columns: f.columns.map((ic) =>
ic.columnName === col.columnName
? { ...ic, order: option.value }
: ic,
),
}))
}
/>
)}
</div>
</div>
)
})}
</div>
</div>
{indexForm.columns.length > 0 && (
<p className="text-xs text-gray-400 mt-1">
{translate('::App.SqlQueryManager.Selected')}{' '}
{indexForm.columns.map((c) => `[${c.columnName}] ${c.order}`).join(', ')}
</p>
)}
</div>
<div>
<label className="block text-xs font-semibold text-gray-700 dark:text-gray-300 mb-1.5">
{translate('::App.Listform.ListformField.Description')}
</label>
<textarea
value={indexForm.description}
onChange={(e) => setIndexForm((f) => ({ ...f, description: e.target.value }))}
rows={2}
placeholder={translate('::App.SqlQueryManager.OptionalDescriptionPlaceholder')}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg text-sm dark:bg-gray-700 dark:text-white focus:ring-2 focus:ring-indigo-500 resize-none"
/>
</div>
</div>
{/* Modal Footer */}
<div className="flex items-center justify-end gap-2 px-6 py-4 border-t border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800/50 rounded-b-2xl">
<Button
onClick={() => setIndexModalOpen(false)}
size="sm"
variant="plain"
className="text-gray-600 dark:text-gray-300 hover:!bg-gray-100 dark:hover:!bg-gray-700 !rounded-lg transition-colors"
>
{translate('::App.Platform.Cancel')}
</Button>
<Button
onClick={saveIndex}
disabled={!indexForm.indexName.trim() || indexForm.columns.length === 0}
size="sm"
variant="solid"
color="indigo-600"
className="hover:!bg-indigo-700 disabled:opacity-50 !rounded-lg transition-colors"
>
{translate('::App.Platform.Save')}
</Button>
</div>
</div>
</div>,
document.body,
)}
</div>
)
// ── Step: CRUD Endpoints ───────────────────────────────────────────────────
const crudEntityName = toEntityName(
settings.tableName || initialTableData?.tableName || settings.entityName || '',
)
const allCrudSelected = crudOperations.length === CRUD_OPERATION_TYPES.length
const renderCrudEndpoints = () => (
<div className="flex flex-col gap-4">
{crudLoading && (
<p className="mb-0 text-xs text-gray-400">{translate('::App.Platform.Loading')}</p>
)}
<label className="flex items-start gap-3 cursor-pointer rounded-lg border p-3 dark:border-gray-700">
<Checkbox checked={generateCrud} onChange={(checked) => setGenerateCrud(checked)} />
<span>
<span className="block font-medium">
{translate('::App.SqlQueryManager.GenerateCrudEndpointsAfterDeploy')}
</span>
<span className="block text-xs text-gray-500 dark:text-gray-400">
{translate('::App.SqlQueryManager.GenerateCrudEndpointsHint')}
{crudEntityName ? `${crudEntityName}` : ''}
</span>
</span>
</label>
<div className={generateCrud ? '' : 'opacity-40 pointer-events-none'}>
<div className="mb-2 flex items-center justify-between">
<span className="text-sm font-medium">
{translate('::App.SqlQueryManager.SelectCrudOperations')}
</span>
<Button
size="xs"
variant="plain"
className="!h-auto !px-1 !py-0 text-xs text-blue-500 hover:underline"
onClick={() => setCrudOperations(allCrudSelected ? [] : [...CRUD_OPERATION_TYPES])}
>
{allCrudSelected
? translate('::App.SqlQueryManager.ClearSelection')
: translate('::App.Platform.SelectAll')}
</Button>
</div>
<div className="flex flex-col gap-2">
{CRUD_OPERATION_TYPES.map((operation) => {
const meta = getOperationMeta(operation)
const checked = crudOperations.includes(operation)
return (
<label
key={operation}
className="flex items-center gap-3 cursor-pointer rounded-lg border px-3 py-2 dark:border-gray-700"
>
<Checkbox checked={checked} onChange={() => toggleCrudOperation(operation)} />
<span className={meta.color}>{meta.icon}</span>
<span className="text-sm font-medium">{operation}</span>
<span className="ml-auto text-xs text-gray-400">
{translate(`::App.SqlQueryManager.CrudOperation${operation}`)}
</span>
</label>
)
})}
</div>
{generateCrud && crudOperations.length === 0 && (
<p className="mt-2 mb-0 text-xs text-amber-600 dark:text-amber-400">
{translate('::App.SqlQueryManager.SelectAtLeastOneCrudOperation')}
</p>
)}
</div>
<p className="mb-0 text-xs text-gray-500 dark:text-gray-400">
{translate('::App.SqlQueryManager.CrudEndpointsInactiveNote')}
</p>
</div>
)
// ── Step: T-SQL Preview ────────────────────────────────────────────────────
const renderSqlPreview = () => (
<div className="flex flex-col gap-3">
<div className="flex items-center justify-between">
<p className="text-sm text-gray-600 dark:text-gray-400">
{translate('::App.SqlQueryManager.GeneratedSqlDescription')}
</p>
<Button
size="xs"
variant="plain"
className="!h-auto !px-1 !py-0 text-xs text-blue-500 hover:underline"
onClick={() => navigator.clipboard.writeText(generatedSql)}
>
{translate('::App.Platform.Copy')}
</Button>
</div>
<pre className="bg-gray-900 text-green-300 rounded-lg p-4 text-xs overflow-auto max-h-96 leading-relaxed whitespace-pre">
{generatedSql}
</pre>
</div>
)
// ── Render ─────────────────────────────────────────────────────────────────
return (
<Dialog isOpen={isOpen} onClose={handleClose} onRequestClose={handleClose} width={1200}>
<Dialog.Header className="flex flex-col gap-2 -mt-5 p-2 ">
{/* Header */}
<div className="flex items-center gap-3 border-b pb-3 flex-shrink-0">
<FaTable className="text-2xl text-blue-500" />
<div>
<h5 className="font-bold">
{isEditMode
? `${translate('::App.SqlQueryManager.EditTable')}${initialTableData?.tableName}`
: translate('::App.SqlQueryManager.TableDesigner')}
</h5>
<p className="text-xs text-gray-500">
{isEditMode
? translate('::App.SqlQueryManager.EditModeDescription')
: translate('::App.SqlQueryManager.NewModeDescription')}
</p>
</div>
</div>
{/* Steps */}
<div className="flex-shrink-0">{renderStepIndicator()}</div>
</Dialog.Header>
<Dialog.Body className="flex flex-col gap-2">
{/* Content */}
<StepContentWrapper>
{currentStepKey === 'columns' && renderColumnDesigner()}
{currentStepKey === 'settings' && renderEntitySettings()}
{currentStepKey === 'indexes' && renderIndexes()}
{currentStepKey === 'relationships' && renderRelationships()}
{currentStepKey === 'crud' && renderCrudEndpoints()}
{currentStepKey === 'sql' && renderSqlPreview()}
</StepContentWrapper>
</Dialog.Body>
<Dialog.Footer className="flex justify-between items-center border-t pt-3 mt-1">
<Button variant="plain" onClick={handleClose}>
{translate('::App.Platform.Cancel')}
</Button>
<div className="flex items-center gap-2">
{step > 0 && (
<Button variant="default" onClick={handleBack}>
{translate('::App.Platform.Back')}
</Button>
)}
{step < lastStep ? (
<Button variant="solid" color="blue-600" onClick={handleNext} disabled={!canGoNext()}>
{translate('::App.Platform.Next')}
</Button>
) : (
<Button
variant="solid"
color="green-600"
icon={<FaCloudUploadAlt />}
onClick={handleDeploy}
loading={isDeploying}
disabled={
!dataSource ||
isDeploying ||
(isEditMode &&
generatedSql.includes(translate('::App.TableDesignerDiff.NoChanges')))
}
>
{translate('::App.Platform.Deploy')}
</Button>
)}
</div>
</Dialog.Footer>
</Dialog>
)
}
export default SqlTableDesignerDialog