Sql Query Manager Index ve PrimaryKey

This commit is contained in:
Sedat ÖZTÜRK 2026-03-09 16:35:00 +03:00
parent f03612b619
commit dbd3e9f32a
2 changed files with 691 additions and 23 deletions

View file

@ -16430,7 +16430,96 @@
"en": "Select key column", "en": "Select key column",
"tr": "Anahtar sütunu seç" "tr": "Anahtar sütunu seç"
}, },
{
"resourceName": "Platform",
"key": "App.SqlQueryManager.IndexKeys",
"en": "Index Keys",
"tr": "Dizin Anahtarları"
},
{
"resourceName": "Platform",
"key": "App.SqlQueryManager.NoIndexesDefined",
"en": "No indexes defined",
"tr": "Dizin tanımlanmamış"
},
{
"resourceName": "Platform",
"key": "App.SqlQueryManager.IndexKey",
"en": "Index Key",
"tr": "Dizin Anahtarı"
},
{
"resourceName": "Platform",
"key": "App.SqlQueryManager.AddIndexKey",
"en": "Add Index Key",
"tr": "Dizin Anahtarı Ekle"
},
{
"resourceName": "Platform",
"key": "App.SqlQueryManager.EditIndexKey",
"en": "Edit Index Key",
"tr": "Dizin Anahtarı Düzenle"
},
{
"resourceName": "Platform",
"key": "App.SqlQueryManager.AddNewIndexKey",
"en": "Add New Index Key",
"tr": "Yeni Dizin Anahtarı Ekle"
},
{
"resourceName": "Platform",
"key": "App.SqlQueryManager.IndexKeyType",
"en": "Index Key Type",
"tr": "Dizin Anahtarı Türü"
},
{
"resourceName": "Platform",
"key": "App.SqlQueryManager.IndexConstraintName",
"en": "Index Constraint Name",
"tr": "Dizin Kısıtlaması Adı"
},
{
"resourceName": "Platform",
"key": "App.SqlQueryManager.Columns",
"en": "Columns",
"tr": "Sütunlar"
},
{
"resourceName": "Platform",
"key": "App.SqlQueryManager.Column",
"en": "Column",
"tr": "Sütun"
},
{
"resourceName": "Platform",
"key": "App.SqlQueryManager.SortOrder",
"en": "Sort Order",
"tr": "Sıralama Düzeni"
},
{
"resourceName": "Platform",
"key": "App.SqlQueryManager.Selected",
"en": "Selected",
"tr": "Seçili"
},
{
"resourceName": "Platform",
"key": "App.SqlQueryManager.IndexType_PrimaryKey_Desc",
"en": "Primary Key",
"tr": "Birincil anahtar"
},
{
"resourceName": "Platform",
"key": "App.SqlQueryManager.IndexType_UniqueKey_Desc",
"en": "Unique Key",
"tr": "Benzersiz anahtar"
},
{
"resourceName": "Platform",
"key": "App.SqlQueryManager.IndexType_Index_Desc",
"en": "Index",
"tr": "Dizin"
},
{ {
"resourceName": "Platform", "resourceName": "Platform",
"key": "App.SqlQueryManager.ColumnDesign", "key": "App.SqlQueryManager.ColumnDesign",

View file

@ -15,6 +15,7 @@ import {
FaArrowRight, FaArrowRight,
FaChevronDown, FaChevronDown,
FaChevronRight, FaChevronRight,
FaKey,
} from 'react-icons/fa' } from 'react-icons/fa'
import { sqlObjectManagerService } from '@/services/sql-query-manager.service' import { sqlObjectManagerService } from '@/services/sql-query-manager.service'
import { getMenus } from '@/services/menu.service' import { getMenus } from '@/services/menu.service'
@ -69,6 +70,22 @@ interface TableDesignerDialogProps {
initialTableData?: { schemaName: string; tableName: string } | null 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 ──────────────────────────────────────────────────────────────── // ─── Constants ────────────────────────────────────────────────────────────────
const DATA_TYPES: { value: SqlDataType; label: string }[] = [ const DATA_TYPES: { value: SqlDataType; label: string }[] = [
@ -183,6 +200,20 @@ const EMPTY_FK: Omit<SqlTableRelation, 'id'> = {
description: '', 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 = { const TENANT_COLUMN: ColumnDefinition = {
id: '__TenantId', id: '__TenantId',
columnName: 'TenantId', columnName: 'TenantId',
@ -219,6 +250,7 @@ function generateCreateTableSql(
columns: ColumnDefinition[], columns: ColumnDefinition[],
settings: TableSettings, settings: TableSettings,
relationships: SqlTableRelation[], relationships: SqlTableRelation[],
indexes: TableIndex[],
): string { ): string {
const tableName = settings.tableName || 'NewTable' const tableName = settings.tableName || 'NewTable'
const fullTableName = `[dbo].[${tableName}]` const fullTableName = `[dbo].[${tableName}]`
@ -264,6 +296,29 @@ function generateCreateTableSql(
fkLines.push(` ON UPDATE ${cascadeUpdate};`) 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[] = [ const lines: string[] = [
`/* ── Table: ${fullTableName} ── */`, `/* ── Table: ${fullTableName} ── */`,
...(settings.entityName ? [`/* Entity Name: ${settings.entityName} */`] : []), ...(settings.entityName ? [`/* Entity Name: ${settings.entityName} */`] : []),
@ -274,6 +329,7 @@ function generateCreateTableSql(
...bodyLines, ...bodyLines,
`);`, `);`,
...fkLines, ...fkLines,
...indexLines,
'', '',
`/* Verify: SELECT TOP 10 * FROM ${fullTableName}; */`, `/* Verify: SELECT TOP 10 * FROM ${fullTableName}; */`,
] ]
@ -335,6 +391,8 @@ function generateAlterTableSql(
tableName: string, tableName: string,
relationships: SqlTableRelation[], relationships: SqlTableRelation[],
originalRelationships: SqlTableRelation[], originalRelationships: SqlTableRelation[],
indexes: TableIndex[],
originalIndexes: TableIndex[],
): string { ): string {
const fullTableName = `[dbo].[${tableName}]` const fullTableName = `[dbo].[${tableName}]`
const lines: string[] = [`/* ── ALTER TABLE: ${fullTableName} ── */`, ''] const lines: string[] = [`/* ── ALTER TABLE: ${fullTableName} ── */`, '']
@ -474,17 +532,86 @@ function generateAlterTableSql(
} }
}) })
// 🔑 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) { if (!hasChanges) {
lines.push( lines.push(
'/* Henüz değişiklik yapılmadı. Sütunları ekleyin/silin/düzeyin ya da ilişki ekleyin. */', '/* Henüz değişiklik yapılmadı. Sütunları ekleyin/silin/düzeyin ya da ilişki/index ekleyin. */',
) )
} }
return lines.join('\n') return lines.join('\n')
} }
const STEPS = ['Sütun Tasarımı', 'Entity Ayarları', 'İlişkiler', 'T-SQL Önizleme'] as const const STEPS = ['Sütun Tasarımı', 'Entity Ayarları', 'İlişkiler', 'Index / Key', 'T-SQL Önizleme'] as const
type Step = 0 | 1 | 2 | 3 type Step = 0 | 1 | 2 | 3 | 4
// ─── Simple Menu Tree (read-only selection) ─────────────────────────────────── // ─── Simple Menu Tree (read-only selection) ───────────────────────────────────
@ -668,6 +795,12 @@ const SqlTableDesignerDialog = ({
const [dbTables, setDbTables] = useState<{ schemaName: string; tableName: string }[]>([]) const [dbTables, setDbTables] = useState<{ schemaName: string; tableName: string }[]>([])
const [targetTableColumns, setTargetTableColumns] = useState<string[]>([]) const [targetTableColumns, setTargetTableColumns] = useState<string[]>([])
const [targetColsLoading, setTargetColsLoading] = useState(false) 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) => { const reloadMenus = (onLoaded?: (items: MenuItem[]) => void) => {
setMenuLoading(true) setMenuLoading(true)
@ -775,6 +908,56 @@ const SqlTableDesignerDialog = ({
}) })
.catch(() => {}) .catch(() => {})
.finally(() => setFksLoading(false)) .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]) }, [isOpen, dataSource, initialTableData])
@ -787,8 +970,10 @@ const SqlTableDesignerDialog = ({
settings.tableName || initialTableData?.tableName || '', settings.tableName || initialTableData?.tableName || '',
relationships, relationships,
originalRelationships, originalRelationships,
indexes,
originalIndexes,
) )
: generateCreateTableSql(columns, settings, relationships), : generateCreateTableSql(columns, settings, relationships, indexes),
[ [
isEditMode, isEditMode,
originalColumns, originalColumns,
@ -797,6 +982,8 @@ const SqlTableDesignerDialog = ({
initialTableData, initialTableData,
relationships, relationships,
originalRelationships, originalRelationships,
indexes,
originalIndexes,
], ],
) )
@ -813,6 +1000,22 @@ const SqlTableDesignerDialog = ({
const nonEmpty = prev.filter((c) => c.columnName.trim() !== '') const nonEmpty = prev.filter((c) => c.columnName.trim() !== '')
return [...nonEmpty, ...toAdd.map((c) => ({ ...c })), createEmptyColumn()] return [...nonEmpty, ...toAdd.map((c) => ({ ...c })), createEmptyColumn()]
}) })
// Auto-add PK for Id column if not already defined
const hasPk = indexes.some((ix) => ix.indexType === 'PrimaryKey')
if (!hasPk) {
const tblName = settings.tableName || initialTableData?.tableName || 'Table'
setIndexes((prev) => [
...prev,
{
id: `auto-pk-${crypto.randomUUID()}`,
indexName: `PK_${tblName}`,
indexType: 'PrimaryKey',
isClustered: false,
columns: [{ columnName: 'Id', order: 'ASC' }],
description: 'Primary key',
},
])
}
} }
const addMultiTenantColumns = () => { const addMultiTenantColumns = () => {
@ -851,28 +1054,43 @@ const SqlTableDesignerDialog = ({
const buildTableName = (prefix: string, entity: string) => const buildTableName = (prefix: string, entity: string) =>
prefix && entity ? `${prefix}_D_${entity}` : '' prefix && entity ? `${prefix}_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) => { const onMenuCodeSelect = (code: string) => {
if (isEditMode) return if (isEditMode) return
const item = rawMenuItems.find((m) => m.code === code) const item = rawMenuItems.find((m) => m.code === code)
const prefix = item?.shortName ?? '' const prefix = item?.shortName ?? ''
setSelectedMenuCode(code) setSelectedMenuCode(code)
const newTableName = buildTableName(prefix, settings.entityName)
setSettings((s) => ({ setSettings((s) => ({
...s, ...s,
menuValue: prefix, menuValue: prefix,
menuPrefix: prefix, menuPrefix: prefix,
tableName: buildTableName(prefix, s.entityName), tableName: newTableName,
})) }))
syncAutoPkName(newTableName)
} }
// Strip spaces and special chars — only alphanumeric + underscore allowed // Strip spaces and special chars — only alphanumeric + underscore allowed
const onEntityNameChange = (value: string) => { const onEntityNameChange = (value: string) => {
const sanitized = value.replace(/[^A-Za-z0-9_]/g, '') const sanitized = value.replace(/[^A-Za-z0-9_]/g, '')
const newTableName = buildTableName(settings.menuPrefix, sanitized)
setSettings((s) => ({ setSettings((s) => ({
...s, ...s,
entityName: sanitized, entityName: sanitized,
tableName: buildTableName(s.menuPrefix, sanitized), tableName: newTableName,
displayName: sanitized, // always mirrors entity name displayName: sanitized, // always mirrors entity name
})) }))
syncAutoPkName(newTableName)
} }
// ── FK Relationship handlers ─────────────────────────────────────────────── // ── FK Relationship handlers ───────────────────────────────────────────────
@ -923,6 +1141,48 @@ const SqlTableDesignerDialog = ({
setRelationships((prev) => prev.filter((r) => r.id !== id)) 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 ───────────────────────────────────────────────────────────── // ── Navigation ─────────────────────────────────────────────────────────────
// Compute duplicate column names (lowercased) // Compute duplicate column names (lowercased)
@ -947,7 +1207,7 @@ const SqlTableDesignerDialog = ({
} }
const handleNext = () => { const handleNext = () => {
if (step < 3) setStep((s) => (s + 1) as Step) if (step < 4) setStep((s) => (s + 1) as Step)
} }
const handleBack = () => { const handleBack = () => {
if (step > 0) setStep((s) => (s - 1) as Step) if (step > 0) setStep((s) => (s - 1) as Step)
@ -1009,6 +1269,8 @@ const SqlTableDesignerDialog = ({
setSettings(DEFAULT_SETTINGS) setSettings(DEFAULT_SETTINGS)
setRelationships([]) setRelationships([])
setOriginalRelationships([]) setOriginalRelationships([])
setIndexes([])
setOriginalIndexes([])
setDbTables([]) setDbTables([])
setTargetTableColumns([]) setTargetTableColumns([])
setSelectedMenuCode('') setSelectedMenuCode('')
@ -1022,6 +1284,7 @@ const SqlTableDesignerDialog = ({
translate('::App.SqlQueryManager.ColumnDesign'), translate('::App.SqlQueryManager.ColumnDesign'),
translate('::App.SqlQueryManager.EntitySettings'), translate('::App.SqlQueryManager.EntitySettings'),
translate('::App.SqlQueryManager.Relationships'), translate('::App.SqlQueryManager.Relationships'),
translate('::App.SqlQueryManager.IndexKeys'),
translate('::App.SqlQueryManager.TSqlPreview'), translate('::App.SqlQueryManager.TSqlPreview'),
] ]
@ -1099,14 +1362,13 @@ const SqlTableDesignerDialog = ({
{/* Header row */} {/* 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="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-3">{translate('::App.SqlQueryManager.ColumnName')}</div> <div className="col-span-4">{translate('::App.SqlQueryManager.ColumnName')}</div>
<div className="col-span-3">{translate('::App.SqlQueryManager.DataType')}</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.Max')}</div>
<div className="col-span-1 text-center"> <div className="col-span-1 text-center">
{translate('::App.SqlQueryManager.Nullable')} {translate('::App.SqlQueryManager.Nullable')}
</div> </div>
<div className="col-span-2">{translate('::App.SqlQueryManager.DefaultValue')}</div> <div className="col-span-2">{translate('::App.SqlQueryManager.DefaultValue')}</div>
<div className="col-span-1">{translate('::App.SqlQueryManager.Note')}</div>
<div className="col-span-1 text-center"> <div className="col-span-1 text-center">
{translate('::App.SqlQueryManager.Actions')} {translate('::App.SqlQueryManager.Actions')}
</div> </div>
@ -1161,7 +1423,7 @@ const SqlTableDesignerDialog = ({
key={col.id} key={col.id}
className={`grid grid-cols-12 gap-2 bg-white dark:bg-gray-800 rounded items-center ${rowBg}`} className={`grid grid-cols-12 gap-2 bg-white dark:bg-gray-800 rounded items-center ${rowBg}`}
> >
<div className="col-span-3"> <div className="col-span-4">
<input <input
type="text" type="text"
className={`w-full px-2 py-1 text-sm border rounded dark:bg-gray-700 dark:border-gray-600 dark:text-white ${ className={`w-full px-2 py-1 text-sm border rounded dark:bg-gray-700 dark:border-gray-600 dark:text-white ${
@ -1215,15 +1477,6 @@ const SqlTableDesignerDialog = ({
onChange={(e) => updateColumn(col.id, 'defaultValue', e.target.value)} onChange={(e) => updateColumn(col.id, 'defaultValue', e.target.value)}
/> />
</div> </div>
<div className="col-span-1">
<input
type="text"
className="w-full px-2 py-1 text-xs border rounded dark:bg-gray-700 dark:border-gray-600 dark:text-white"
placeholder={translate('::App.SqlQueryManager.Note')}
value={col.description}
onChange={(e) => updateColumn(col.id, 'description', e.target.value)}
/>
</div>
<div className="col-span-1 flex items-center justify-center gap-1"> <div className="col-span-1 flex items-center justify-center gap-1">
<button <button
onClick={() => moveColumn(col.id, 'up')} onClick={() => moveColumn(col.id, 'up')}
@ -1683,7 +1936,332 @@ const SqlTableDesignerDialog = ({
</div> </div>
) )
// ── Step 3: T-SQL Preview ────────────────────────────────────────────────── // ── 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 = () => ( const renderSqlPreview = () => (
<div className="flex flex-col gap-3"> <div className="flex flex-col gap-3">
@ -1734,7 +2312,8 @@ const SqlTableDesignerDialog = ({
{step === 0 && renderColumnDesigner()} {step === 0 && renderColumnDesigner()}
{step === 1 && renderEntitySettings()} {step === 1 && renderEntitySettings()}
{step === 2 && renderRelationships()} {step === 2 && renderRelationships()}
{step === 3 && renderSqlPreview()} {step === 3 && renderIndexes()}
{step === 4 && renderSqlPreview()}
</div> </div>
{/* Footer */} {/* Footer */}
@ -1748,7 +2327,7 @@ const SqlTableDesignerDialog = ({
{translate('::App.SqlQueryManager.Back')} {translate('::App.SqlQueryManager.Back')}
</Button> </Button>
)} )}
{step < 3 ? ( {step < 4 ? (
<Button variant="solid" color="blue-600" onClick={handleNext} disabled={!canGoNext()}> <Button variant="solid" color="blue-600" onClick={handleNext} disabled={!canGoNext()}>
{translate('::App.SqlQueryManager.Next')} {translate('::App.SqlQueryManager.Next')}
</Button> </Button>