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, } 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 } // ─── 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 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[], ): string { const tableName = settings.tableName || 'NewTable' const fullTableName = `[dbo].[${tableName}]` const userCols = columns.filter((c) => c.columnName.trim()) const allBodyCols = userCols const hasIdCol = userCols.some((c) => c.columnName.trim().toLowerCase() === 'id') const bodyLines = hasIdCol ? [ ...allBodyCols.map((c) => colToSqlLine(c, true)), ` CONSTRAINT [PK_${tableName}] PRIMARY KEY NONCLUSTERED ([Id])`, ] : 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};`) } const lines: string[] = [ `/* ── Table: ${fullTableName} ── */`, ...(settings.entityName ? [`/* Entity Name: ${settings.entityName} */`] : []), ...(fkLines.length > 0 ? ['/* Foreign Key Constraints */'] : []), '', `CREATE TABLE ${fullTableName}`, `(`, ...bodyLines, `);`, ...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[], ): 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) } }) if (!hasChanges) { lines.push( '/* ℹ️ Henüz değişiklik yapılmadı. Sütunları ekleyin/silin/düzeyin ya da ilişki ekleyin. */', ) } return lines.join('\n') } const STEPS = ['Sütun Tasarımı', 'Entity Ayarları', 'İlişkiler', 'T-SQL Önizleme'] as const type Step = 0 | 1 | 2 | 3 // ─── 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 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)) } }, [isOpen, dataSource, initialTableData]) const generatedSql = useMemo( () => isEditMode ? generateAlterTableSql( originalColumns, columns, settings.tableName || initialTableData?.tableName || '', relationships, originalRelationships, ) : generateCreateTableSql(columns, settings, relationships), [ isEditMode, originalColumns, columns, settings, initialTableData, relationships, originalRelationships, ], ) // ── 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()] }) } 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 buildTableName = (prefix: string, entity: string) => prefix && entity ? `${prefix}_D_${entity}` : '' const onMenuCodeSelect = (code: string) => { if (isEditMode) return const item = rawMenuItems.find((m) => m.code === code) const prefix = item?.shortName ?? '' setSelectedMenuCode(code) setSettings((s) => ({ ...s, menuValue: prefix, menuPrefix: prefix, tableName: buildTableName(prefix, s.entityName), })) } // Strip spaces and special chars — only alphanumeric + underscore allowed const onEntityNameChange = (value: string) => { const sanitized = value.replace(/[^A-Za-z0-9_]/g, '') setSettings((s) => ({ ...s, entityName: sanitized, tableName: buildTableName(s.menuPrefix, sanitized), displayName: sanitized, // always mirrors entity name })) } // ── 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)) } // ── 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 < 3) 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([]) 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.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.Note')}
{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)} />
updateColumn(col.id, 'description', 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 */}