sozsoft-platform/ui/src/views/developerKit/SqlTableDesignerDialog.tsx
2026-03-18 12:00:11 +03:00

2433 lines
97 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 { useState, useCallback, useMemo, useEffect } from 'react'
import { createPortal } from 'react-dom'
import { Button, Dialog, Notification, toast, Checkbox } from '@/components/ui'
import {
FaPlus,
FaTrash,
FaArrowUp,
FaArrowDown,
FaTable,
FaCloudUploadAlt,
FaCheck,
FaLink,
FaEdit,
FaTimes,
FaArrowRight,
FaChevronDown,
FaChevronRight,
FaKey,
} 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 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'
// ─── 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
}
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 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',
},
{
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'> = {
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.IndexType_PrimaryKey_Desc' },
{ value: 'UniqueKey', label: 'Unique Key', desc: 'App.SqlQueryManager.IndexType_UniqueKey_Desc' },
{ value: 'Index', label: 'Index', desc: 'App.SqlQueryManager.IndexType_Index_Desc' },
]
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[],
indexes: TableIndex[],
): string {
const tableName = settings.tableName || 'NewTable'
const fullTableName = `[dbo].[${tableName}]`
const userCols = columns.filter((c) => c.columnName.trim())
const allBodyCols = userCols
const bodyLines = allBodyCols.map((c, i) => colToSqlLine(c, i < allBodyCols.length - 1))
// 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};`)
}
// 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});`,
)
}
}
const lines: string[] = [
`/* ── Table: ${fullTableName} ── */`,
...(settings.entityName ? [`/* Entity Name: ${settings.entityName} */`] : []),
'',
`CREATE TABLE ${fullTableName}`,
`(`,
...bodyLines,
`);`,
...indexLines,
...(fkLines.length > 0 ? ['/* Foreign Key Constraints */'] : []),
...fkLines,
'',
`/* 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[],
indexes: TableIndex[],
originalIndexes: TableIndex[],
): 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
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(`-- 🔄 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) =>
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(`-- 🔗 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>()
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(`-- ❌ 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)
}
})
// 🔑 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)
}
}
})
if (!hasChanges) {
lines.push(
'/* Henüz değişiklik yapılmadı. Sütunları ekleyin/silin/düzeyin ya da ilişki/index ekleyin. */',
)
}
return lines.join('\n')
}
const STEPS = ['Sütun Tasarımı', 'Entity Ayarları', 'Index / Key', 'İlişkiler', 'T-SQL Önizleme'] as const
type Step = 0 | 1 | 2 | 3 | 4
// ─── 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>
)
}
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,
}: 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)
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 [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.shortName?.trim())
setRawMenuItems(filtered)
const tree = filterNonLinkNodes(buildMenuTree(filtered))
setMenuTree(tree)
onLoaded?.(filtered)
})
.catch(() => {})
.finally(() => setMenuLoading(false))
}
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)
}
})
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,
}))
// 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])
const generatedSql = useMemo(
() =>
isEditMode
? generateAlterTableSql(
originalColumns,
columns,
settings.tableName || initialTableData?.tableName || '',
relationships,
originalRelationships,
indexes,
originalIndexes,
)
: generateCreateTableSql(columns, settings, relationships, indexes),
[
isEditMode,
originalColumns,
columns,
settings,
initialTableData,
relationships,
originalRelationships,
indexes,
originalIndexes,
],
)
// ── 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()))
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()]
})
// 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 [
...prev,
{
id: `auto-pk-${crypto.randomUUID()}`,
indexName: `PK_${tableName}`,
indexType: 'PrimaryKey',
isClustered: false,
columns: [{ columnName: 'Id', order: 'ASC' }],
description: '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 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) =>
ix.indexType === 'PrimaryKey' && ix.id.startsWith('auto-pk')
? { ...ix, indexName: `PK_${newTableName}` }
: ix,
),
)
}
const onMenuCodeSelect = (code: string) => {
if (isEditMode) return
const item = rawMenuItems.find((m) => m.code === code)
const prefix = item?.shortName ?? ''
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 { id: _id, ...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.SqlQueryManager.Warning')}>
Referans kolon PK/UNIQUE olmalı. Lütfen hedef tablodan anahtar bir kolon seçin.
</Notification>,
{ placement: 'top-center' },
)
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 { 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))
}
// ── 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')
}
return true
}
const handleNext = () => {
if (step < 4) setStep((s) => (s + 1) as Step)
}
const handleBack = () => {
if (step > 0) setStep((s) => (s - 1) as Step)
}
// ── Deploy ─────────────────────────────────────────────────────────────────
const handleDeploy = async () => {
if (!dataSource) {
toast.push(
<Notification type="warning" title={translate('::App.SqlQueryManager.Warning')}>
{translate('::App.SqlQueryManager.PleaseSelectDataSource')}
</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(
<Notification type="success" title={translate('::App.SqlQueryManager.Success')}>
{`${translate(isEditMode ? '::App.SqlQueryManager.TableUpdated' : '::App.SqlQueryManager.TableCreated')}: [dbo].[${deployedTable}]`}
</Notification>,
{ placement: 'top-center' },
)
onDeployed?.()
handleClose()
} else {
toast.push(
<Notification type="danger" title={translate('::App.SqlQueryManager.Error')}>
{result.data.message || translate('::App.SqlQueryManager.TableCreationFailed')}
</Notification>,
{ placement: 'top-center' },
)
}
} catch (error: any) {
toast.push(
<Notification type="danger" title={translate('::App.SqlQueryManager.Error')}>
{error.response?.data?.error?.message ||
translate('::App.SqlQueryManager.TableDeployFailed')}
</Notification>,
{ placement: 'top-center' },
)
} finally {
setIsDeploying(false)
}
}
const handleClose = () => {
setStep(0)
setColumns([createEmptyColumn()])
setOriginalColumns([])
setSettings(DEFAULT_SETTINGS)
setRelationships([])
setOriginalRelationships([])
setIndexes([])
setOriginalIndexes([])
setDbTables([])
setTargetTableColumns([])
setTargetTableKeyColumns([])
setSelectedMenuCode('')
setMenuAddDialogOpen(false)
onClose()
}
// ── Step Indicator ─────────────────────────────────────────────────────────
const STEP_LABELS = [
translate('::App.SqlQueryManager.ColumnDesign'),
translate('::App.SqlQueryManager.EntitySettings'),
translate('::App.SqlQueryManager.IndexKeys'),
translate('::App.SqlQueryManager.Relationships'),
translate('::App.SqlQueryManager.TSqlPreview'),
]
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 ────────────────────────────────────────────────
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 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>
</div>
<div className="flex items-center gap-2">
<Button
size="xs"
variant="solid"
color="red-600"
icon={<FaTrash />}
onClick={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('::App.SqlQueryManager.DefaultValue')}</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">
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}`}
>
<div className="col-span-4">
<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>
)
})}
</div>
{/* 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>
)}
</>
)}
</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)}
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()}
/>
</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
type="text"
disabled={isEditMode} // Entity name (and thus table name) cannot be changed in edit mode
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)}
placeholder={translate('::App.SqlQueryManager.EntityNamePlaceholder')}
/>
</div>
{/* Table Name (readonly, auto-generated) */}
<div>
<label className="block text-sm font-medium mb-1">
{translate('::App.SqlQueryManager.TableName')}
</label>
<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}
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-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>
)}
</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}
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>
{/* 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.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>
))}
</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)}
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">
{/* 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 }))}
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}
</option>
))}
</select>
</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.SqlQueryManager.Loading')}` : ''}
</label>
<select
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"
>
<option value="">
{translate('::App.SqlQueryManager.SelectTargetTableFirst')}
</option>
{targetTableColumns.map((col) => (
<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)`}
</option>
))}
</select>
{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>
)}
</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
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>
{/* 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>
</label>
</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={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)}
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}
disabled={
!fkForm.fkColumnName.trim() ||
!fkForm.referencedTable.trim() ||
!fkForm.referencedColumn.trim() ||
!targetTableKeyColumns.some(
(c) => c.toLowerCase() === fkForm.referencedColumn.trim().toLowerCase(),
)
}
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 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 ──────────────────────────────────────────────────
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
className="text-xs text-blue-500 hover:underline"
onClick={() => navigator.clipboard.writeText(generatedSql)}
>
{translate('::App.SqlQueryManager.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={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
? `${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 */}
{renderStepIndicator()}
{/* Content */}
<div className="min-h-[420px]">
{step === 0 && renderColumnDesigner()}
{step === 1 && renderEntitySettings()}
{step === 2 && renderIndexes()}
{step === 3 && renderRelationships()}
{step === 4 && renderSqlPreview()}
</div>
{/* Footer */}
<div className="flex justify-between items-center border-t pt-3 mt-1">
<Button variant="plain" onClick={handleClose}>
{translate('::App.SqlQueryManager.Cancel')}
</Button>
<div className="flex items-center gap-2">
{step > 0 && (
<Button variant="default" onClick={handleBack}>
{translate('::App.SqlQueryManager.Back')}
</Button>
)}
{step < 4 ? (
<Button variant="solid" color="blue-600" onClick={handleNext} disabled={!canGoNext()}>
{translate('::App.SqlQueryManager.Next')}
</Button>
) : (
<Button
variant="solid"
color="green-600"
icon={<FaCloudUploadAlt />}
onClick={handleDeploy}
loading={isDeploying}
disabled={
!dataSource ||
isDeploying ||
(isEditMode && generatedSql.includes('Henüz değişiklik yapılmadı'))
}
>
{translate('::App.SqlQueryManager.Deploy')}
</Button>
)}
</div>
</div>
</div>
</Dialog>
)
}
export default SqlTableDesignerDialog