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

2434 lines
97 KiB
TypeScript
Raw Normal View History

2026-03-01 17:40:25 +00:00
import { useState, useCallback, useMemo, useEffect } from 'react'
import { createPortal } from 'react-dom'
2026-03-01 17:40:25 +00:00
import { Button, Dialog, Notification, toast, Checkbox } from '@/components/ui'
import {
FaPlus,
FaTrash,
FaArrowUp,
FaArrowDown,
FaTable,
FaCloudUploadAlt,
FaCheck,
FaLink,
FaEdit,
FaTimes,
FaArrowRight,
2026-03-03 07:34:25 +00:00
FaChevronDown,
FaChevronRight,
2026-03-09 13:35:00 +00:00
FaKey,
2026-03-01 17:40:25 +00:00
} from 'react-icons/fa'
import { sqlObjectManagerService } from '@/services/sql-query-manager.service'
2026-03-03 07:34:25 +00:00
import { getMenus } from '@/services/menu.service'
2026-03-01 17:40:25 +00:00
import { useLocalization } from '@/utils/hooks/useLocalization'
import { CascadeBehavior, SqlTableRelation, RelationshipType } from '@/proxy/developerKit/models'
2026-03-03 07:34:25 +00:00
import navigationIcon from '@/proxy/menus/navigation-icon.config'
import { MenuItem } from '@/proxy/menus/menu'
import {
MenuTreeNode,
buildMenuTree,
filterNonLinkNodes,
} from '@/views/admin/listForm/WizardStep1'
import { MenuAddDialog } from '../shared/MenuAddDialog'
2026-03-01 17:40:25 +00:00
// ─── Types ────────────────────────────────────────────────────────────────────
type SqlDataType =
| 'nvarchar'
| 'nvarchar(MAX)'
| 'int'
| 'bigint'
| 'decimal'
| 'float'
| 'bit'
| '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?: () => void
initialTableData?: { schemaName: string; tableName: string } | null
}
2026-03-09 13:35:00 +00:00
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
}
2026-03-01 17:40:25 +00:00
// ─── Constants ────────────────────────────────────────────────────────────────
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: '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',
},
2026-03-01 17:40:25 +00:00
{
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 REL_TYPES: { value: RelationshipType; label: string; desc: string }[] = [
{ value: 'OneToMany', label: '1 → N', desc: 'Bire-çok' },
{ value: 'OneToOne', label: '1 → 1', desc: 'Bire-bir' },
]
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'> = {
2026-03-01 17:40:25 +00:00
relationshipType: 'OneToMany',
fkColumnName: '',
referencedTable: '',
referencedColumn: 'Id',
cascadeDelete: 'NoAction',
cascadeUpdate: 'Cascade',
2026-03-01 17:40:25 +00:00
isRequired: false,
description: '',
}
2026-03-09 13:35:00 +00:00
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.IndexType_PrimaryKey_Desc' },
{ value: 'UniqueKey', label: 'Unique Key', desc: 'App.SqlQueryManager.IndexType_UniqueKey_Desc' },
{ value: 'Index', label: 'Index', desc: 'App.SqlQueryManager.IndexType_Index_Desc' },
]
2026-03-01 17:40:25 +00:00
const TENANT_COLUMN: ColumnDefinition = {
id: '__TenantId',
columnName: 'TenantId',
dataType: 'uniqueidentifier',
maxLength: '',
isNullable: true,
defaultValue: '',
description: 'Tenant ID for multi-tenancy',
}
// ─── 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 generateCreateTableSql(
columns: ColumnDefinition[],
settings: TableSettings,
relationships: SqlTableRelation[],
2026-03-09 13:35:00 +00:00
indexes: TableIndex[],
2026-03-01 17:40:25 +00:00
): string {
const tableName = settings.tableName || 'NewTable'
const fullTableName = `[dbo].[${tableName}]`
const userCols = columns.filter((c) => c.columnName.trim())
const allBodyCols = userCols
2026-03-16 09:52:15 +00:00
const bodyLines = allBodyCols.map((c, i) => colToSqlLine(c, i < allBodyCols.length - 1))
2026-03-01 17:40:25 +00:00
// 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}`
2026-03-03 07:34:25 +00:00
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()
2026-03-01 17:40:25 +00:00
fkLines.push('')
fkLines.push(`ALTER TABLE ${fullTableName}`)
fkLines.push(` ADD CONSTRAINT [${constraintName}]`)
fkLines.push(` FOREIGN KEY ([${rel.fkColumnName}])`)
2026-03-03 07:34:25 +00:00
fkLines.push(
` REFERENCES [dbo].[${rel.referencedTable}] ([${rel.referencedColumn || 'Id'}])`,
)
2026-03-01 17:40:25 +00:00
fkLines.push(` ON DELETE ${cascadeDelete}`)
fkLines.push(` ON UPDATE ${cascadeUpdate};`)
}
2026-03-09 13:35:00 +00:00
// Index / Key SQL lines
const indexLines: string[] = []
for (const idx of indexes) {
if (idx.columns.length === 0) continue
const clustered = idx.isClustered ? 'CLUSTERED' : 'NONCLUSTERED'
const colsSql = idx.columns.map((c) => `[${c.columnName}] ${c.order}`).join(', ')
indexLines.push('')
if (idx.indexType === 'PrimaryKey') {
indexLines.push(`-- 🔑 Primary Key: [${idx.indexName}]`)
indexLines.push(`ALTER TABLE ${fullTableName}`)
indexLines.push(` ADD CONSTRAINT [${idx.indexName}] PRIMARY KEY ${clustered} (${colsSql});`)
} else if (idx.indexType === 'UniqueKey') {
indexLines.push(`-- 🔒 Unique Key: [${idx.indexName}]`)
indexLines.push(`ALTER TABLE ${fullTableName}`)
indexLines.push(` ADD CONSTRAINT [${idx.indexName}] UNIQUE ${clustered} (${colsSql});`)
} else {
indexLines.push(`-- 📋 Index: [${idx.indexName}]`)
indexLines.push(
`CREATE ${idx.isClustered ? 'CLUSTERED ' : ''}INDEX [${idx.indexName}] ON ${fullTableName} (${colsSql});`,
)
}
}
2026-03-01 17:40:25 +00:00
const lines: string[] = [
`/* ── Table: ${fullTableName} ── */`,
...(settings.entityName ? [`/* Entity Name: ${settings.entityName} */`] : []),
'',
`CREATE TABLE ${fullTableName}`,
`(`,
...bodyLines,
`);`,
2026-03-09 13:35:00 +00:00
...indexLines,
2026-03-18 09:00:11 +00:00
...(fkLines.length > 0 ? ['/* Foreign Key Constraints */'] : []),
...fkLines,
2026-03-01 17:40:25 +00:00
'',
`/* Verify: SELECT TOP 10 * FROM ${fullTableName}; */`,
]
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.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: '',
}
}
/** Generate ALTER TABLE diff SQL (edit mode) */
function generateAlterTableSql(
originalCols: ColumnDefinition[],
currentCols: ColumnDefinition[],
tableName: string,
relationships: SqlTableRelation[],
originalRelationships: SqlTableRelation[],
2026-03-09 13:35:00 +00:00
indexes: TableIndex[],
originalIndexes: TableIndex[],
2026-03-01 17:40:25 +00:00
): 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(`-- Yeni Sütun: [${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(`-- ❌ Silinen Sütun: [${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
2026-03-03 07:34:25 +00:00
const nameChanged =
orig.columnName.trim().toLowerCase() !== col.columnName.trim().toLowerCase()
2026-03-01 17:40:25 +00:00
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(`-- 🔄 Sütun Yeniden Adlandırma: [${orig.columnName}] → [${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
? `-- ✏️ Değiştirilen Sütun (yeniden adlandırıldıktan sonra): [${col.columnName}]`
: `-- ✏️ Değiştirilen Sütun: [${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) =>
2026-03-03 07:34:25 +00:00
b === 'NoAction'
? 'NO ACTION'
: b
.replace(/([A-Z])/g, ' $1')
.trim()
.toUpperCase()
2026-03-01 17:40:25 +00:00
const addFkSql = (rel: SqlTableRelation) => {
2026-03-01 17:40:25 +00:00
const cname = rel.constraintName ?? `FK_${tableName}_${rel.fkColumnName}`
lines.push(`-- 🔗 FK Ekle: [${rel.fkColumnName}] → [${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>()
2026-03-01 17:40:25 +00:00
originalRelationships.forEach((r) => origRelById.set(r.id, r))
const curRelById = new Map<string, SqlTableRelation>()
2026-03-01 17:40:25 +00:00
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(`-- ❌ FK Kaldır: [${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(`-- ✏️ FK Güncelle (drop + re-add): [${cname}]`)
lines.push(`ALTER TABLE ${fullTableName}`)
lines.push(` DROP CONSTRAINT [${cname}];`)
lines.push('')
addFkSql(rel)
}
} else {
// New FK
hasChanges = true
addFkSql(rel)
}
})
2026-03-09 13:35:00 +00:00
// 🔑 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(`-- ❌ Kaldır: [${ix.indexName}]`)
lines.push(`ALTER TABLE ${fullTableName}`)
lines.push(` DROP CONSTRAINT [${ix.indexName}];`)
} else {
lines.push(`-- ❌ Kaldır: [${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(
`CREATE ${ix.isClustered ? 'CLUSTERED ' : ''}INDEX [${ix.indexName}] ON ${fullTableName} (${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(`-- ✏️ Index Güncelle (drop + re-create): [${orig.indexName}]`)
dropIndexSql(orig)
addIndexSql(cur)
}
}
})
2026-03-01 17:40:25 +00:00
if (!hasChanges) {
lines.push(
2026-03-09 13:35:00 +00:00
'/* Henüz değişiklik yapılmadı. Sütunları ekleyin/silin/düzeyin ya da ilişki/index ekleyin. */',
2026-03-01 17:40:25 +00:00
)
}
return lines.join('\n')
}
2026-03-18 09:00:11 +00:00
const STEPS = ['Sütun Tasarımı', 'Entity Ayarları', 'Index / Key', 'İlişkiler', 'T-SQL Önizleme'] as const
2026-03-09 13:35:00 +00:00
type Step = 0 | 1 | 2 | 3 | 4
2026-03-01 17:40:25 +00:00
2026-03-03 07:34:25 +00:00
// ─── Simple Menu Tree (read-only selection) ───────────────────────────────────
interface SimpleMenuTreeNodeProps {
node: MenuTreeNode & { shortName?: 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 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.shortName && (
<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.shortName}
</span>
)}
</div>
{isExpanded &&
node.children.map((child: MenuTreeNode) => (
<SimpleMenuTreeNode
key={child.code}
node={child as MenuTreeNode & { shortName?: 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)
n.has(code) ? n.delete(code) : n.add(code)
return n
})
return (
<div
className={`rounded-lg border ${
invalid ? 'border-red-500' : '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 & { shortName?: string }}
depth={0}
selectedCode={selectedCode}
onSelect={onSelect}
expanded={expanded}
onToggle={toggle}
/>
))
)}
</div>
</div>
)
}
2026-03-01 17:40:25 +00:00
const createEmptyColumn = (): ColumnDefinition => ({
id: crypto.randomUUID(),
columnName: '',
dataType: 'nvarchar',
maxLength: '100',
isNullable: true,
defaultValue: '',
description: '',
})
const DEFAULT_SETTINGS: TableSettings = {
menuValue: '',
menuPrefix: '',
entityName: '',
tableName: '',
}
// ─── Component ────────────────────────────────────────────────────────────────
2026-03-01 20:43:25 +00:00
const SqlTableDesignerDialog = ({
2026-03-01 17:40:25 +00:00
isOpen,
onClose,
dataSource,
onDeployed,
initialTableData,
}: TableDesignerDialogProps) => {
const { translate } = useLocalization()
const isEditMode = !!initialTableData
const [step, setStep] = useState<Step>(0)
const [isDeploying, setIsDeploying] = useState(false)
const [columns, setColumns] = useState<ColumnDefinition[]>([createEmptyColumn()])
const [originalColumns, setOriginalColumns] = useState<ColumnDefinition[]>([])
const [colsLoading, setColsLoading] = useState(false)
const [settings, setSettings] = useState<TableSettings>(DEFAULT_SETTINGS)
2026-03-03 07:34:25 +00:00
const [rawMenuItems, setRawMenuItems] = useState<MenuItem[]>([])
const [menuTree, setMenuTree] = useState<MenuTreeNode[]>([])
const [selectedMenuCode, setSelectedMenuCode] = useState('')
const [menuAddDialogOpen, setMenuAddDialogOpen] = useState(false)
2026-03-01 17:40:25 +00:00
const [menuLoading, setMenuLoading] = useState(false)
const [relationships, setRelationships] = useState<SqlTableRelation[]>([])
const [originalRelationships, setOriginalRelationships] = useState<SqlTableRelation[]>([])
2026-03-01 17:40:25 +00:00
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)
2026-03-01 17:40:25 +00:00
const [dbTables, setDbTables] = useState<{ schemaName: string; tableName: string }[]>([])
2026-03-03 07:34:25 +00:00
const [targetTableColumns, setTargetTableColumns] = useState<string[]>([])
2026-03-18 09:00:11 +00:00
const [targetTableKeyColumns, setTargetTableKeyColumns] = useState<string[]>([])
2026-03-01 17:40:25 +00:00
const [targetColsLoading, setTargetColsLoading] = useState(false)
2026-03-09 13:35:00 +00:00
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)
2026-03-01 17:40:25 +00:00
2026-03-03 07:34:25 +00:00
const reloadMenus = (onLoaded?: (items: MenuItem[]) => void) => {
2026-03-01 17:40:25 +00:00
setMenuLoading(true)
2026-03-03 07:34:25 +00:00
getMenus(0, 1000)
2026-03-01 17:40:25 +00:00
.then((res) => {
2026-03-03 07:34:25 +00:00
const items = (res.data?.items ?? []) as MenuItem[]
const filtered = items.filter((m) => !!m.shortName?.trim())
setRawMenuItems(filtered)
const tree = filterNonLinkNodes(buildMenuTree(filtered))
setMenuTree(tree)
onLoaded?.(filtered)
2026-03-01 17:40:25 +00:00
})
.catch(() => {})
.finally(() => setMenuLoading(false))
2026-03-03 07:34:25 +00:00
}
useEffect(() => {
if (!isOpen) return
reloadMenus((items) => {
// In edit mode, auto-select the matching menu code by shortName
if (initialTableData) {
const parts = initialTableData.tableName.split('_')
const derivedShortName = parts[0] ?? ''
const match = items.find((m) => m.shortName === derivedShortName)
if (match?.code) setSelectedMenuCode(match.code)
}
})
2026-03-01 17:40:25 +00:00
if (dataSource) {
sqlObjectManagerService
.getAllObjects(dataSource)
.then((res) => setDbTables(res.data?.tables ?? []))
.catch(() => {})
}
// 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,
}))
2026-03-01 17:40:25 +00:00
// 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) => ({
2026-03-01 17:40:25 +00:00
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))
2026-03-09 13:35:00 +00:00
// 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))
2026-03-01 17:40:25 +00:00
}
}, [isOpen, dataSource, initialTableData])
const generatedSql = useMemo(
() =>
isEditMode
? generateAlterTableSql(
originalColumns,
columns,
settings.tableName || initialTableData?.tableName || '',
relationships,
originalRelationships,
2026-03-09 13:35:00 +00:00
indexes,
originalIndexes,
2026-03-01 17:40:25 +00:00
)
2026-03-09 13:35:00 +00:00
: generateCreateTableSql(columns, settings, relationships, indexes),
2026-03-03 07:34:25 +00:00
[
isEditMode,
originalColumns,
columns,
settings,
initialTableData,
relationships,
originalRelationships,
2026-03-09 13:35:00 +00:00
indexes,
originalIndexes,
2026-03-03 07:34:25 +00:00
],
2026-03-01 17:40:25 +00:00
)
// ── Column operations ──────────────────────────────────────────────────────
const addColumn = () => setColumns((prev) => [...prev, createEmptyColumn()])
const clearAllColumns = () => setColumns([createEmptyColumn()])
const addFullAuditedColumns = () => {
const existingNames = new Set(columns.map((c) => c.columnName.trim().toLowerCase()))
2026-03-03 07:34:25 +00:00
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()]
})
2026-03-16 09:52:15 +00:00
// FullAudited ile Id eklendiğinde PK tanımı Index/Key listesine otomatik düşsün.
setIndexes((prev) => {
const hasPk = prev.some((ix) => ix.indexType === 'PrimaryKey')
if (hasPk) return prev
const tableName = settings.tableName || initialTableData?.tableName || 'Table'
return [
2026-03-09 13:35:00 +00:00
...prev,
{
id: `auto-pk-${crypto.randomUUID()}`,
2026-03-16 09:52:15 +00:00
indexName: `PK_${tableName}`,
2026-03-09 13:35:00 +00:00
indexType: 'PrimaryKey',
isClustered: false,
columns: [{ columnName: 'Id', order: 'ASC' }],
2026-03-16 09:52:15 +00:00
description: 'Primary key (auto)',
2026-03-09 13:35:00 +00:00
},
2026-03-16 09:52:15 +00:00
]
})
}
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()]
})
}
}
2026-03-01 17:40:25 +00:00
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 ───────────────────────────────────────────────────────
2026-03-16 09:52:15 +00:00
const hasTenantIdColumn = (cols: ColumnDefinition[]) =>
cols.some((c) => c.columnName.trim().toLowerCase() === 'tenantid')
2026-03-01 17:40:25 +00:00
const buildTableName = (prefix: string, entity: string) =>
2026-03-16 09:52:15 +00:00
prefix && entity ? `${prefix}_${hasTenantIdColumn(columns) ? 'T' : 'D'}_${entity}` : ''
2026-03-01 17:40:25 +00:00
2026-03-09 13:35:00 +00:00
const syncAutoPkName = (newTableName: string) => {
if (!newTableName) return
setIndexes((prev) =>
prev.map((ix) =>
ix.indexType === 'PrimaryKey' && ix.id.startsWith('auto-pk')
? { ...ix, indexName: `PK_${newTableName}` }
: ix,
),
)
}
2026-03-03 07:34:25 +00:00
const onMenuCodeSelect = (code: string) => {
if (isEditMode) return
const item = rawMenuItems.find((m) => m.code === code)
const prefix = item?.shortName ?? ''
setSelectedMenuCode(code)
2026-03-09 13:35:00 +00:00
const newTableName = buildTableName(prefix, settings.entityName)
2026-03-01 17:40:25 +00:00
setSettings((s) => ({
...s,
2026-03-03 07:34:25 +00:00
menuValue: prefix,
2026-03-01 17:40:25 +00:00
menuPrefix: prefix,
2026-03-09 13:35:00 +00:00
tableName: newTableName,
2026-03-01 17:40:25 +00:00
}))
2026-03-09 13:35:00 +00:00
syncAutoPkName(newTableName)
2026-03-01 17:40:25 +00:00
}
// Strip spaces and special chars — only alphanumeric + underscore allowed
const onEntityNameChange = (value: string) => {
const sanitized = value.replace(/[^A-Za-z0-9_]/g, '')
2026-03-09 13:35:00 +00:00
const newTableName = buildTableName(settings.menuPrefix, sanitized)
2026-03-01 17:40:25 +00:00
setSettings((s) => ({
...s,
entityName: sanitized,
2026-03-09 13:35:00 +00:00
tableName: newTableName,
2026-03-01 17:40:25 +00:00
displayName: sanitized, // always mirrors entity name
}))
2026-03-09 13:35:00 +00:00
syncAutoPkName(newTableName)
2026-03-01 17:40:25 +00:00
}
2026-03-16 09:52:15 +00:00
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,
])
2026-03-01 17:40:25 +00:00
// ── FK Relationship handlers ───────────────────────────────────────────────
const loadTargetColumns = (tableName: string) => {
if (!tableName || !dataSource) {
setTargetTableColumns([])
2026-03-18 09:00:11 +00:00
setTargetTableKeyColumns([])
2026-03-01 17:40:25 +00:00
return
}
const tbl = dbTables.find((t) => t.tableName === tableName)
if (!tbl) return
setTargetColsLoading(true)
2026-03-18 09:00:11 +00:00
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([])
})
2026-03-01 17:40:25 +00:00
.finally(() => setTargetColsLoading(false))
}
const openAddFk = () => {
setEditingFkId(null)
setFkForm(EMPTY_FK)
setTargetTableColumns([])
2026-03-18 09:00:11 +00:00
setTargetTableKeyColumns([])
2026-03-01 17:40:25 +00:00
setFkModalOpen(true)
}
const openEditFk = (rel: SqlTableRelation) => {
2026-03-01 17:40:25 +00:00
setEditingFkId(rel.id)
const { id: _id, ...rest } = rel
setFkForm(rest)
loadTargetColumns(rest.referencedTable)
setFkModalOpen(true)
}
const saveFk = () => {
2026-03-18 09:00:11 +00:00
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.SqlQueryManager.Warning')}>
Referans kolon PK/UNIQUE olmalı. Lütfen hedef tablodan anahtar bir kolon seçin.
</Notification>,
{ placement: 'top-center' },
)
return
}
2026-03-01 17:40:25 +00:00
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))
}
2026-03-09 13:35:00 +00:00
// ── 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 { id: _id, ...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))
}
2026-03-01 17:40:25 +00:00
// ── 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 (step === 0) {
const hasNamed = columns.some((c) => c.columnName.trim().length > 0)
return hasNamed && duplicateColumnNames.size === 0
}
if (step === 1) {
if (isEditMode) return true // table name is locked in edit mode
const baseOk =
!!settings.tableName.trim() && !!settings.entityName.trim() && !!settings.menuValue
if (!baseOk) return false
return columns.some((c) => c.columnName.trim().toLowerCase() === 'id')
2026-03-01 17:40:25 +00:00
}
return true
}
const handleNext = () => {
2026-03-09 13:35:00 +00:00
if (step < 4) setStep((s) => (s + 1) as Step)
2026-03-01 17:40:25 +00:00
}
const handleBack = () => {
if (step > 0) setStep((s) => (s - 1) as Step)
}
// ── Deploy ─────────────────────────────────────────────────────────────────
const handleDeploy = async () => {
if (!dataSource) {
toast.push(
2026-03-03 06:15:23 +00:00
<Notification type="warning" title={translate('::App.SqlQueryManager.Warning')}>
{translate('::App.SqlQueryManager.PleaseSelectDataSource')}
2026-03-01 17:40:25 +00:00
</Notification>,
{ placement: 'top-center' },
)
return
}
setIsDeploying(true)
try {
const result = await sqlObjectManagerService.executeQuery({
queryText: generatedSql,
dataSourceCode: dataSource,
})
if (result.data.success) {
const deployedTable = settings.tableName || initialTableData?.tableName || ''
toast.push(
2026-03-03 06:15:23 +00:00
<Notification type="success" title={translate('::App.SqlQueryManager.Success')}>
{`${translate(isEditMode ? '::App.SqlQueryManager.TableUpdated' : '::App.SqlQueryManager.TableCreated')}: [dbo].[${deployedTable}]`}
2026-03-01 17:40:25 +00:00
</Notification>,
{ placement: 'top-center' },
)
onDeployed?.()
handleClose()
} else {
toast.push(
2026-03-03 06:15:23 +00:00
<Notification type="danger" title={translate('::App.SqlQueryManager.Error')}>
{result.data.message || translate('::App.SqlQueryManager.TableCreationFailed')}
2026-03-01 17:40:25 +00:00
</Notification>,
{ placement: 'top-center' },
)
}
} catch (error: any) {
toast.push(
2026-03-03 06:15:23 +00:00
<Notification type="danger" title={translate('::App.SqlQueryManager.Error')}>
2026-03-03 07:34:25 +00:00
{error.response?.data?.error?.message ||
translate('::App.SqlQueryManager.TableDeployFailed')}
2026-03-01 17:40:25 +00:00
</Notification>,
{ placement: 'top-center' },
)
} finally {
setIsDeploying(false)
}
}
const handleClose = () => {
setStep(0)
setColumns([createEmptyColumn()])
setOriginalColumns([])
setSettings(DEFAULT_SETTINGS)
setRelationships([])
setOriginalRelationships([])
2026-03-09 13:35:00 +00:00
setIndexes([])
setOriginalIndexes([])
2026-03-01 17:40:25 +00:00
setDbTables([])
setTargetTableColumns([])
2026-03-18 09:00:11 +00:00
setTargetTableKeyColumns([])
2026-03-03 07:34:25 +00:00
setSelectedMenuCode('')
setMenuAddDialogOpen(false)
2026-03-01 17:40:25 +00:00
onClose()
}
// ── Step Indicator ─────────────────────────────────────────────────────────
2026-03-03 06:15:23 +00:00
const STEP_LABELS = [
translate('::App.SqlQueryManager.ColumnDesign'),
translate('::App.SqlQueryManager.EntitySettings'),
2026-03-09 13:35:00 +00:00
translate('::App.SqlQueryManager.IndexKeys'),
2026-03-18 09:00:11 +00:00
translate('::App.SqlQueryManager.Relationships'),
2026-03-03 06:15:23 +00:00
translate('::App.SqlQueryManager.TSqlPreview'),
]
2026-03-01 17:40:25 +00:00
const renderStepIndicator = () => (
<div className="flex items-center justify-between mb-2">
2026-03-01 17:40:25 +00:00
{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>
2026-03-01 17:40:25 +00:00
</div>
{i < STEP_LABELS.length - 1 && (
<div className="flex-1 mx-3 h-px bg-gray-200 dark:bg-gray-700" />
2026-03-01 17:40:25 +00:00
)}
</div>
)
})}
</div>
)
// ── Step 0: Column Designer ────────────────────────────────────────────────
const renderColumnDesigner = () => (
<div className="flex flex-col">
{colsLoading && (
<div className="flex items-center justify-center py-12 text-gray-500">
2026-03-03 07:34:25 +00:00
<span className="animate-spin mr-2">&#9696;</span>{' '}
{translate('::App.SqlQueryManager.LoadingColumns')}
2026-03-01 17:40:25 +00:00
</div>
)}
{!colsLoading && (
2026-03-03 07:34:25 +00:00
<>
<div className="flex items-center justify-between py-2">
<div className="flex items-center gap-2">
<Button size="xs" variant="solid" color="blue-600" onClick={addFullAuditedColumns}>
{translate('::App.SqlQueryManager.AddFullAuditedColumns')}
</Button>
<Button size="xs" variant="solid" color="green-600" onClick={addMultiTenantColumns}>
{translate('::App.SqlQueryManager.AddMultiTenantColumns')}
</Button>
2026-03-01 17:40:25 +00:00
</div>
2026-03-03 07:34:25 +00:00
<div className="flex items-center gap-2">
<Button
size="xs"
variant="solid"
color="red-600"
icon={<FaTrash />}
onClick={clearAllColumns}
2026-03-01 17:40:25 +00:00
>
2026-03-03 07:34:25 +00:00
{translate('::App.SqlQueryManager.ClearAllColumns')}
</Button>
<Button
size="xs"
variant="solid"
color="blue-600"
icon={<FaPlus />}
onClick={addColumn}
>
{translate('::App.SqlQueryManager.AddColumn')}
</Button>
2026-03-01 17:40:25 +00:00
</div>
2026-03-03 07:34:25 +00:00
</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">
2026-03-09 13:35:00 +00:00
<div className="col-span-4">{translate('::App.SqlQueryManager.ColumnName')}</div>
2026-03-03 07:34:25 +00:00
<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')}
2026-03-01 17:40:25 +00:00
</div>
2026-03-03 07:34:25 +00:00
<div className="col-span-2">{translate('::App.SqlQueryManager.DefaultValue')}</div>
<div className="col-span-1 text-center">
{translate('::App.SqlQueryManager.Actions')}
2026-03-01 17:40:25 +00:00
</div>
</div>
2026-03-03 07:34:25 +00:00
{/* 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">
Aynı isimde sütun tanımlanamaz:{' '}
{[...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 max-h-80 overflow-y-auto 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}`}
>
2026-03-09 13:35:00 +00:00
<div className="col-span-4">
2026-03-03 07:34:25 +00:00
<input
type="text"
className={`w-full px-2 py-1 text-sm border rounded dark:bg-gray-700 dark:border-gray-600 dark:text-white ${
isDuplicate ? 'border-red-400' : ''
}`}
placeholder={translate('::App.SqlQueryManager.ColumnNamePlaceholder')}
value={col.columnName}
onChange={(e) => updateColumn(col.id, 'columnName', e.target.value)}
/>
</div>
<div className="col-span-3">
<select
className="w-full px-2 py-1 text-sm border rounded dark:bg-gray-700 dark:border-gray-600 dark:text-white"
value={col.dataType}
onChange={(e) => {
const dt = e.target.value as SqlDataType
updateColumn(col.id, 'dataType', dt)
if (dt !== 'nvarchar') updateColumn(col.id, 'maxLength', '')
else if (!col.maxLength) updateColumn(col.id, 'maxLength', '100')
}}
>
{DATA_TYPES.map((t) => (
<option key={t.value} value={t.value}>
{t.label}
</option>
))}
</select>
</div>
<div className="col-span-1">
<input
type="number"
className="w-full px-2 py-1 text-sm border rounded dark:bg-gray-700 dark:border-gray-600 dark:text-white 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
type="text"
className="w-full px-2 py-1 text-sm border rounded dark:bg-gray-700 dark:border-gray-600 dark:text-white"
placeholder={translate('::App.SqlQueryManager.DefaultValue')}
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}
className="p-1 rounded hover:bg-gray-100 dark:hover:bg-gray-700 disabled:opacity-30 text-gray-500"
title={translate('::App.SqlQueryManager.MoveUp')}
>
<FaArrowUp className="text-xs" />
</button>
<button
onClick={() => moveColumn(col.id, 'down')}
disabled={idx === columns.length - 1}
className="p-1 rounded hover:bg-gray-100 dark:hover:bg-gray-700 disabled:opacity-30 text-gray-500"
title={translate('::App.SqlQueryManager.MoveDown')}
>
<FaArrowDown className="text-xs" />
</button>
<button
onClick={() => removeColumn(col.id)}
className="p-1 rounded hover:bg-red-50 dark:hover:bg-red-900/30 text-red-500"
title={translate('::App.SqlQueryManager.Delete')}
>
<FaTrash className="text-xs" />
</button>
</div>
</div>
)
})}
2026-03-01 17:40:25 +00:00
</div>
2026-03-03 07:34:25 +00:00
{/* Id warning */}
{!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.SqlQueryManager.NoIdColumnWarning')}
</div>
)}
</>
2026-03-01 17:40:25 +00:00
)}
</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">
2026-03-03 07:34:25 +00:00
<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)}
className="flex items-center gap-1 px-2 py-0.5 text-xs rounded bg-green-500 text-white hover:bg-green-600 disabled:opacity-40 disabled:cursor-not-allowed"
>
<FaPlus className="text-xs" />{' '}
{translate('::ListForms.Wizard.Step1.AddNewMenu') || 'Add Menu'}
</button>
{settings.menuValue && !isEditMode && (
<button
type="button"
onClick={() => {
setSelectedMenuCode('')
setSettings((s) => ({ ...s, menuValue: '', menuPrefix: '', tableName: '' }))
}}
className="flex items-center gap-1 px-2 py-0.5 text-xs rounded border border-gray-300 dark:border-gray-600 text-gray-500 hover:text-red-500 hover:border-red-400"
>
<FaTimes className="text-xs" />{' '}
{translate('::ListForms.Wizard.ClearSelection') || 'Clear'}
</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={() => reloadMenus()}
/>
2026-03-01 17:40:25 +00:00
</div>
{/* Entity Name */}
<div>
<label className="block text-sm font-medium mb-1">
2026-03-03 06:15:23 +00:00
{translate('::App.SqlQueryManager.EntityName')} <span className="text-red-500">*</span>
2026-03-01 17:40:25 +00:00
</label>
<input
type="text"
disabled={isEditMode} // Entity name (and thus table name) cannot be changed in edit mode
2026-03-01 17:40:25 +00:00
className="w-full px-3 py-2 text-sm border rounded dark:bg-gray-700 dark:border-gray-600 dark:text-white"
value={settings.entityName}
onChange={(e) => onEntityNameChange(e.target.value)}
2026-03-03 06:15:23 +00:00
placeholder={translate('::App.SqlQueryManager.EntityNamePlaceholder')}
2026-03-01 17:40:25 +00:00
/>
</div>
{/* Table Name (readonly, auto-generated) */}
<div>
2026-03-03 07:34:25 +00:00
<label className="block text-sm font-medium mb-1">
{translate('::App.SqlQueryManager.TableName')}
</label>
2026-03-01 17:40:25 +00:00
<input
type="text"
readOnly
className="w-full px-3 py-2 text-sm border rounded bg-gray-50 dark:bg-gray-800 dark:border-gray-600 dark:text-gray-300 cursor-not-allowed"
value={isEditMode ? (initialTableData?.tableName ?? '') : settings.tableName}
2026-03-03 06:15:23 +00:00
placeholder={translate('::App.SqlQueryManager.TableNameAutoGenerated')}
2026-03-01 17:40:25 +00:00
/>
</div>
</div>
{/* Warning: no Id column */}
2026-03-03 07:34:25 +00:00
{!isEditMode && !columns.some((c) => c.columnName.trim().toLowerCase() === 'id') && (
<div className="px-3 py-2 bg-red-50 dark:bg-red-900/20 border border-red-300 dark:border-red-700 rounded text-xs text-red-700 dark:text-red-300">
{translate('::App.SqlQueryManager.NoIdColumnError')}
</div>
)}
2026-03-01 17:40:25 +00:00
</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">
2026-03-03 07:34:25 +00:00
<span className="animate-spin">&#9696;</span>{' '}
{translate('::App.SqlQueryManager.LoadingFkConstraints')}
2026-03-01 17:40:25 +00:00
</div>
)}
2026-03-03 07:34:25 +00:00
{!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}
className="flex items-center gap-1 px-3 py-1.5 bg-indigo-600 hover:bg-indigo-700 text-white text-xs rounded-lg transition-colors"
>
<FaPlus className="w-2.5 h-2.5" />{' '}
{translate('::App.SqlQueryManager.AddRelationship')}
</button>
</div>
2026-03-01 17:40:25 +00:00
2026-03-03 07:34:25 +00:00
{/* 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>
2026-03-01 17:40:25 +00:00
</div>
2026-03-03 07:34:25 +00:00
)}
{/* 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"
2026-03-01 17:40:25 +00:00
>
2026-03-03 07:34:25 +00:00
<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.SqlQueryManager.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.SqlQueryManager.Edit')}
className="p-1.5 text-gray-400 hover:text-indigo-600 hover:bg-indigo-50 dark:hover:bg-indigo-900/20 rounded transition-colors"
>
<FaEdit className="w-3.5 h-3.5" />
</button>
<button
onClick={() => deleteFk(rel.id)}
title={translate('::App.SqlQueryManager.Delete')}
className="p-1.5 text-gray-400 hover:text-red-600 hover:bg-red-50 dark:hover:bg-red-900/20 rounded transition-colors"
>
<FaTrash className="w-3.5 h-3.5" />
</button>
</div>
</div>
))}
2026-03-01 17:40:25 +00:00
</div>
2026-03-03 07:34:25 +00:00
</>
)}
2026-03-01 17:40:25 +00:00
{/* FK Add/Edit Modal */}
2026-03-03 07:34:25 +00:00
{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)}
className="p-2 text-gray-400 hover:text-gray-600 rounded-lg transition-colors"
>
<FaTimes className="w-4 h-4" />
</button>
2026-03-01 17:40:25 +00:00
</div>
2026-03-03 07:34:25 +00:00
{/* Modal Body */}
<div className="flex-1 overflow-y-auto px-6 py-4 space-y-4">
{/* Relationship type */}
2026-03-01 17:40:25 +00:00
<div>
2026-03-03 07:34:25 +00:00
<label className="block text-xs font-semibold text-gray-700 dark:text-gray-300 mb-2">
{translate('::App.SqlQueryManager.RelationshipType')}
2026-03-01 17:40:25 +00:00
</label>
2026-03-03 07:34:25 +00:00
<div className="flex gap-2">
{REL_TYPES.map((t) => (
<button
key={t.value}
type="button"
onClick={() => setFkForm((f) => ({ ...f, relationshipType: t.value }))}
className={`flex-1 py-2 px-3 rounded-lg border 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">{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
value={fkForm.fkColumnName}
onChange={(e) => setFkForm((f) => ({ ...f, fkColumnName: e.target.value }))}
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"
>
<option value="">
{translate('::App.SqlQueryManager.SelectPlaceholder')}
</option>
{columns
.filter((c) => c.columnName.trim())
.map((c) => (
<option key={c.id} value={c.columnName}>
{c.columnName}
</option>
))}
</select>
</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
value={fkForm.referencedTable}
onChange={(e) => {
const val = e.target.value
setFkForm((f) => ({ ...f, referencedTable: val, referencedColumn: '' }))
loadTargetColumns(val)
}}
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"
>
<option value="">
{translate('::App.SqlQueryManager.SelectPlaceholder')}
</option>
{dbTables.map((t) => (
<option key={`${t.schemaName}.${t.tableName}`} value={t.tableName}>
{t.tableName}
2026-03-01 17:40:25 +00:00
</option>
))}
2026-03-03 07:34:25 +00:00
</select>
</div>
2026-03-01 17:40:25 +00:00
</div>
2026-03-03 07:34:25 +00:00
2026-03-01 17:40:25 +00:00
<div>
<label className="block text-xs font-semibold text-gray-700 dark:text-gray-300 mb-1.5">
2026-03-03 07:34:25 +00:00
{translate('::App.SqlQueryManager.TargetColumn')}
{targetColsLoading ? `${translate('::App.SqlQueryManager.Loading')}` : ''}
2026-03-01 17:40:25 +00:00
</label>
<select
2026-03-03 07:34:25 +00:00
value={fkForm.referencedColumn}
onChange={(e) => setFkForm((f) => ({ ...f, referencedColumn: e.target.value }))}
disabled={targetColsLoading || targetTableColumns.length === 0}
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 disabled:opacity-60"
2026-03-01 17:40:25 +00:00
>
2026-03-03 07:34:25 +00:00
<option value="">
{translate('::App.SqlQueryManager.SelectTargetTableFirst')}
</option>
{targetTableColumns.map((col) => (
2026-03-18 09:00:11 +00:00
<option
key={col}
value={col}
disabled={!targetTableKeyColumns.some((k) => k.toLowerCase() === col.toLowerCase())}
>
{targetTableKeyColumns.some((k) => k.toLowerCase() === col.toLowerCase())
? `${col} (PK/UNIQUE)`
: `${col} (Not Key)`}
2026-03-01 17:40:25 +00:00
</option>
))}
</select>
2026-03-18 09:00:11 +00:00
{fkForm.referencedTable && !targetColsLoading && targetTableKeyColumns.length === 0 && (
<p className="mt-1 text-xs text-amber-600 dark:text-amber-400">
Seçilen tabloda FK için uygun PK/UNIQUE kolon bulunamadı.
</p>
)}
2026-03-01 17:40:25 +00:00
</div>
2026-03-03 07:34:25 +00:00
{/* 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
value={fkForm.cascadeUpdate}
onChange={(e) =>
setFkForm((f) => ({
...f,
cascadeUpdate: e.target.value as CascadeBehavior,
}))
}
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"
>
{CASCADE_OPTIONS.map((o) => (
<option key={o.value} value={o.value}>
{o.label}
</option>
))}
</select>
</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
value={fkForm.cascadeDelete}
onChange={(e) =>
setFkForm((f) => ({
...f,
cascadeDelete: e.target.value as CascadeBehavior,
}))
}
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"
>
{CASCADE_OPTIONS.map((o) => (
<option key={o.value} value={o.value}>
{o.label}
</option>
))}
</select>
</div>
</div>
2026-03-01 17:40:25 +00:00
2026-03-03 07:34:25 +00:00
{/* Options */}
<div className="flex items-center gap-6">
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={fkForm.isRequired}
onChange={(e) => setFkForm((f) => ({ ...f, isRequired: e.target.checked }))}
className="w-4 h-4 text-indigo-600 rounded"
/>
<span className="text-sm text-gray-700 dark:text-gray-300">
{translate('::App.SqlQueryManager.Required')}
</span>
2026-03-01 17:40:25 +00:00
</label>
</div>
2026-03-03 07:34:25 +00:00
{/* Description */}
2026-03-01 17:40:25 +00:00
<div>
<label className="block text-xs font-semibold text-gray-700 dark:text-gray-300 mb-1.5">
2026-03-03 07:34:25 +00:00
{translate('::App.SqlQueryManager.Description')}
2026-03-01 17:40:25 +00:00
</label>
2026-03-03 07:34:25 +00:00
<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"
2026-03-01 17:40:25 +00:00
/>
2026-03-03 07:34:25 +00:00
</div>
2026-03-01 17:40:25 +00:00
</div>
2026-03-03 07:34:25 +00:00
{/* 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)}
className="px-4 py-2 text-sm text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition-colors"
>
{translate('::App.SqlQueryManager.Cancel')}
</button>
<button
onClick={saveFk}
2026-03-18 09:00:11 +00:00
disabled={
!fkForm.fkColumnName.trim() ||
!fkForm.referencedTable.trim() ||
!fkForm.referencedColumn.trim() ||
!targetTableKeyColumns.some(
(c) => c.toLowerCase() === fkForm.referencedColumn.trim().toLowerCase(),
)
}
2026-03-03 07:34:25 +00:00
className="px-4 py-2 text-sm bg-indigo-600 hover:bg-indigo-700 disabled:opacity-50 text-white rounded-lg transition-colors"
>
{translate('::App.SqlQueryManager.Save')}
</button>
2026-03-01 17:40:25 +00:00
</div>
</div>
2026-03-03 07:34:25 +00:00
</div>,
document.body,
)}
2026-03-01 17:40:25 +00:00
</div>
)
2026-03-09 13:35:00 +00:00
// ── 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.SqlQueryManager.Loading')}
</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}
className="flex items-center gap-1 px-3 py-1.5 bg-indigo-600 hover:bg-indigo-700 text-white text-xs rounded-lg 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.SqlQueryManager.Edit')}
className="p-1.5 text-gray-400 hover:text-indigo-600 hover:bg-indigo-50 dark:hover:bg-indigo-900/20 rounded transition-colors"
>
<FaEdit className="w-3.5 h-3.5" />
</button>
<button
onClick={() => deleteIndex(idx.id)}
title={translate('::App.SqlQueryManager.Delete')}
className="p-1.5 text-gray-400 hover:text-red-600 hover:bg-red-50 dark:hover:bg-red-900/20 rounded transition-colors"
>
<FaTrash className="w-3.5 h-3.5" />
</button>
</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)}
className="p-2 text-gray-400 hover:text-gray-600 rounded-lg transition-colors"
>
<FaTimes className="w-4 h-4" />
</button>
</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),
}))
}
className={`flex-1 py-2 px-3 rounded-lg border 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
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 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"
/>
</div>
{/* Clustered */}
<div className="flex items-center gap-3">
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={indexForm.isClustered}
onChange={(e) =>
setIndexForm((f) => ({ ...f, isClustered: e.target.checked }))
}
className="w-4 h-4 text-indigo-600 rounded"
/>
<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.SqlQueryManager.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.SqlQueryManager.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
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 h-4 text-indigo-600 rounded"
/>
</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
value={existing.order}
onChange={(e) =>
setIndexForm((f) => ({
...f,
columns: f.columns.map((ic) =>
ic.columnName === col.columnName
? { ...ic, order: e.target.value as 'ASC' | 'DESC' }
: ic,
),
}))
}
className="w-full px-2 py-0.5 text-xs border border-gray-300 dark:border-gray-600 rounded dark:bg-gray-700 dark:text-white"
>
<option value="ASC">ASC</option>
<option value="DESC">DESC</option>
</select>
)}
</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>
{/* Description */}
<div>
<label className="block text-xs font-semibold text-gray-700 dark:text-gray-300 mb-1.5">
{translate('::App.SqlQueryManager.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)}
className="px-4 py-2 text-sm text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition-colors"
>
{translate('::App.SqlQueryManager.Cancel')}
</button>
<button
onClick={saveIndex}
disabled={!indexForm.indexName.trim() || indexForm.columns.length === 0}
className="px-4 py-2 text-sm bg-indigo-600 hover:bg-indigo-700 disabled:opacity-50 text-white rounded-lg transition-colors"
>
{translate('::App.SqlQueryManager.Save')}
</button>
</div>
</div>
</div>,
document.body,
)}
</div>
)
// ── Step 4: T-SQL Preview ──────────────────────────────────────────────────
2026-03-01 17:40:25 +00:00
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">
2026-03-03 06:15:23 +00:00
{translate('::App.SqlQueryManager.GeneratedSqlDescription')}
2026-03-01 17:40:25 +00:00
</p>
<button
className="text-xs text-blue-500 hover:underline"
onClick={() => navigator.clipboard.writeText(generatedSql)}
>
2026-03-03 06:15:23 +00:00
{translate('::App.SqlQueryManager.Copy')}
2026-03-01 17:40:25 +00:00
</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">
2026-03-01 17:40:25 +00:00
{generatedSql}
</pre>
</div>
)
// ── Render ─────────────────────────────────────────────────────────────────
return (
<Dialog isOpen={isOpen} onClose={handleClose} onRequestClose={handleClose} width={900}>
<div className="flex flex-col gap-2">
{/* Header */}
<div className="flex items-center gap-3 border-b pb-3">
<FaTable className="text-2xl text-blue-500" />
<div>
<h5 className="font-bold">
{isEditMode
2026-03-03 06:15:23 +00:00
? `${translate('::App.SqlQueryManager.EditTable')}${initialTableData?.tableName}`
: translate('::App.SqlQueryManager.TableDesigner')}
2026-03-01 17:40:25 +00:00
</h5>
<p className="text-xs text-gray-500">
{isEditMode
2026-03-03 06:15:23 +00:00
? translate('::App.SqlQueryManager.EditModeDescription')
: translate('::App.SqlQueryManager.NewModeDescription')}
2026-03-01 17:40:25 +00:00
</p>
</div>
</div>
{/* Steps */}
{renderStepIndicator()}
{/* Content */}
<div className="min-h-[420px]">
{step === 0 && renderColumnDesigner()}
{step === 1 && renderEntitySettings()}
2026-03-18 09:00:11 +00:00
{step === 2 && renderIndexes()}
{step === 3 && renderRelationships()}
2026-03-09 13:35:00 +00:00
{step === 4 && renderSqlPreview()}
2026-03-01 17:40:25 +00:00
</div>
{/* Footer */}
<div className="flex justify-between items-center border-t pt-3 mt-1">
<Button variant="plain" onClick={handleClose}>
2026-03-03 06:15:23 +00:00
{translate('::App.SqlQueryManager.Cancel')}
2026-03-01 17:40:25 +00:00
</Button>
<div className="flex items-center gap-2">
{step > 0 && (
<Button variant="default" onClick={handleBack}>
2026-03-03 06:15:23 +00:00
{translate('::App.SqlQueryManager.Back')}
2026-03-01 17:40:25 +00:00
</Button>
)}
2026-03-09 13:35:00 +00:00
{step < 4 ? (
2026-03-01 17:40:25 +00:00
<Button variant="solid" color="blue-600" onClick={handleNext} disabled={!canGoNext()}>
2026-03-03 06:15:23 +00:00
{translate('::App.SqlQueryManager.Next')}
2026-03-01 17:40:25 +00:00
</Button>
) : (
<Button
variant="solid"
color="green-600"
icon={<FaCloudUploadAlt />}
onClick={handleDeploy}
loading={isDeploying}
2026-03-03 07:34:25 +00:00
disabled={
!dataSource ||
isDeploying ||
(isEditMode && generatedSql.includes('Henüz değişiklik yapılmadı'))
}
2026-03-01 17:40:25 +00:00
>
2026-03-03 06:15:23 +00:00
{translate('::App.SqlQueryManager.Deploy')}
2026-03-01 17:40:25 +00:00
</Button>
)}
</div>
</div>
</div>
</Dialog>
)
}
2026-03-01 20:43:25 +00:00
export default SqlTableDesignerDialog