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 = { relationshipType: 'OneToMany', fkColumnName: '', referencedTable: '', referencedColumn: 'Id', cascadeDelete: 'NoAction', cascadeUpdate: 'Cascade', isRequired: false, description: '', } const EMPTY_INDEX: Omit = { 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} */`] : []), ...(fkLines.length > 0 ? ['/* Foreign Key Constraints */'] : []), '', `CREATE TABLE ${fullTableName}`, `(`, ...bodyLines, `);`, ...fkLines, ...indexLines, '', `/* 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() originalCols.forEach((c) => origById.set(c.id, c)) const curById = new Map() 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() originalRelationships.forEach((r) => origRelById.set(r.id, r)) const curRelById = new Map() 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() originalIndexes.forEach((ix) => origIdxById.set(ix.id, ix)) const curIdxById = new Map() 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ı', 'İlişkiler', 'Index / Key', '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 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 (
onSelect(node.code)} > {hasChildren ? ( isExpanded ? ( ) : ( ) ) : null} {NodeIcon && ( )} {translate('::' + node.code)} {node.shortName && ( {node.shortName} )}
{isExpanded && node.children.map((child: MenuTreeNode) => ( ))}
) } 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>(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 (
{isLoading ? (
Loading…
) : nodes.length === 0 ? (
No menus available
) : ( nodes.map((node) => ( )) )}
) } 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(0) const [isDeploying, setIsDeploying] = useState(false) const [columns, setColumns] = useState([createEmptyColumn()]) const [originalColumns, setOriginalColumns] = useState([]) const [colsLoading, setColsLoading] = useState(false) const [settings, setSettings] = useState(DEFAULT_SETTINGS) const [rawMenuItems, setRawMenuItems] = useState([]) const [menuTree, setMenuTree] = useState([]) const [selectedMenuCode, setSelectedMenuCode] = useState('') const [menuAddDialogOpen, setMenuAddDialogOpen] = useState(false) const [menuLoading, setMenuLoading] = useState(false) const [relationships, setRelationships] = useState([]) const [originalRelationships, setOriginalRelationships] = useState([]) const [fksLoading, setFksLoading] = useState(false) const [fkModalOpen, setFkModalOpen] = useState(false) const [editingFkId, setEditingFkId] = useState(null) const [fkForm, setFkForm] = useState>(EMPTY_FK) const [dbTables, setDbTables] = useState<{ schemaName: string; tableName: string }[]>([]) const [targetTableColumns, setTargetTableColumns] = useState([]) const [targetColsLoading, setTargetColsLoading] = useState(false) const [indexes, setIndexes] = useState([]) const [originalIndexes, setOriginalIndexes] = useState([]) const [indexesLoading, setIndexesLoading] = useState(false) const [indexModalOpen, setIndexModalOpen] = useState(false) const [editingIndexId, setEditingIndexId] = useState(null) const [indexForm, setIndexForm] = useState>(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() 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( (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([]) return } const tbl = dbTables.find((t) => t.tableName === tableName) if (!tbl) return setTargetColsLoading(true) sqlObjectManagerService .getTableColumns(dataSource, tbl.schemaName, tbl.tableName) .then((res) => setTargetTableColumns((res.data ?? []).map((c) => c.columnName))) .catch(() => setTargetTableColumns([])) .finally(() => setTargetColsLoading(false)) } const openAddFk = () => { setEditingFkId(null) setFkForm(EMPTY_FK) setTargetTableColumns([]) 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()) 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( {translate('::App.SqlQueryManager.PleaseSelectDataSource')} , { 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( {`${translate(isEditMode ? '::App.SqlQueryManager.TableUpdated' : '::App.SqlQueryManager.TableCreated')}: [dbo].[${deployedTable}]`} , { placement: 'top-center' }, ) onDeployed?.() handleClose() } else { toast.push( {result.data.message || translate('::App.SqlQueryManager.TableCreationFailed')} , { placement: 'top-center' }, ) } } catch (error: any) { toast.push( {error.response?.data?.error?.message || translate('::App.SqlQueryManager.TableDeployFailed')} , { placement: 'top-center' }, ) } finally { setIsDeploying(false) } } const handleClose = () => { setStep(0) setColumns([createEmptyColumn()]) setOriginalColumns([]) setSettings(DEFAULT_SETTINGS) setRelationships([]) setOriginalRelationships([]) setIndexes([]) setOriginalIndexes([]) setDbTables([]) setTargetTableColumns([]) setSelectedMenuCode('') setMenuAddDialogOpen(false) onClose() } // ── Step Indicator ───────────────────────────────────────────────────────── const STEP_LABELS = [ translate('::App.SqlQueryManager.ColumnDesign'), translate('::App.SqlQueryManager.EntitySettings'), translate('::App.SqlQueryManager.Relationships'), translate('::App.SqlQueryManager.IndexKeys'), translate('::App.SqlQueryManager.TSqlPreview'), ] const renderStepIndicator = () => (
{STEP_LABELS.map((label, i) => { const isDone = i < step const isActive = i === step return (
{isDone ? : i + 1}
{label}
{i < STEP_LABELS.length - 1 && (
)}
) })}
) // ── Step 0: Column Designer ──────────────────────────────────────────────── const renderColumnDesigner = () => (
{colsLoading && (
{' '} {translate('::App.SqlQueryManager.LoadingColumns')}
)} {!colsLoading && ( <>
{/* Header row */}
{translate('::App.SqlQueryManager.ColumnName')}
{translate('::App.SqlQueryManager.DataType')}
{translate('::App.SqlQueryManager.Max')}
{translate('::App.SqlQueryManager.Nullable')}
{translate('::App.SqlQueryManager.DefaultValue')}
{translate('::App.SqlQueryManager.Actions')}
{/* Editable column rows */} {duplicateColumnNames.size > 0 && (
⚠️ Aynı isimde sütun tanımlanamaz:{' '} {[...duplicateColumnNames].map((n) => ( {n} ))}
)}
{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 (
updateColumn(col.id, 'columnName', e.target.value)} />
updateColumn(col.id, 'maxLength', e.target.value)} />
updateColumn(col.id, 'isNullable', checked as boolean)} />
updateColumn(col.id, 'defaultValue', e.target.value)} />
) })}
{/* Id warning */} {!isEditMode && !columns.some((c) => c.columnName.trim().toLowerCase() === 'id') && (
{translate('::App.SqlQueryManager.NoIdColumnWarning')}
)} )}
) // ── Step 1: Entity Settings ──────────────────────────────────────────────── const renderEntitySettings = () => (
{/* Menu Name */}
{settings.menuValue && !isEditMode && ( )}
{} : onMenuCodeSelect} nodes={menuTree} isLoading={menuLoading} invalid={!settings.menuValue && !isEditMode && !menuLoading} /> {settings.menuValue && (

{settings.menuValue}

)} setMenuAddDialogOpen(false)} initialParentCode={selectedMenuCode} initialOrder={999} rawItems={rawMenuItems} onSaved={() => reloadMenus()} />
{/* Entity Name */}
onEntityNameChange(e.target.value)} placeholder={translate('::App.SqlQueryManager.EntityNamePlaceholder')} />
{/* Table Name (readonly, auto-generated) */}
{/* Warning: no Id column */} {!isEditMode && !columns.some((c) => c.columnName.trim().toLowerCase() === 'id') && (
{translate('::App.SqlQueryManager.NoIdColumnError')}
)}
) // ── Step 2: Relationships ───────────────────────────────────────────────── const renderRelationships = () => (
{/* Loading indicator for edit mode */} {fksLoading && (
{' '} {translate('::App.SqlQueryManager.LoadingFkConstraints')}
)} {!fksLoading && ( <> {/* Header */}
{relationships.length === 0 ? translate('::App.SqlQueryManager.NoRelationshipsDefined') : `${relationships.length} ${translate('::App.SqlQueryManager.Relationship')}`}
{/* Empty state */} {relationships.length === 0 && (

{translate('::App.SqlQueryManager.NoRelationshipsYet')}

{translate('::App.SqlQueryManager.StepIsOptional')}

)} {/* Relationship cards */}
{relationships.map((rel) => (
[{rel.fkColumnName}] [dbo].[{rel.referencedTable}].[{rel.referencedColumn || 'Id'}] {REL_TYPES.find((t) => t.value === rel.relationshipType)?.label}
ON DELETE: {rel.cascadeDelete} ON UPDATE: {rel.cascadeUpdate} {rel.isRequired && ( {translate('::App.SqlQueryManager.Required')} )} {rel.description && ( {rel.description} )}
))}
)} {/* FK Add/Edit Modal */} {fkModalOpen && createPortal(
{/* Modal Header */}

{editingFkId ? translate('::App.SqlQueryManager.EditRelationship') : translate('::App.SqlQueryManager.AddNewRelationship')}

{/* Modal Body */}
{/* Relationship type */}
{REL_TYPES.map((t) => ( ))}
{/* FK column / Referenced table & column */}
{/* Cascade */}
{/* Options */}
{/* Description */}