2026-08-26 09:10:12 +00:00
|
|
|
|
import React, { useState, useCallback, useMemo, useEffect, useRef } from 'react'
|
2026-03-02 21:34:19 +00:00
|
|
|
|
import { createPortal } from 'react-dom'
|
2026-08-20 14:54:11 +00:00
|
|
|
|
import { Button, Checkbox, Dialog, Notification, Select, toast } from '@/components/ui'
|
2026-03-01 17:40:25 +00:00
|
|
|
|
import {
|
|
|
|
|
|
FaPlus,
|
|
|
|
|
|
FaTrash,
|
|
|
|
|
|
FaArrowUp,
|
|
|
|
|
|
FaArrowDown,
|
|
|
|
|
|
FaTable,
|
|
|
|
|
|
FaCloudUploadAlt,
|
|
|
|
|
|
FaCheck,
|
|
|
|
|
|
FaLink,
|
|
|
|
|
|
FaEdit,
|
|
|
|
|
|
FaTimes,
|
|
|
|
|
|
FaArrowRight,
|
2026-03-03 07:34:25 +00:00
|
|
|
|
FaChevronDown,
|
|
|
|
|
|
FaChevronRight,
|
2026-03-09 13:35:00 +00:00
|
|
|
|
FaKey,
|
2026-08-19 07:50:33 +00:00
|
|
|
|
FaLayerGroup,
|
|
|
|
|
|
FaRegClipboard,
|
|
|
|
|
|
FaUsers,
|
|
|
|
|
|
FaHistory,
|
|
|
|
|
|
FaProjectDiagram,
|
|
|
|
|
|
FaTasks,
|
|
|
|
|
|
FaChartBar,
|
|
|
|
|
|
FaRegCalendarAlt,
|
2026-03-01 17:40:25 +00:00
|
|
|
|
} from 'react-icons/fa'
|
|
|
|
|
|
import { sqlObjectManagerService } from '@/services/sql-query-manager.service'
|
2026-03-03 07:34:25 +00:00
|
|
|
|
import { getMenus } from '@/services/menu.service'
|
2026-03-01 17:40:25 +00:00
|
|
|
|
import { useLocalization } from '@/utils/hooks/useLocalization'
|
2026-03-02 21:34:19 +00:00
|
|
|
|
import { CascadeBehavior, SqlTableRelation, RelationshipType } from '@/proxy/developerKit/models'
|
2026-08-16 15:30:07 +00:00
|
|
|
|
import { useNavigationIcons } from '@/proxy/menus/navigation-icon.config'
|
2026-03-03 07:34:25 +00:00
|
|
|
|
import { MenuItem } from '@/proxy/menus/menu'
|
|
|
|
|
|
import {
|
|
|
|
|
|
MenuTreeNode,
|
|
|
|
|
|
buildMenuTree,
|
|
|
|
|
|
filterNonLinkNodes,
|
2026-08-26 09:10:12 +00:00
|
|
|
|
} from '@/views/admin/listForm/wizard/menuTree'
|
2026-03-03 07:34:25 +00:00
|
|
|
|
import { MenuAddDialog } from '../shared/MenuAddDialog'
|
2026-07-09 17:55:07 +00:00
|
|
|
|
import { useStoreActions, useStoreState } from '@/store'
|
2026-08-17 14:26:51 +00:00
|
|
|
|
import { usePermission } from '@/utils/hooks/usePermission'
|
|
|
|
|
|
import { developerKitService } from '@/services/developerKit.service'
|
2026-08-20 12:01:10 +00:00
|
|
|
|
import Input from '@/components/ui/Input'
|
2026-08-17 14:26:51 +00:00
|
|
|
|
import {
|
|
|
|
|
|
CRUD_ENDPOINT_PERMISSION,
|
|
|
|
|
|
CRUD_OPERATION_TYPES,
|
|
|
|
|
|
type CrudOperationType,
|
|
|
|
|
|
getErrorMessage,
|
|
|
|
|
|
getOperationMeta,
|
|
|
|
|
|
toEntityName,
|
|
|
|
|
|
} from './CrudEndpointDialog'
|
2026-03-01 17:40:25 +00:00
|
|
|
|
|
|
|
|
|
|
// ─── Types ────────────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
|
|
type SqlDataType =
|
|
|
|
|
|
| 'nvarchar'
|
|
|
|
|
|
| 'nvarchar(MAX)'
|
|
|
|
|
|
| 'int'
|
|
|
|
|
|
| 'bigint'
|
|
|
|
|
|
| 'decimal'
|
|
|
|
|
|
| 'float'
|
|
|
|
|
|
| 'bit'
|
2026-06-06 18:31:03 +00:00
|
|
|
|
| 'datetime'
|
2026-03-01 17:40:25 +00:00
|
|
|
|
| '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
|
2026-06-06 18:31:03 +00:00
|
|
|
|
onDeployed?: (table: { schemaName: string; tableName: string }) => void | Promise<void>
|
2026-03-01 17:40:25 +00:00
|
|
|
|
initialTableData?: { schemaName: string; tableName: string } | null
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-09 13:35:00 +00:00
|
|
|
|
type IndexType = 'PrimaryKey' | 'UniqueKey' | 'Index'
|
|
|
|
|
|
|
|
|
|
|
|
interface IndexColumnEntry {
|
|
|
|
|
|
columnName: string
|
|
|
|
|
|
order: 'ASC' | 'DESC'
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
interface TableIndex {
|
|
|
|
|
|
id: string
|
|
|
|
|
|
indexName: string
|
|
|
|
|
|
indexType: IndexType
|
|
|
|
|
|
isClustered: boolean
|
|
|
|
|
|
columns: IndexColumnEntry[]
|
|
|
|
|
|
description: string
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-01 17:40:25 +00:00
|
|
|
|
// ─── Constants ────────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
2026-07-17 20:23:43 +00:00
|
|
|
|
const REL_TYPES: { value: RelationshipType; label: string; desc: string }[] = [
|
2026-08-14 14:03:34 +00:00
|
|
|
|
{ value: 'OneToMany', label: '1 → N', desc: 'App.DeveloperKitTableDesigner.OneToMany' },
|
|
|
|
|
|
{ value: 'OneToOne', label: '1 → 1', desc: 'App.DeveloperKitTableDesigner.OneToOne' },
|
2026-07-17 20:23:43 +00:00
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
|
|
const CASCADE_OPTIONS: { value: CascadeBehavior; label: string }[] = [
|
|
|
|
|
|
{ value: 'NoAction', label: 'No Action' },
|
|
|
|
|
|
{ value: 'Cascade', label: 'Cascade' },
|
|
|
|
|
|
{ value: 'SetNull', label: 'Set Null' },
|
|
|
|
|
|
{ value: 'Restrict', label: 'Restrict' },
|
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
|
|
const EMPTY_FK: Omit<SqlTableRelation, 'id'> = {
|
|
|
|
|
|
relationshipType: 'OneToMany',
|
|
|
|
|
|
fkColumnName: '',
|
|
|
|
|
|
referencedTable: '',
|
|
|
|
|
|
referencedColumn: 'Id',
|
|
|
|
|
|
cascadeDelete: 'NoAction',
|
|
|
|
|
|
cascadeUpdate: 'Cascade',
|
|
|
|
|
|
isRequired: false,
|
|
|
|
|
|
description: '',
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const EMPTY_INDEX: Omit<TableIndex, 'id'> = {
|
|
|
|
|
|
indexName: '',
|
|
|
|
|
|
indexType: 'Index',
|
|
|
|
|
|
isClustered: false,
|
|
|
|
|
|
columns: [],
|
|
|
|
|
|
description: '',
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const INDEX_TYPES: { value: IndexType; label: string; desc: string }[] = [
|
|
|
|
|
|
{
|
|
|
|
|
|
value: 'PrimaryKey',
|
|
|
|
|
|
label: 'Primary Key',
|
2026-08-14 14:03:34 +00:00
|
|
|
|
desc: 'App.SqlQueryManager.IndexTypePrimaryKeyDesc',
|
2026-07-17 20:23:43 +00:00
|
|
|
|
},
|
2026-08-14 14:03:34 +00:00
|
|
|
|
{ value: 'UniqueKey', label: 'Unique Key', desc: 'App.SqlQueryManager.IndexTypeUniqueKeyDesc' },
|
|
|
|
|
|
{ value: 'Index', label: 'Index', desc: 'App.SqlQueryManager.IndexTypeIndexDesc' },
|
2026-07-17 20:23:43 +00:00
|
|
|
|
]
|
|
|
|
|
|
|
2026-03-01 17:40:25 +00:00
|
|
|
|
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)' },
|
2026-06-06 18:31:03 +00:00
|
|
|
|
{ value: 'datetime', label: 'DateTime (datetime)' },
|
2026-03-01 17:40:25 +00:00
|
|
|
|
{ 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[] = [
|
2026-03-02 21:34:19 +00:00
|
|
|
|
{
|
|
|
|
|
|
id: '__Id',
|
|
|
|
|
|
columnName: 'Id',
|
|
|
|
|
|
dataType: 'uniqueidentifier',
|
|
|
|
|
|
maxLength: '',
|
|
|
|
|
|
isNullable: false,
|
|
|
|
|
|
defaultValue: 'NEWID()',
|
|
|
|
|
|
description: 'Primary key',
|
|
|
|
|
|
},
|
2026-03-01 17:40:25 +00:00
|
|
|
|
{
|
|
|
|
|
|
id: '__CreationTime',
|
|
|
|
|
|
columnName: 'CreationTime',
|
|
|
|
|
|
dataType: 'datetime2',
|
|
|
|
|
|
maxLength: '',
|
|
|
|
|
|
isNullable: false,
|
|
|
|
|
|
defaultValue: 'GETUTCDATE()',
|
|
|
|
|
|
description: 'Record creation time',
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
id: '__CreatorId',
|
|
|
|
|
|
columnName: 'CreatorId',
|
|
|
|
|
|
dataType: 'uniqueidentifier',
|
|
|
|
|
|
maxLength: '',
|
|
|
|
|
|
isNullable: true,
|
|
|
|
|
|
defaultValue: '',
|
|
|
|
|
|
description: 'Creator user ID',
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
id: '__LastModificationTime',
|
|
|
|
|
|
columnName: 'LastModificationTime',
|
|
|
|
|
|
dataType: 'datetime2',
|
|
|
|
|
|
maxLength: '',
|
|
|
|
|
|
isNullable: true,
|
|
|
|
|
|
defaultValue: '',
|
|
|
|
|
|
description: 'Last modification time',
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
id: '__LastModifierId',
|
|
|
|
|
|
columnName: 'LastModifierId',
|
|
|
|
|
|
dataType: 'uniqueidentifier',
|
|
|
|
|
|
maxLength: '',
|
|
|
|
|
|
isNullable: true,
|
|
|
|
|
|
defaultValue: '',
|
|
|
|
|
|
description: 'Last modifier user ID',
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
id: '__IsDeleted',
|
|
|
|
|
|
columnName: 'IsDeleted',
|
|
|
|
|
|
dataType: 'bit',
|
|
|
|
|
|
maxLength: '',
|
|
|
|
|
|
isNullable: false,
|
|
|
|
|
|
defaultValue: '0',
|
|
|
|
|
|
description: 'Soft delete flag',
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
id: '__DeletionTime',
|
|
|
|
|
|
columnName: 'DeletionTime',
|
|
|
|
|
|
dataType: 'datetime2',
|
|
|
|
|
|
maxLength: '',
|
|
|
|
|
|
isNullable: true,
|
|
|
|
|
|
defaultValue: '',
|
|
|
|
|
|
description: 'Deletion time',
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
id: '__DeleterId',
|
|
|
|
|
|
columnName: 'DeleterId',
|
|
|
|
|
|
dataType: 'uniqueidentifier',
|
|
|
|
|
|
maxLength: '',
|
|
|
|
|
|
isNullable: true,
|
|
|
|
|
|
defaultValue: '',
|
|
|
|
|
|
description: 'Deleter user ID',
|
|
|
|
|
|
},
|
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
|
|
const TENANT_COLUMN: ColumnDefinition = {
|
|
|
|
|
|
id: '__TenantId',
|
|
|
|
|
|
columnName: 'TenantId',
|
|
|
|
|
|
dataType: 'uniqueidentifier',
|
|
|
|
|
|
maxLength: '',
|
|
|
|
|
|
isNullable: true,
|
|
|
|
|
|
defaultValue: '',
|
|
|
|
|
|
description: 'Tenant ID for multi-tenancy',
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-06 18:31:03 +00:00
|
|
|
|
const WORKFLOW_COLUMNS: ColumnDefinition[] = [
|
2026-07-17 20:23:43 +00:00
|
|
|
|
{
|
|
|
|
|
|
id: '__WorkflowId',
|
|
|
|
|
|
columnName: 'Id',
|
|
|
|
|
|
dataType: 'uniqueidentifier',
|
|
|
|
|
|
maxLength: '',
|
|
|
|
|
|
isNullable: false,
|
|
|
|
|
|
defaultValue: 'NEWID()',
|
|
|
|
|
|
description: 'Workflow primary key',
|
|
|
|
|
|
},
|
2026-06-06 18:31:03 +00:00
|
|
|
|
{
|
2026-08-19 07:50:33 +00:00
|
|
|
|
id: '__UserId',
|
|
|
|
|
|
columnName: 'UserName',
|
2026-06-06 18:31:03 +00:00
|
|
|
|
dataType: 'nvarchar',
|
|
|
|
|
|
maxLength: '256',
|
|
|
|
|
|
isNullable: true,
|
|
|
|
|
|
defaultValue: '',
|
|
|
|
|
|
description: 'Workflow approval user name',
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
2026-08-19 07:50:33 +00:00
|
|
|
|
id: '__Status',
|
|
|
|
|
|
columnName: 'Status',
|
2026-06-06 18:31:03 +00:00
|
|
|
|
dataType: 'nvarchar',
|
|
|
|
|
|
maxLength: '50',
|
|
|
|
|
|
isNullable: true,
|
|
|
|
|
|
defaultValue: '',
|
|
|
|
|
|
description: 'Workflow approval status',
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
2026-08-19 07:50:33 +00:00
|
|
|
|
id: '__Date',
|
|
|
|
|
|
columnName: 'Date',
|
2026-06-06 18:31:03 +00:00
|
|
|
|
dataType: 'datetime',
|
|
|
|
|
|
maxLength: '',
|
|
|
|
|
|
isNullable: true,
|
|
|
|
|
|
defaultValue: '',
|
|
|
|
|
|
description: 'Workflow approval date',
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
2026-08-19 07:50:33 +00:00
|
|
|
|
id: '__Description',
|
|
|
|
|
|
columnName: 'Description',
|
2026-06-06 18:31:03 +00:00
|
|
|
|
dataType: 'nvarchar',
|
|
|
|
|
|
maxLength: '200',
|
|
|
|
|
|
isNullable: true,
|
|
|
|
|
|
defaultValue: '',
|
|
|
|
|
|
description: 'Workflow approval description',
|
|
|
|
|
|
},
|
|
|
|
|
|
]
|
|
|
|
|
|
|
2026-07-16 14:01:18 +00:00
|
|
|
|
const TODO_COLUMNS: ColumnDefinition[] = [
|
|
|
|
|
|
{
|
|
|
|
|
|
id: '__TodoId',
|
|
|
|
|
|
columnName: 'Id',
|
|
|
|
|
|
dataType: 'uniqueidentifier',
|
|
|
|
|
|
maxLength: '',
|
|
|
|
|
|
isNullable: false,
|
|
|
|
|
|
defaultValue: 'NEWID()',
|
|
|
|
|
|
description: 'Todo primary key',
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
id: '__TodoTitle',
|
|
|
|
|
|
columnName: 'Title',
|
|
|
|
|
|
dataType: 'nvarchar',
|
|
|
|
|
|
maxLength: '300',
|
|
|
|
|
|
isNullable: false,
|
|
|
|
|
|
defaultValue: '',
|
|
|
|
|
|
description: 'Todo title',
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
id: '__TodoStatus',
|
|
|
|
|
|
columnName: 'Status',
|
|
|
|
|
|
dataType: 'nvarchar',
|
|
|
|
|
|
maxLength: '50',
|
|
|
|
|
|
isNullable: false,
|
2026-07-30 14:37:15 +00:00
|
|
|
|
defaultValue: "N'Backlog'",
|
2026-07-16 14:01:18 +00:00
|
|
|
|
description: 'Todo board column/status',
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
id: '__TodoDescription',
|
|
|
|
|
|
columnName: 'Description',
|
|
|
|
|
|
dataType: 'nvarchar(MAX)',
|
|
|
|
|
|
maxLength: '',
|
|
|
|
|
|
isNullable: true,
|
|
|
|
|
|
defaultValue: '',
|
|
|
|
|
|
description: 'Todo description',
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
id: '__TodoDueDate',
|
|
|
|
|
|
columnName: 'DueDate',
|
|
|
|
|
|
dataType: 'datetime2',
|
|
|
|
|
|
maxLength: '',
|
|
|
|
|
|
isNullable: true,
|
|
|
|
|
|
defaultValue: '',
|
|
|
|
|
|
description: 'Todo deadline',
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
id: '__TodoTags',
|
|
|
|
|
|
columnName: 'Tags',
|
|
|
|
|
|
dataType: 'nvarchar',
|
|
|
|
|
|
maxLength: '500',
|
|
|
|
|
|
isNullable: true,
|
|
|
|
|
|
defaultValue: '',
|
|
|
|
|
|
description: 'Comma-separated todo tags',
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
id: '__TodoAssignees',
|
|
|
|
|
|
columnName: 'Assignees',
|
|
|
|
|
|
dataType: 'nvarchar',
|
|
|
|
|
|
maxLength: '500',
|
|
|
|
|
|
isNullable: true,
|
|
|
|
|
|
defaultValue: '',
|
|
|
|
|
|
description: 'Comma-separated todo assignees',
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
id: '__TodoPriority',
|
|
|
|
|
|
columnName: 'Priority',
|
|
|
|
|
|
dataType: 'nvarchar',
|
|
|
|
|
|
maxLength: '50',
|
|
|
|
|
|
isNullable: true,
|
|
|
|
|
|
defaultValue: '',
|
|
|
|
|
|
description: 'Todo priority',
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
id: '__TodoCompleted',
|
|
|
|
|
|
columnName: 'Completed',
|
|
|
|
|
|
dataType: 'bit',
|
|
|
|
|
|
maxLength: '',
|
|
|
|
|
|
isNullable: false,
|
|
|
|
|
|
defaultValue: '0',
|
|
|
|
|
|
description: 'Todo completion flag',
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
id: '__TodoSortOrder',
|
|
|
|
|
|
columnName: 'SortOrder',
|
|
|
|
|
|
dataType: 'int',
|
|
|
|
|
|
maxLength: '',
|
|
|
|
|
|
isNullable: false,
|
|
|
|
|
|
defaultValue: '0',
|
|
|
|
|
|
description: 'Todo order inside its board column',
|
|
|
|
|
|
},
|
2026-08-24 10:33:03 +00:00
|
|
|
|
{
|
|
|
|
|
|
id: '__TodoTimesheets',
|
|
|
|
|
|
columnName: 'Timesheets',
|
|
|
|
|
|
dataType: 'nvarchar(MAX)',
|
|
|
|
|
|
maxLength: '',
|
|
|
|
|
|
isNullable: true,
|
|
|
|
|
|
defaultValue: '',
|
|
|
|
|
|
description: 'Todo timesheet rows as JSON array',
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
id: '__TodoSubTasks',
|
|
|
|
|
|
columnName: 'SubTasks',
|
|
|
|
|
|
dataType: 'nvarchar(MAX)',
|
|
|
|
|
|
maxLength: '',
|
|
|
|
|
|
isNullable: true,
|
|
|
|
|
|
defaultValue: '',
|
|
|
|
|
|
description: 'Todo sub task rows as JSON array',
|
|
|
|
|
|
},
|
2026-07-16 14:01:18 +00:00
|
|
|
|
]
|
|
|
|
|
|
|
2026-07-28 14:00:38 +00:00
|
|
|
|
const GANTT_COLUMNS: ColumnDefinition[] = [
|
|
|
|
|
|
{
|
|
|
|
|
|
id: '__GanttId',
|
|
|
|
|
|
columnName: 'Id',
|
|
|
|
|
|
dataType: 'uniqueidentifier',
|
|
|
|
|
|
maxLength: '',
|
|
|
|
|
|
isNullable: false,
|
|
|
|
|
|
defaultValue: 'NEWID()',
|
|
|
|
|
|
description: 'Gantt task primary key',
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
id: '__GanttTitle',
|
|
|
|
|
|
columnName: 'Title',
|
|
|
|
|
|
dataType: 'nvarchar',
|
|
|
|
|
|
maxLength: '300',
|
|
|
|
|
|
isNullable: false,
|
|
|
|
|
|
defaultValue: '',
|
|
|
|
|
|
description: 'Gantt task title',
|
|
|
|
|
|
},
|
2026-07-29 07:57:49 +00:00
|
|
|
|
{
|
|
|
|
|
|
id: '__GanttParentId',
|
|
|
|
|
|
columnName: 'ParentId',
|
|
|
|
|
|
dataType: 'uniqueidentifier',
|
|
|
|
|
|
maxLength: '',
|
|
|
|
|
|
isNullable: true,
|
|
|
|
|
|
defaultValue: '',
|
|
|
|
|
|
description: 'Parent Gantt task ID',
|
|
|
|
|
|
},
|
2026-07-28 14:00:38 +00:00
|
|
|
|
{
|
|
|
|
|
|
id: '__GanttStartDate',
|
|
|
|
|
|
columnName: 'StartDate',
|
|
|
|
|
|
dataType: 'datetime2',
|
|
|
|
|
|
maxLength: '',
|
|
|
|
|
|
isNullable: false,
|
|
|
|
|
|
defaultValue: '',
|
|
|
|
|
|
description: 'Gantt task start date',
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
id: '__GanttEndDate',
|
|
|
|
|
|
columnName: 'EndDate',
|
|
|
|
|
|
dataType: 'datetime2',
|
|
|
|
|
|
maxLength: '',
|
|
|
|
|
|
isNullable: false,
|
|
|
|
|
|
defaultValue: '',
|
|
|
|
|
|
description: 'Gantt task end date',
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
id: '__GanttProgress',
|
|
|
|
|
|
columnName: 'Progress',
|
|
|
|
|
|
dataType: 'int',
|
|
|
|
|
|
maxLength: '',
|
|
|
|
|
|
isNullable: false,
|
|
|
|
|
|
defaultValue: '0',
|
|
|
|
|
|
description: 'Gantt task completion percentage',
|
|
|
|
|
|
},
|
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
|
|
const SCHEDULER_COLUMNS: ColumnDefinition[] = [
|
|
|
|
|
|
{
|
|
|
|
|
|
id: '__SchedulerId',
|
|
|
|
|
|
columnName: 'Id',
|
|
|
|
|
|
dataType: 'uniqueidentifier',
|
|
|
|
|
|
maxLength: '',
|
|
|
|
|
|
isNullable: false,
|
|
|
|
|
|
defaultValue: 'NEWID()',
|
|
|
|
|
|
description: 'Scheduler appointment primary key',
|
|
|
|
|
|
},
|
2026-07-30 19:38:27 +00:00
|
|
|
|
{
|
|
|
|
|
|
id: '__SchedulerUserName',
|
|
|
|
|
|
columnName: 'UserName',
|
|
|
|
|
|
dataType: 'nvarchar',
|
|
|
|
|
|
maxLength: '256',
|
|
|
|
|
|
isNullable: false,
|
|
|
|
|
|
defaultValue: '',
|
|
|
|
|
|
description: 'Scheduler appointment userName',
|
|
|
|
|
|
},
|
2026-07-28 14:00:38 +00:00
|
|
|
|
{
|
|
|
|
|
|
id: '__SchedulerText',
|
|
|
|
|
|
columnName: 'Text',
|
|
|
|
|
|
dataType: 'nvarchar',
|
2026-07-30 19:38:27 +00:00
|
|
|
|
maxLength: '512',
|
2026-07-28 14:00:38 +00:00
|
|
|
|
isNullable: false,
|
|
|
|
|
|
defaultValue: '',
|
|
|
|
|
|
description: 'Scheduler appointment text',
|
|
|
|
|
|
},
|
2026-07-30 19:38:27 +00:00
|
|
|
|
{
|
|
|
|
|
|
id: '__SchedulerDescription',
|
|
|
|
|
|
columnName: 'Description',
|
|
|
|
|
|
dataType: 'nvarchar(MAX)',
|
|
|
|
|
|
maxLength: '',
|
|
|
|
|
|
isNullable: false,
|
|
|
|
|
|
defaultValue: '',
|
|
|
|
|
|
description: 'Scheduler appointment description',
|
|
|
|
|
|
},
|
2026-07-28 14:00:38 +00:00
|
|
|
|
{
|
|
|
|
|
|
id: '__SchedulerStartDate',
|
|
|
|
|
|
columnName: 'StartDate',
|
|
|
|
|
|
dataType: 'datetime2',
|
|
|
|
|
|
maxLength: '',
|
|
|
|
|
|
isNullable: false,
|
|
|
|
|
|
defaultValue: '',
|
|
|
|
|
|
description: 'Scheduler appointment start date',
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
id: '__SchedulerEndDate',
|
|
|
|
|
|
columnName: 'EndDate',
|
|
|
|
|
|
dataType: 'datetime2',
|
|
|
|
|
|
maxLength: '',
|
|
|
|
|
|
isNullable: false,
|
|
|
|
|
|
defaultValue: '',
|
|
|
|
|
|
description: 'Scheduler appointment end date',
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
id: '__SchedulerAllDay',
|
|
|
|
|
|
columnName: 'AllDay',
|
|
|
|
|
|
dataType: 'bit',
|
|
|
|
|
|
maxLength: '',
|
|
|
|
|
|
isNullable: false,
|
|
|
|
|
|
defaultValue: '0',
|
|
|
|
|
|
description: 'Scheduler all-day appointment flag',
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
id: '__SchedulerRecurrenceRule',
|
|
|
|
|
|
columnName: 'RecurrenceRule',
|
|
|
|
|
|
dataType: 'nvarchar',
|
2026-07-30 19:38:27 +00:00
|
|
|
|
maxLength: '512',
|
2026-07-28 14:00:38 +00:00
|
|
|
|
isNullable: true,
|
|
|
|
|
|
defaultValue: '',
|
|
|
|
|
|
description: 'Scheduler recurrence rule',
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
id: '__SchedulerRecurrenceException',
|
|
|
|
|
|
columnName: 'RecurrenceException',
|
|
|
|
|
|
dataType: 'nvarchar(MAX)',
|
|
|
|
|
|
maxLength: '',
|
|
|
|
|
|
isNullable: true,
|
|
|
|
|
|
defaultValue: '',
|
|
|
|
|
|
description: 'Scheduler recurrence exceptions',
|
|
|
|
|
|
},
|
|
|
|
|
|
]
|
|
|
|
|
|
|
2026-03-01 17:40:25 +00:00
|
|
|
|
// ─── 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}` : ''
|
2026-05-01 10:20:40 +00:00
|
|
|
|
return ` [${col.columnName}] ${typeSql} ${nullPart}${defaultPart}${addComma ? ',' : ''}`
|
2026-03-01 17:40:25 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-23 21:12:01 +00:00
|
|
|
|
function buildCreateIndexIfNotExistsSql(
|
|
|
|
|
|
fullTableName: string,
|
|
|
|
|
|
indexName: string,
|
|
|
|
|
|
isClustered: boolean,
|
|
|
|
|
|
colsSql: string,
|
|
|
|
|
|
): string[] {
|
|
|
|
|
|
const escapedFullTableName = fullTableName.replace(/'/g, "''")
|
|
|
|
|
|
const escapedIndexName = indexName.replace(/'/g, "''")
|
|
|
|
|
|
|
|
|
|
|
|
return [
|
|
|
|
|
|
`IF INDEXPROPERTY(OBJECT_ID(N'${escapedFullTableName}'), '${escapedIndexName}', 'IndexID') IS NULL`,
|
|
|
|
|
|
`BEGIN`,
|
|
|
|
|
|
` CREATE ${isClustered ? 'CLUSTERED ' : ''}INDEX [${indexName}] ON ${fullTableName} (${colsSql});`,
|
|
|
|
|
|
`END`,
|
|
|
|
|
|
]
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-01 17:40:25 +00:00
|
|
|
|
function generateCreateTableSql(
|
|
|
|
|
|
columns: ColumnDefinition[],
|
|
|
|
|
|
settings: TableSettings,
|
2026-03-02 21:34:19 +00:00
|
|
|
|
relationships: SqlTableRelation[],
|
2026-03-09 13:35:00 +00:00
|
|
|
|
indexes: TableIndex[],
|
2026-03-01 17:40:25 +00:00
|
|
|
|
): string {
|
|
|
|
|
|
const tableName = settings.tableName || 'NewTable'
|
|
|
|
|
|
const fullTableName = `[dbo].[${tableName}]`
|
2026-05-01 10:20:40 +00:00
|
|
|
|
const escapedFullTableName = fullTableName.replace(/'/g, "''")
|
2026-03-01 17:40:25 +00:00
|
|
|
|
|
|
|
|
|
|
const userCols = columns.filter((c) => c.columnName.trim())
|
2026-05-01 10:20:40 +00:00
|
|
|
|
const bodyLines = userCols.map((c) => colToSqlLine(c, false))
|
|
|
|
|
|
|
|
|
|
|
|
// Inline PRIMARY KEY constraint inside CREATE TABLE
|
|
|
|
|
|
const pkIndex = indexes.find((idx) => idx.indexType === 'PrimaryKey' && idx.columns.length > 0)
|
|
|
|
|
|
const pkConstraintLines: string[] = []
|
|
|
|
|
|
if (pkIndex) {
|
|
|
|
|
|
const clustered = pkIndex.isClustered ? 'CLUSTERED' : 'NONCLUSTERED'
|
|
|
|
|
|
const colsSql = pkIndex.columns.map((c) => ` [${c.columnName}] ${c.order}`).join(',\n')
|
|
|
|
|
|
pkConstraintLines.push(
|
|
|
|
|
|
` CONSTRAINT [${pkIndex.indexName}] PRIMARY KEY ${clustered}`,
|
|
|
|
|
|
` (`,
|
|
|
|
|
|
colsSql,
|
|
|
|
|
|
` )WITH (PAD_INDEX = OFF, STATISTICS_NORECOMPUTE = OFF, IGNORE_DUP_KEY = OFF, ALLOW_ROW_LOCKS = ON, ALLOW_PAGE_LOCKS = ON, OPTIMIZE_FOR_SEQUENTIAL_KEY = OFF) ON [PRIMARY]`,
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Build all column + pk lines with commas
|
|
|
|
|
|
const allInnerLines: string[] = []
|
|
|
|
|
|
for (let i = 0; i < bodyLines.length; i++) {
|
|
|
|
|
|
allInnerLines.push(bodyLines[i] + ',')
|
|
|
|
|
|
}
|
|
|
|
|
|
if (pkConstraintLines.length > 0) {
|
|
|
|
|
|
for (let i = 0; i < pkConstraintLines.length; i++) {
|
|
|
|
|
|
allInnerLines.push(pkConstraintLines[i])
|
|
|
|
|
|
}
|
|
|
|
|
|
} else if (allInnerLines.length > 0) {
|
|
|
|
|
|
// Remove trailing comma from last column if no PK
|
2026-06-25 22:55:44 +00:00
|
|
|
|
allInnerLines[allInnerLines.length - 1] = allInnerLines[allInnerLines.length - 1].replace(
|
|
|
|
|
|
/,$/,
|
|
|
|
|
|
'',
|
|
|
|
|
|
)
|
2026-05-01 10:20:40 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Non-PK index lines (UNIQUE, regular INDEX) — outside CREATE TABLE
|
|
|
|
|
|
const extraIndexLines: string[] = []
|
|
|
|
|
|
for (const idx of indexes) {
|
|
|
|
|
|
if (idx.columns.length === 0 || idx.indexType === 'PrimaryKey') continue
|
|
|
|
|
|
const clustered = idx.isClustered ? 'CLUSTERED' : 'NONCLUSTERED'
|
|
|
|
|
|
const colsSql = idx.columns.map((c) => `[${c.columnName}] ${c.order}`).join(', ')
|
|
|
|
|
|
extraIndexLines.push('')
|
|
|
|
|
|
if (idx.indexType === 'UniqueKey') {
|
|
|
|
|
|
extraIndexLines.push(`-- 🔒 Unique Key: [${idx.indexName}]`)
|
|
|
|
|
|
extraIndexLines.push(`ALTER TABLE ${fullTableName}`)
|
2026-06-25 22:55:44 +00:00
|
|
|
|
extraIndexLines.push(
|
|
|
|
|
|
` ADD CONSTRAINT [${idx.indexName}] UNIQUE ${clustered} (${colsSql});`,
|
|
|
|
|
|
)
|
2026-05-01 10:20:40 +00:00
|
|
|
|
} else {
|
|
|
|
|
|
extraIndexLines.push(`-- 📋 Index: [${idx.indexName}]`)
|
|
|
|
|
|
extraIndexLines.push(
|
2026-06-25 22:55:44 +00:00
|
|
|
|
...buildCreateIndexIfNotExistsSql(fullTableName, idx.indexName, idx.isClustered, colsSql),
|
2026-05-01 10:20:40 +00:00
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-03-01 17:40:25 +00:00
|
|
|
|
|
|
|
|
|
|
// FK constraint lines
|
|
|
|
|
|
const fkLines: string[] = []
|
|
|
|
|
|
for (const rel of relationships) {
|
|
|
|
|
|
if (!rel.fkColumnName.trim() || !rel.referencedTable.trim()) continue
|
|
|
|
|
|
const constraintName = `FK_${tableName}_${rel.fkColumnName}`
|
2026-03-03 07:34:25 +00:00
|
|
|
|
const cascadeDelete =
|
|
|
|
|
|
rel.cascadeDelete === 'NoAction'
|
|
|
|
|
|
? 'NO ACTION'
|
|
|
|
|
|
: rel.cascadeDelete
|
|
|
|
|
|
.replace(/([A-Z])/g, ' $1')
|
|
|
|
|
|
.trim()
|
|
|
|
|
|
.toUpperCase()
|
|
|
|
|
|
const cascadeUpdate =
|
|
|
|
|
|
rel.cascadeUpdate === 'NoAction'
|
|
|
|
|
|
? 'NO ACTION'
|
|
|
|
|
|
: rel.cascadeUpdate
|
|
|
|
|
|
.replace(/([A-Z])/g, ' $1')
|
|
|
|
|
|
.trim()
|
|
|
|
|
|
.toUpperCase()
|
2026-03-01 17:40:25 +00:00
|
|
|
|
fkLines.push('')
|
|
|
|
|
|
fkLines.push(`ALTER TABLE ${fullTableName}`)
|
|
|
|
|
|
fkLines.push(` ADD CONSTRAINT [${constraintName}]`)
|
|
|
|
|
|
fkLines.push(` FOREIGN KEY ([${rel.fkColumnName}])`)
|
2026-03-03 07:34:25 +00:00
|
|
|
|
fkLines.push(
|
|
|
|
|
|
` REFERENCES [dbo].[${rel.referencedTable}] ([${rel.referencedColumn || 'Id'}])`,
|
|
|
|
|
|
)
|
2026-03-01 17:40:25 +00:00
|
|
|
|
fkLines.push(` ON DELETE ${cascadeDelete}`)
|
|
|
|
|
|
fkLines.push(` ON UPDATE ${cascadeUpdate};`)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const lines: string[] = [
|
2026-05-01 10:20:40 +00:00
|
|
|
|
`IF OBJECT_ID(N'${escapedFullTableName}', 'U') IS NULL`,
|
|
|
|
|
|
`BEGIN`,
|
|
|
|
|
|
` CREATE TABLE ${fullTableName}`,
|
|
|
|
|
|
` (`,
|
|
|
|
|
|
...allInnerLines,
|
|
|
|
|
|
` ) ON [PRIMARY]`,
|
|
|
|
|
|
`END`,
|
|
|
|
|
|
`GO`,
|
|
|
|
|
|
...extraIndexLines,
|
|
|
|
|
|
...(fkLines.length > 0 ? ['', '/* Foreign Key Constraints */'] : []),
|
2026-03-18 09:00:11 +00:00
|
|
|
|
...fkLines,
|
2026-03-01 17:40:25 +00:00
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
|
|
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'
|
2026-06-06 18:31:03 +00:00
|
|
|
|
} else if (dt === 'datetime') {
|
|
|
|
|
|
dataType = 'datetime'
|
2026-03-01 17:40:25 +00:00
|
|
|
|
} 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: '',
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-21 14:42:36 +00:00
|
|
|
|
function splitByTopLevelComma(input: string): string[] {
|
|
|
|
|
|
const parts: string[] = []
|
|
|
|
|
|
let current = ''
|
|
|
|
|
|
let depth = 0
|
|
|
|
|
|
|
|
|
|
|
|
for (let i = 0; i < input.length; i++) {
|
|
|
|
|
|
const ch = input[i]
|
|
|
|
|
|
if (ch === '(') depth++
|
|
|
|
|
|
if (ch === ')') depth = Math.max(0, depth - 1)
|
|
|
|
|
|
|
|
|
|
|
|
if (ch === ',' && depth === 0) {
|
|
|
|
|
|
if (current.trim()) parts.push(current.trim())
|
|
|
|
|
|
current = ''
|
|
|
|
|
|
continue
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
current += ch
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (current.trim()) parts.push(current.trim())
|
|
|
|
|
|
return parts
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function unwrapDefaultExpression(value: string): string {
|
|
|
|
|
|
let result = value.trim()
|
|
|
|
|
|
while (result.startsWith('(') && result.endsWith(')')) {
|
|
|
|
|
|
result = result.slice(1, -1).trim()
|
|
|
|
|
|
}
|
|
|
|
|
|
return result
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-25 22:55:44 +00:00
|
|
|
|
function mapSqlTypeToDesigner(
|
|
|
|
|
|
dataTypeRaw: string,
|
|
|
|
|
|
lengthRaw: string,
|
|
|
|
|
|
): {
|
2026-03-21 14:42:36 +00:00
|
|
|
|
dataType: SqlDataType
|
|
|
|
|
|
maxLength: string
|
|
|
|
|
|
} {
|
|
|
|
|
|
const dt = dataTypeRaw.toLowerCase()
|
|
|
|
|
|
const len = lengthRaw.toLowerCase()
|
|
|
|
|
|
|
|
|
|
|
|
if (dt === 'nvarchar' || dt === 'varchar' || dt === 'char' || dt === 'nchar') {
|
|
|
|
|
|
if (len === 'max') {
|
|
|
|
|
|
return { dataType: 'nvarchar(MAX)', maxLength: '' }
|
|
|
|
|
|
}
|
|
|
|
|
|
return { dataType: 'nvarchar', maxLength: len || '100' }
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (dt === 'int') return { dataType: 'int', maxLength: '' }
|
|
|
|
|
|
if (dt === 'bigint') return { dataType: 'bigint', maxLength: '' }
|
|
|
|
|
|
if (dt === 'decimal' || dt === 'numeric') return { dataType: 'decimal', maxLength: '' }
|
|
|
|
|
|
if (dt === 'float' || dt === 'real') return { dataType: 'float', maxLength: '' }
|
|
|
|
|
|
if (dt === 'bit') return { dataType: 'bit', maxLength: '' }
|
2026-06-06 18:31:03 +00:00
|
|
|
|
if (dt === 'datetime') return { dataType: 'datetime', maxLength: '' }
|
2026-03-21 14:42:36 +00:00
|
|
|
|
if (dt.startsWith('datetime') || dt === 'smalldatetime' || dt === 'time') {
|
|
|
|
|
|
return { dataType: 'datetime2', maxLength: '' }
|
|
|
|
|
|
}
|
|
|
|
|
|
if (dt === 'date') return { dataType: 'date', maxLength: '' }
|
|
|
|
|
|
if (dt === 'uniqueidentifier') return { dataType: 'uniqueidentifier', maxLength: '' }
|
|
|
|
|
|
if (dt === 'money' || dt === 'smallmoney') return { dataType: 'money', maxLength: '' }
|
|
|
|
|
|
|
|
|
|
|
|
return { dataType: 'nvarchar', maxLength: '100' }
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function parseCreateTableColumns(script: string): ColumnDefinition[] {
|
|
|
|
|
|
if (!script?.trim()) return []
|
|
|
|
|
|
|
|
|
|
|
|
const createIdx = script.search(/\bCREATE\s+TABLE\b/i)
|
|
|
|
|
|
if (createIdx < 0) return []
|
|
|
|
|
|
|
|
|
|
|
|
const openParenIdx = script.indexOf('(', createIdx)
|
|
|
|
|
|
if (openParenIdx < 0) return []
|
|
|
|
|
|
|
|
|
|
|
|
let depth = 0
|
|
|
|
|
|
let closeParenIdx = -1
|
|
|
|
|
|
for (let i = openParenIdx; i < script.length; i++) {
|
|
|
|
|
|
const ch = script[i]
|
|
|
|
|
|
if (ch === '(') depth++
|
|
|
|
|
|
if (ch === ')') {
|
|
|
|
|
|
depth--
|
|
|
|
|
|
if (depth === 0) {
|
|
|
|
|
|
closeParenIdx = i
|
|
|
|
|
|
break
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (closeParenIdx < 0) return []
|
|
|
|
|
|
|
|
|
|
|
|
const body = script.slice(openParenIdx + 1, closeParenIdx)
|
|
|
|
|
|
const defs = splitByTopLevelComma(body)
|
|
|
|
|
|
const columns: ColumnDefinition[] = []
|
|
|
|
|
|
|
|
|
|
|
|
for (const def of defs) {
|
|
|
|
|
|
const trimmed = def.trim()
|
|
|
|
|
|
if (!trimmed) continue
|
|
|
|
|
|
|
2026-06-25 22:55:44 +00:00
|
|
|
|
if (/^(CONSTRAINT|PRIMARY\s+KEY|UNIQUE\s+KEY|UNIQUE|FOREIGN\s+KEY|CHECK)\b/i.test(trimmed)) {
|
2026-03-21 14:42:36 +00:00
|
|
|
|
continue
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const colMatch = trimmed.match(/^\[([^\]]+)\]\s+(.+)$/i)
|
|
|
|
|
|
if (!colMatch) continue
|
|
|
|
|
|
|
|
|
|
|
|
const columnName = colMatch[1].trim()
|
|
|
|
|
|
const rest = colMatch[2].trim()
|
|
|
|
|
|
|
|
|
|
|
|
const typeMatch = rest.match(/^\[?([A-Za-z0-9_]+)\]?\s*(?:\(([^)]*)\))?/)
|
|
|
|
|
|
if (!typeMatch) continue
|
|
|
|
|
|
|
|
|
|
|
|
const sqlType = typeMatch[1] ?? 'nvarchar'
|
|
|
|
|
|
const sqlLength = (typeMatch[2] ?? '').trim()
|
|
|
|
|
|
const mapped = mapSqlTypeToDesigner(sqlType, sqlLength)
|
|
|
|
|
|
|
|
|
|
|
|
const isNullable = !/\bNOT\s+NULL\b/i.test(rest)
|
|
|
|
|
|
const defaultMatch = rest.match(/\bDEFAULT\b\s+(.+)$/i)
|
|
|
|
|
|
const defaultValue = defaultMatch ? unwrapDefaultExpression(defaultMatch[1]) : ''
|
|
|
|
|
|
|
|
|
|
|
|
columns.push({
|
|
|
|
|
|
id: crypto.randomUUID(),
|
|
|
|
|
|
columnName,
|
|
|
|
|
|
dataType: mapped.dataType,
|
|
|
|
|
|
maxLength: mapped.maxLength,
|
|
|
|
|
|
isNullable,
|
|
|
|
|
|
defaultValue,
|
|
|
|
|
|
description: '',
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return columns
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-01 17:40:25 +00:00
|
|
|
|
/** Generate ALTER TABLE diff SQL (edit mode) */
|
|
|
|
|
|
function generateAlterTableSql(
|
|
|
|
|
|
originalCols: ColumnDefinition[],
|
|
|
|
|
|
currentCols: ColumnDefinition[],
|
|
|
|
|
|
tableName: string,
|
2026-03-02 21:34:19 +00:00
|
|
|
|
relationships: SqlTableRelation[],
|
|
|
|
|
|
originalRelationships: SqlTableRelation[],
|
2026-03-09 13:35:00 +00:00
|
|
|
|
indexes: TableIndex[],
|
|
|
|
|
|
originalIndexes: TableIndex[],
|
2026-08-14 11:32:31 +00:00
|
|
|
|
translate: (key: string, params?: Record<string, string | number>) => string,
|
2026-03-01 17:40:25 +00:00
|
|
|
|
): string {
|
|
|
|
|
|
const fullTableName = `[dbo].[${tableName}]`
|
|
|
|
|
|
const lines: string[] = [`/* ── ALTER TABLE: ${fullTableName} ── */`, '']
|
|
|
|
|
|
let hasChanges = false
|
|
|
|
|
|
|
|
|
|
|
|
// Build id-based maps for O(1) lookup
|
|
|
|
|
|
const origById = new Map<string, ColumnDefinition>()
|
|
|
|
|
|
originalCols.forEach((c) => origById.set(c.id, c))
|
|
|
|
|
|
|
|
|
|
|
|
const curById = new Map<string, ColumnDefinition>()
|
|
|
|
|
|
currentCols.filter((c) => c.columnName.trim()).forEach((c) => curById.set(c.id, c))
|
|
|
|
|
|
|
|
|
|
|
|
// ➕ Added columns (id exists in current but NOT in original)
|
|
|
|
|
|
currentCols
|
|
|
|
|
|
.filter((c) => c.columnName.trim())
|
|
|
|
|
|
.forEach((col) => {
|
|
|
|
|
|
if (!origById.has(col.id)) {
|
|
|
|
|
|
hasChanges = true
|
2026-08-14 11:32:31 +00:00
|
|
|
|
lines.push(
|
2026-08-14 14:03:34 +00:00
|
|
|
|
`-- ➕ ${translate('::App.TableDesignerDiff.AddedColumn', { name: col.columnName })}`,
|
2026-08-14 11:32:31 +00:00
|
|
|
|
)
|
2026-03-01 17:40:25 +00:00
|
|
|
|
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
|
2026-08-14 11:32:31 +00:00
|
|
|
|
lines.push(
|
2026-08-14 14:03:34 +00:00
|
|
|
|
`-- ❌ ${translate('::App.TableDesignerDiff.DeletedColumn', { name: col.columnName })}`,
|
2026-08-14 11:32:31 +00:00
|
|
|
|
)
|
2026-03-01 17:40:25 +00:00
|
|
|
|
lines.push(`ALTER TABLE ${fullTableName}`)
|
|
|
|
|
|
lines.push(` DROP COLUMN [${col.columnName}];`)
|
|
|
|
|
|
lines.push('')
|
|
|
|
|
|
}
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
// ✏️ Renamed / Modified columns (same id in both)
|
|
|
|
|
|
currentCols
|
|
|
|
|
|
.filter((c) => c.columnName.trim())
|
|
|
|
|
|
.forEach((col) => {
|
|
|
|
|
|
const orig = origById.get(col.id)
|
|
|
|
|
|
if (!orig) return // new column, already handled above
|
|
|
|
|
|
|
2026-03-03 07:34:25 +00:00
|
|
|
|
const nameChanged =
|
|
|
|
|
|
orig.columnName.trim().toLowerCase() !== col.columnName.trim().toLowerCase()
|
2026-03-01 17:40:25 +00:00
|
|
|
|
const typeChanged = orig.dataType !== col.dataType || orig.maxLength !== col.maxLength
|
|
|
|
|
|
const nullChanged = orig.isNullable !== col.isNullable
|
|
|
|
|
|
|
|
|
|
|
|
// Rename: use sp_rename to preserve data
|
|
|
|
|
|
if (nameChanged) {
|
|
|
|
|
|
hasChanges = true
|
2026-08-14 11:32:31 +00:00
|
|
|
|
lines.push(
|
2026-08-14 14:03:34 +00:00
|
|
|
|
`-- 🔄 ${translate('::App.TableDesignerDiff.RenamedColumn', {
|
2026-08-14 11:32:31 +00:00
|
|
|
|
from: orig.columnName,
|
|
|
|
|
|
to: col.columnName,
|
|
|
|
|
|
})}`,
|
|
|
|
|
|
)
|
2026-03-01 17:40:25 +00:00
|
|
|
|
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
|
2026-08-14 14:03:34 +00:00
|
|
|
|
? `-- ✏️ ${translate('::App.TableDesignerDiff.ModifiedColumnAfterRename', { name: col.columnName })}`
|
|
|
|
|
|
: `-- ✏️ ${translate('::App.TableDesignerDiff.ModifiedColumn', { name: col.columnName })}`
|
2026-03-01 17:40:25 +00:00
|
|
|
|
lines.push(label)
|
|
|
|
|
|
lines.push(`ALTER TABLE ${fullTableName}`)
|
|
|
|
|
|
lines.push(
|
|
|
|
|
|
` ALTER COLUMN ${colToSqlLine({ ...col, defaultValue: '' }, false).trimStart()};`,
|
|
|
|
|
|
)
|
|
|
|
|
|
lines.push('')
|
|
|
|
|
|
}
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
// 🔗 FK Diff: drop removed / drop+re-add modified / add new
|
|
|
|
|
|
const fkCascadeSql = (b: CascadeBehavior) =>
|
2026-03-03 07:34:25 +00:00
|
|
|
|
b === 'NoAction'
|
|
|
|
|
|
? 'NO ACTION'
|
|
|
|
|
|
: b
|
|
|
|
|
|
.replace(/([A-Z])/g, ' $1')
|
|
|
|
|
|
.trim()
|
|
|
|
|
|
.toUpperCase()
|
2026-03-01 17:40:25 +00:00
|
|
|
|
|
2026-03-02 21:34:19 +00:00
|
|
|
|
const addFkSql = (rel: SqlTableRelation) => {
|
2026-03-01 17:40:25 +00:00
|
|
|
|
const cname = rel.constraintName ?? `FK_${tableName}_${rel.fkColumnName}`
|
2026-08-14 11:32:31 +00:00
|
|
|
|
lines.push(
|
2026-08-14 14:03:34 +00:00
|
|
|
|
`-- 🔗 ${translate('::App.TableDesignerDiff.AddFk', {
|
2026-08-14 11:32:31 +00:00
|
|
|
|
column: rel.fkColumnName,
|
|
|
|
|
|
table: rel.referencedTable,
|
|
|
|
|
|
})}`,
|
|
|
|
|
|
)
|
2026-03-01 17:40:25 +00:00
|
|
|
|
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('')
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-02 21:34:19 +00:00
|
|
|
|
const origRelById = new Map<string, SqlTableRelation>()
|
2026-03-01 17:40:25 +00:00
|
|
|
|
originalRelationships.forEach((r) => origRelById.set(r.id, r))
|
2026-03-02 21:34:19 +00:00
|
|
|
|
const curRelById = new Map<string, SqlTableRelation>()
|
2026-03-01 17:40:25 +00:00
|
|
|
|
relationships.forEach((r) => curRelById.set(r.id, r))
|
|
|
|
|
|
|
|
|
|
|
|
// Removed FKs → DROP CONSTRAINT
|
|
|
|
|
|
originalRelationships.forEach((orig) => {
|
|
|
|
|
|
if (!curRelById.has(orig.id)) {
|
|
|
|
|
|
hasChanges = true
|
|
|
|
|
|
const cname = orig.constraintName ?? `FK_${tableName}_${orig.fkColumnName}`
|
2026-08-14 11:32:31 +00:00
|
|
|
|
lines.push(
|
2026-08-14 14:03:34 +00:00
|
|
|
|
`-- ❌ ${translate('::App.TableDesignerDiff.DropFk', { name: orig.constraintName ?? cname })}`,
|
2026-08-14 11:32:31 +00:00
|
|
|
|
)
|
2026-03-01 17:40:25 +00:00
|
|
|
|
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}`
|
2026-08-19 07:50:33 +00:00
|
|
|
|
lines.push(`-- ✏️ ${translate('::App.TableDesignerDiff.UpdateFk', { name: cname })}`)
|
2026-03-01 17:40:25 +00:00
|
|
|
|
lines.push(`ALTER TABLE ${fullTableName}`)
|
|
|
|
|
|
lines.push(` DROP CONSTRAINT [${cname}];`)
|
|
|
|
|
|
lines.push('')
|
|
|
|
|
|
addFkSql(rel)
|
|
|
|
|
|
}
|
|
|
|
|
|
} else {
|
|
|
|
|
|
// New FK
|
|
|
|
|
|
hasChanges = true
|
|
|
|
|
|
addFkSql(rel)
|
|
|
|
|
|
}
|
|
|
|
|
|
})
|
|
|
|
|
|
|
2026-03-09 13:35:00 +00:00
|
|
|
|
// 🔑 Index / Key Diff
|
|
|
|
|
|
const origIdxById = new Map<string, TableIndex>()
|
|
|
|
|
|
originalIndexes.forEach((ix) => origIdxById.set(ix.id, ix))
|
|
|
|
|
|
const curIdxById = new Map<string, TableIndex>()
|
|
|
|
|
|
indexes.forEach((ix) => curIdxById.set(ix.id, ix))
|
|
|
|
|
|
|
|
|
|
|
|
const dropIndexSql = (ix: TableIndex) => {
|
|
|
|
|
|
if (ix.indexType === 'PrimaryKey' || ix.indexType === 'UniqueKey') {
|
2026-08-19 07:50:33 +00:00
|
|
|
|
lines.push(`-- ❌ ${translate('::App.TableDesignerDiff.DropIndex', { name: ix.indexName })}`)
|
2026-03-09 13:35:00 +00:00
|
|
|
|
lines.push(`ALTER TABLE ${fullTableName}`)
|
|
|
|
|
|
lines.push(` DROP CONSTRAINT [${ix.indexName}];`)
|
|
|
|
|
|
} else {
|
2026-08-19 07:50:33 +00:00
|
|
|
|
lines.push(`-- ❌ ${translate('::App.TableDesignerDiff.DropIndex', { name: ix.indexName })}`)
|
2026-03-09 13:35:00 +00:00
|
|
|
|
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(
|
2026-06-25 22:55:44 +00:00
|
|
|
|
...buildCreateIndexIfNotExistsSql(fullTableName, ix.indexName, ix.isClustered, colsSql),
|
2026-03-09 13:35:00 +00:00
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
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
|
2026-08-14 11:32:31 +00:00
|
|
|
|
lines.push(
|
2026-08-14 14:03:34 +00:00
|
|
|
|
`-- ✏️ ${translate('::App.TableDesignerDiff.UpdateIndex', { name: orig.indexName })}`,
|
2026-08-14 11:32:31 +00:00
|
|
|
|
)
|
2026-03-09 13:35:00 +00:00
|
|
|
|
dropIndexSql(orig)
|
|
|
|
|
|
addIndexSql(cur)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
})
|
|
|
|
|
|
|
2026-03-01 17:40:25 +00:00
|
|
|
|
if (!hasChanges) {
|
2026-08-14 14:03:34 +00:00
|
|
|
|
lines.push(`/* ℹ️ ${translate('::App.TableDesignerDiff.NoChanges')} */`)
|
2026-03-01 17:40:25 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return lines.join('\n')
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-17 14:26:51 +00:00
|
|
|
|
/**
|
|
|
|
|
|
* Adim sirasi. CRUD adimi yalnizca `App.SqlQueryManager.CrudEndpoints` izni olan
|
|
|
|
|
|
* kullanicilarda gosterildigi icin index'ler sabit degil; anahtarla eslestirilir.
|
|
|
|
|
|
*/
|
|
|
|
|
|
type StepKey = 'columns' | 'settings' | 'indexes' | 'relationships' | 'crud' | 'sql'
|
2026-03-01 17:40:25 +00:00
|
|
|
|
|
2026-05-13 11:31:15 +00:00
|
|
|
|
function StepContentWrapper({ children }: { children: React.ReactNode }) {
|
2026-06-25 22:55:44 +00:00
|
|
|
|
return <div className="min-h-[420px] flex flex-col">{children}</div>
|
2026-05-13 11:31:15 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-03 07:34:25 +00:00
|
|
|
|
// ─── Simple Menu Tree (read-only selection) ───────────────────────────────────
|
|
|
|
|
|
|
|
|
|
|
|
interface SimpleMenuTreeNodeProps {
|
|
|
|
|
|
node: MenuTreeNode & { shortName?: string }
|
|
|
|
|
|
depth: number
|
|
|
|
|
|
selectedCode: string
|
|
|
|
|
|
onSelect: (code: string) => void
|
|
|
|
|
|
expanded: Set<string>
|
|
|
|
|
|
onToggle: (code: string) => void
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function SimpleMenuTreeNode({
|
|
|
|
|
|
node,
|
|
|
|
|
|
depth,
|
|
|
|
|
|
selectedCode,
|
|
|
|
|
|
onSelect,
|
|
|
|
|
|
expanded,
|
|
|
|
|
|
onToggle,
|
|
|
|
|
|
}: SimpleMenuTreeNodeProps) {
|
|
|
|
|
|
const hasChildren = node.children.length > 0
|
|
|
|
|
|
const isExpanded = expanded.has(node.code)
|
|
|
|
|
|
const isSelected = node.code === selectedCode
|
2026-08-16 15:30:07 +00:00
|
|
|
|
const navigationIcon = useNavigationIcons()
|
2026-03-03 07:34:25 +00:00
|
|
|
|
const NodeIcon = node.icon ? navigationIcon[node.icon] : null
|
|
|
|
|
|
const { translate } = useLocalization()
|
|
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
|
<div>
|
|
|
|
|
|
<div
|
|
|
|
|
|
className={`flex items-center gap-1 px-2 py-1.5 rounded mx-1 cursor-pointer ${
|
|
|
|
|
|
isSelected
|
|
|
|
|
|
? 'bg-indigo-500 text-white'
|
|
|
|
|
|
: 'hover:bg-gray-100 dark:hover:bg-gray-700 text-gray-700 dark:text-gray-200'
|
|
|
|
|
|
}`}
|
|
|
|
|
|
style={{ paddingLeft: `${8 + depth * 16}px` }}
|
|
|
|
|
|
onClick={() => onSelect(node.code)}
|
|
|
|
|
|
>
|
|
|
|
|
|
<span>
|
|
|
|
|
|
{hasChildren ? (
|
|
|
|
|
|
isExpanded ? (
|
|
|
|
|
|
<FaChevronDown className="text-gray-400" />
|
|
|
|
|
|
) : (
|
|
|
|
|
|
<FaChevronRight className="text-gray-400" />
|
|
|
|
|
|
)
|
|
|
|
|
|
) : null}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
{NodeIcon && (
|
|
|
|
|
|
<NodeIcon
|
|
|
|
|
|
className={`text-base shrink-0 ${isSelected ? 'text-indigo-200' : 'text-gray-400'}`}
|
|
|
|
|
|
/>
|
|
|
|
|
|
)}
|
|
|
|
|
|
<span className="flex-1 text-sm truncate">{translate('::' + node.code)}</span>
|
|
|
|
|
|
{node.shortName && (
|
|
|
|
|
|
<span
|
|
|
|
|
|
className={`text-xs shrink-0 font-mono px-1.5 py-0.5 rounded ${
|
|
|
|
|
|
isSelected
|
|
|
|
|
|
? 'bg-indigo-400 text-indigo-100'
|
|
|
|
|
|
: 'bg-gray-100 dark:bg-gray-700 text-gray-500 dark:text-gray-400'
|
|
|
|
|
|
}`}
|
|
|
|
|
|
>
|
|
|
|
|
|
{node.shortName}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
{isExpanded &&
|
|
|
|
|
|
node.children.map((child: MenuTreeNode) => (
|
|
|
|
|
|
<SimpleMenuTreeNode
|
|
|
|
|
|
key={child.code}
|
|
|
|
|
|
node={child as MenuTreeNode & { shortName?: string }}
|
|
|
|
|
|
depth={depth + 1}
|
|
|
|
|
|
selectedCode={selectedCode}
|
|
|
|
|
|
onSelect={onSelect}
|
|
|
|
|
|
expanded={expanded}
|
|
|
|
|
|
onToggle={onToggle}
|
|
|
|
|
|
/>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
interface SimpleMenuTreeSelectProps {
|
|
|
|
|
|
selectedCode: string
|
|
|
|
|
|
onSelect: (code: string) => void
|
|
|
|
|
|
nodes: MenuTreeNode[]
|
|
|
|
|
|
isLoading: boolean
|
|
|
|
|
|
invalid?: boolean
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function SimpleMenuTreeSelect({
|
|
|
|
|
|
selectedCode,
|
|
|
|
|
|
onSelect,
|
|
|
|
|
|
nodes,
|
|
|
|
|
|
isLoading,
|
|
|
|
|
|
invalid,
|
|
|
|
|
|
}: SimpleMenuTreeSelectProps) {
|
|
|
|
|
|
const [expanded, setExpanded] = useState<Set<string>>(new Set())
|
|
|
|
|
|
|
|
|
|
|
|
const toggle = (code: string) =>
|
|
|
|
|
|
setExpanded((prev) => {
|
|
|
|
|
|
const n = new Set(prev)
|
2026-07-06 09:44:26 +00:00
|
|
|
|
if (n.has(code)) {
|
|
|
|
|
|
n.delete(code)
|
|
|
|
|
|
} else {
|
|
|
|
|
|
n.add(code)
|
|
|
|
|
|
}
|
2026-03-03 07:34:25 +00:00
|
|
|
|
return n
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
|
<div
|
|
|
|
|
|
className={`rounded-lg border ${
|
2026-05-19 20:22:25 +00:00
|
|
|
|
invalid ? 'border-red-500 dark:border-red-700' : 'border-gray-300 dark:border-gray-600'
|
2026-03-03 07:34:25 +00:00
|
|
|
|
} bg-white dark:bg-gray-800 overflow-hidden`}
|
|
|
|
|
|
>
|
|
|
|
|
|
<div className="h-56 overflow-y-auto py-1">
|
|
|
|
|
|
{isLoading ? (
|
|
|
|
|
|
<div className="px-4 py-3 text-sm text-gray-400">Loading…</div>
|
|
|
|
|
|
) : nodes.length === 0 ? (
|
|
|
|
|
|
<div className="px-4 py-3 text-sm text-gray-400">No menus available</div>
|
|
|
|
|
|
) : (
|
|
|
|
|
|
nodes.map((node) => (
|
|
|
|
|
|
<SimpleMenuTreeNode
|
|
|
|
|
|
key={node.code}
|
|
|
|
|
|
node={node as MenuTreeNode & { shortName?: string }}
|
|
|
|
|
|
depth={0}
|
|
|
|
|
|
selectedCode={selectedCode}
|
|
|
|
|
|
onSelect={onSelect}
|
|
|
|
|
|
expanded={expanded}
|
|
|
|
|
|
onToggle={toggle}
|
|
|
|
|
|
/>
|
|
|
|
|
|
))
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-01 17:40:25 +00:00
|
|
|
|
const createEmptyColumn = (): ColumnDefinition => ({
|
|
|
|
|
|
id: crypto.randomUUID(),
|
|
|
|
|
|
columnName: '',
|
|
|
|
|
|
dataType: 'nvarchar',
|
|
|
|
|
|
maxLength: '100',
|
|
|
|
|
|
isNullable: true,
|
|
|
|
|
|
defaultValue: '',
|
|
|
|
|
|
description: '',
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
const DEFAULT_SETTINGS: TableSettings = {
|
|
|
|
|
|
menuValue: '',
|
|
|
|
|
|
menuPrefix: '',
|
|
|
|
|
|
entityName: '',
|
|
|
|
|
|
tableName: '',
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ─── Component ────────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
2026-03-01 20:43:25 +00:00
|
|
|
|
const SqlTableDesignerDialog = ({
|
2026-03-01 17:40:25 +00:00
|
|
|
|
isOpen,
|
|
|
|
|
|
onClose,
|
|
|
|
|
|
dataSource,
|
|
|
|
|
|
onDeployed,
|
|
|
|
|
|
initialTableData,
|
|
|
|
|
|
}: TableDesignerDialogProps) => {
|
|
|
|
|
|
const { translate } = useLocalization()
|
2026-07-09 17:55:07 +00:00
|
|
|
|
const lastCreateTableScript = useStoreState(
|
|
|
|
|
|
(state) => state.client.developerKit.lastCreateTableScript,
|
|
|
|
|
|
)
|
|
|
|
|
|
const setLastCreateTableScript = useStoreActions(
|
|
|
|
|
|
(actions) => actions.client.developerKit.setLastCreateTableScript,
|
|
|
|
|
|
)
|
2026-03-01 17:40:25 +00:00
|
|
|
|
|
|
|
|
|
|
const isEditMode = !!initialTableData
|
|
|
|
|
|
|
2026-08-17 14:26:51 +00:00
|
|
|
|
const { checkPermission } = usePermission()
|
|
|
|
|
|
const canManageCrudEndpoints = checkPermission(CRUD_ENDPOINT_PERMISSION)
|
|
|
|
|
|
|
|
|
|
|
|
/** Izin yoksa CRUD adimi hic gosterilmez. */
|
|
|
|
|
|
const stepKeys = useMemo<StepKey[]>(
|
|
|
|
|
|
() => [
|
|
|
|
|
|
'columns',
|
|
|
|
|
|
'settings',
|
|
|
|
|
|
'indexes',
|
|
|
|
|
|
'relationships',
|
|
|
|
|
|
...(canManageCrudEndpoints ? (['crud'] as StepKey[]) : []),
|
|
|
|
|
|
'sql',
|
|
|
|
|
|
],
|
|
|
|
|
|
[canManageCrudEndpoints],
|
|
|
|
|
|
)
|
|
|
|
|
|
const lastStep = stepKeys.length - 1
|
|
|
|
|
|
|
|
|
|
|
|
const [step, setStep] = useState(0)
|
|
|
|
|
|
const currentStepKey = stepKeys[step]
|
2026-03-01 17:40:25 +00:00
|
|
|
|
const [isDeploying, setIsDeploying] = useState(false)
|
2026-08-17 14:26:51 +00:00
|
|
|
|
/** Deploy sonrasi CRUD endpoint uretilsin mi; varsayilan kapali, kullanici acar. */
|
|
|
|
|
|
const [generateCrud, setGenerateCrud] = useState(false)
|
|
|
|
|
|
const [crudOperations, setCrudOperations] = useState<CrudOperationType[]>([
|
|
|
|
|
|
...CRUD_OPERATION_TYPES,
|
|
|
|
|
|
])
|
2026-03-01 17:40:25 +00:00
|
|
|
|
const [columns, setColumns] = useState<ColumnDefinition[]>([createEmptyColumn()])
|
|
|
|
|
|
const [originalColumns, setOriginalColumns] = useState<ColumnDefinition[]>([])
|
|
|
|
|
|
const [colsLoading, setColsLoading] = useState(false)
|
2026-08-19 07:50:33 +00:00
|
|
|
|
/** Hazir kolon sablonlari panelinin acik/kapali durumu. */
|
|
|
|
|
|
const [templatesOpen, setTemplatesOpen] = useState(false)
|
|
|
|
|
|
const templatesRef = useRef<HTMLDivElement | null>(null)
|
2026-03-01 17:40:25 +00:00
|
|
|
|
const [settings, setSettings] = useState<TableSettings>(DEFAULT_SETTINGS)
|
2026-03-03 07:34:25 +00:00
|
|
|
|
const [rawMenuItems, setRawMenuItems] = useState<MenuItem[]>([])
|
|
|
|
|
|
const [menuTree, setMenuTree] = useState<MenuTreeNode[]>([])
|
|
|
|
|
|
const [selectedMenuCode, setSelectedMenuCode] = useState('')
|
|
|
|
|
|
const [menuAddDialogOpen, setMenuAddDialogOpen] = useState(false)
|
2026-03-01 17:40:25 +00:00
|
|
|
|
const [menuLoading, setMenuLoading] = useState(false)
|
2026-03-02 21:34:19 +00:00
|
|
|
|
const [relationships, setRelationships] = useState<SqlTableRelation[]>([])
|
|
|
|
|
|
const [originalRelationships, setOriginalRelationships] = useState<SqlTableRelation[]>([])
|
2026-03-01 17:40:25 +00:00
|
|
|
|
const [fksLoading, setFksLoading] = useState(false)
|
|
|
|
|
|
const [fkModalOpen, setFkModalOpen] = useState(false)
|
|
|
|
|
|
const [editingFkId, setEditingFkId] = useState<string | null>(null)
|
2026-03-02 21:34:19 +00:00
|
|
|
|
const [fkForm, setFkForm] = useState<Omit<SqlTableRelation, 'id'>>(EMPTY_FK)
|
2026-03-01 17:40:25 +00:00
|
|
|
|
const [dbTables, setDbTables] = useState<{ schemaName: string; tableName: string }[]>([])
|
2026-03-03 07:34:25 +00:00
|
|
|
|
const [targetTableColumns, setTargetTableColumns] = useState<string[]>([])
|
2026-03-18 09:00:11 +00:00
|
|
|
|
const [targetTableKeyColumns, setTargetTableKeyColumns] = useState<string[]>([])
|
2026-08-20 14:54:11 +00:00
|
|
|
|
|
|
|
|
|
|
const dataTypeOptions = DATA_TYPES
|
|
|
|
|
|
const cascadeOptions = CASCADE_OPTIONS
|
|
|
|
|
|
const indexOrderOptions: { value: 'ASC' | 'DESC'; label: string }[] = [
|
|
|
|
|
|
{ value: 'ASC', label: 'ASC' },
|
|
|
|
|
|
{ value: 'DESC', label: 'DESC' },
|
|
|
|
|
|
]
|
|
|
|
|
|
const fkColumnOptions = columns
|
|
|
|
|
|
.filter((c) => c.columnName.trim())
|
|
|
|
|
|
.map((c) => ({ value: c.columnName, label: c.columnName }))
|
|
|
|
|
|
const referencedTableOptions = dbTables.map((t) => ({
|
|
|
|
|
|
value: t.tableName,
|
|
|
|
|
|
label: t.tableName,
|
|
|
|
|
|
}))
|
|
|
|
|
|
const referencedColumnOptions = targetTableColumns.map((col) => {
|
|
|
|
|
|
const isKey = targetTableKeyColumns.some((k) => k.toLowerCase() === col.toLowerCase())
|
|
|
|
|
|
return {
|
|
|
|
|
|
value: col,
|
|
|
|
|
|
label: isKey ? `${col} (PK/UNIQUE)` : `${col} (Not Key)`,
|
|
|
|
|
|
isDisabled: !isKey,
|
|
|
|
|
|
}
|
|
|
|
|
|
})
|
2026-03-01 17:40:25 +00:00
|
|
|
|
const [targetColsLoading, setTargetColsLoading] = useState(false)
|
2026-03-09 13:35:00 +00:00
|
|
|
|
const [indexes, setIndexes] = useState<TableIndex[]>([])
|
|
|
|
|
|
const [originalIndexes, setOriginalIndexes] = useState<TableIndex[]>([])
|
|
|
|
|
|
const [indexesLoading, setIndexesLoading] = useState(false)
|
|
|
|
|
|
const [indexModalOpen, setIndexModalOpen] = useState(false)
|
|
|
|
|
|
const [editingIndexId, setEditingIndexId] = useState<string | null>(null)
|
|
|
|
|
|
const [indexForm, setIndexForm] = useState<Omit<TableIndex, 'id'>>(EMPTY_INDEX)
|
2026-03-01 17:40:25 +00:00
|
|
|
|
|
2026-03-03 07:34:25 +00:00
|
|
|
|
const reloadMenus = (onLoaded?: (items: MenuItem[]) => void) => {
|
2026-03-01 17:40:25 +00:00
|
|
|
|
setMenuLoading(true)
|
2026-03-03 07:34:25 +00:00
|
|
|
|
getMenus(0, 1000)
|
2026-03-01 17:40:25 +00:00
|
|
|
|
.then((res) => {
|
2026-03-03 07:34:25 +00:00
|
|
|
|
const items = (res.data?.items ?? []) as MenuItem[]
|
|
|
|
|
|
const filtered = items.filter((m) => !!m.shortName?.trim())
|
|
|
|
|
|
setRawMenuItems(filtered)
|
|
|
|
|
|
const tree = filterNonLinkNodes(buildMenuTree(filtered))
|
|
|
|
|
|
setMenuTree(tree)
|
|
|
|
|
|
onLoaded?.(filtered)
|
2026-03-01 17:40:25 +00:00
|
|
|
|
})
|
|
|
|
|
|
.catch(() => {})
|
|
|
|
|
|
.finally(() => setMenuLoading(false))
|
2026-03-03 07:34:25 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
if (!isOpen) return
|
|
|
|
|
|
reloadMenus((items) => {
|
|
|
|
|
|
// In edit mode, auto-select the matching menu code by shortName
|
|
|
|
|
|
if (initialTableData) {
|
|
|
|
|
|
const parts = initialTableData.tableName.split('_')
|
|
|
|
|
|
const derivedShortName = parts[0] ?? ''
|
|
|
|
|
|
const match = items.find((m) => m.shortName === derivedShortName)
|
|
|
|
|
|
if (match?.code) setSelectedMenuCode(match.code)
|
|
|
|
|
|
}
|
|
|
|
|
|
})
|
2026-03-01 17:40:25 +00:00
|
|
|
|
|
|
|
|
|
|
if (dataSource) {
|
|
|
|
|
|
sqlObjectManagerService
|
|
|
|
|
|
.getAllObjects(dataSource)
|
|
|
|
|
|
.then((res) => setDbTables(res.data?.tables ?? []))
|
|
|
|
|
|
.catch(() => {})
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Edit mode: load table's existing columns + FK constraints
|
|
|
|
|
|
if (initialTableData && dataSource) {
|
|
|
|
|
|
setColsLoading(true)
|
|
|
|
|
|
sqlObjectManagerService
|
|
|
|
|
|
.getTableColumns(dataSource, initialTableData.schemaName, initialTableData.tableName)
|
|
|
|
|
|
.then((res) => {
|
|
|
|
|
|
const defs = (res.data ?? []).map(dbColToColumnDef)
|
|
|
|
|
|
setColumns(defs.length > 0 ? defs : [createEmptyColumn()])
|
|
|
|
|
|
setOriginalColumns(defs)
|
|
|
|
|
|
setSettings((s) => ({ ...s, tableName: initialTableData.tableName }))
|
|
|
|
|
|
})
|
|
|
|
|
|
.catch(() => {})
|
|
|
|
|
|
.finally(() => setColsLoading(false))
|
|
|
|
|
|
|
2026-03-02 21:34:19 +00:00
|
|
|
|
// Derive settings from table name (e.g. "Sas_D_EntityName" → menu: "Sas", entity: "EntityName")
|
|
|
|
|
|
const parts = initialTableData.tableName.split('_')
|
|
|
|
|
|
const derivedMenu = parts[0] ?? ''
|
|
|
|
|
|
const derivedEntity = parts[parts.length - 1] ?? initialTableData.tableName
|
|
|
|
|
|
setSettings((s) => ({
|
|
|
|
|
|
...s,
|
|
|
|
|
|
menuValue: derivedMenu,
|
|
|
|
|
|
menuPrefix: derivedMenu,
|
|
|
|
|
|
entityName: derivedEntity,
|
|
|
|
|
|
displayName: derivedEntity,
|
|
|
|
|
|
}))
|
2026-03-01 17:40:25 +00:00
|
|
|
|
|
|
|
|
|
|
// Load existing FK constraints
|
|
|
|
|
|
const fkQuery = [
|
|
|
|
|
|
'SELECT',
|
|
|
|
|
|
' fk.name AS constraintName,',
|
|
|
|
|
|
' col.name AS fkColumnName,',
|
|
|
|
|
|
' ref_t.name AS referencedTable,',
|
|
|
|
|
|
' ref_c.name AS referencedColumn,',
|
|
|
|
|
|
' fk.delete_referential_action_desc AS cascadeDelete,',
|
|
|
|
|
|
' fk.update_referential_action_desc AS cascadeUpdate',
|
|
|
|
|
|
'FROM sys.foreign_keys fk',
|
|
|
|
|
|
'INNER JOIN sys.foreign_key_columns fkc ON fk.object_id = fkc.constraint_object_id',
|
|
|
|
|
|
'INNER JOIN sys.columns col ON fkc.parent_object_id = col.object_id AND fkc.parent_column_id = col.column_id',
|
|
|
|
|
|
'INNER JOIN sys.tables ref_t ON fkc.referenced_object_id = ref_t.object_id',
|
|
|
|
|
|
'INNER JOIN sys.columns ref_c ON fkc.referenced_object_id = ref_c.object_id AND fkc.referenced_column_id = ref_c.column_id',
|
|
|
|
|
|
`WHERE fk.parent_object_id = OBJECT_ID('${initialTableData.schemaName}.${initialTableData.tableName}')`,
|
|
|
|
|
|
].join('\n')
|
|
|
|
|
|
|
|
|
|
|
|
const mapCascade = (v: string): CascadeBehavior => {
|
|
|
|
|
|
if (v === 'CASCADE') return 'Cascade'
|
|
|
|
|
|
if (v === 'SET_NULL') return 'SetNull'
|
|
|
|
|
|
if (v === 'SET_DEFAULT') return 'Restrict'
|
|
|
|
|
|
return 'NoAction'
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
setFksLoading(true)
|
|
|
|
|
|
sqlObjectManagerService
|
|
|
|
|
|
.executeQuery({ queryText: fkQuery, dataSourceCode: dataSource })
|
|
|
|
|
|
.then((res) => {
|
|
|
|
|
|
const rows: any[] = res.data?.data ?? []
|
2026-03-02 21:34:19 +00:00
|
|
|
|
const fkDefs: SqlTableRelation[] = rows.map((r) => ({
|
2026-03-01 17:40:25 +00:00
|
|
|
|
id: crypto.randomUUID(),
|
|
|
|
|
|
constraintName: r.constraintName,
|
|
|
|
|
|
relationshipType: 'OneToMany' as RelationshipType,
|
|
|
|
|
|
fkColumnName: r.fkColumnName,
|
|
|
|
|
|
referencedTable: r.referencedTable,
|
|
|
|
|
|
referencedColumn: r.referencedColumn,
|
|
|
|
|
|
cascadeDelete: mapCascade(r.cascadeDelete),
|
|
|
|
|
|
cascadeUpdate: mapCascade(r.cascadeUpdate),
|
|
|
|
|
|
isRequired: false,
|
|
|
|
|
|
description: '',
|
|
|
|
|
|
}))
|
|
|
|
|
|
setRelationships(fkDefs)
|
|
|
|
|
|
setOriginalRelationships(fkDefs)
|
|
|
|
|
|
})
|
|
|
|
|
|
.catch(() => {})
|
|
|
|
|
|
.finally(() => setFksLoading(false))
|
2026-03-09 13:35:00 +00:00
|
|
|
|
|
|
|
|
|
|
// Load existing indexes / keys
|
|
|
|
|
|
const idxQuery = [
|
|
|
|
|
|
'SELECT',
|
|
|
|
|
|
' i.name AS indexName,',
|
|
|
|
|
|
' CASE',
|
|
|
|
|
|
" WHEN i.is_primary_key = 1 THEN 'PrimaryKey'",
|
|
|
|
|
|
" WHEN i.is_unique_constraint = 1 THEN 'UniqueKey'",
|
|
|
|
|
|
" ELSE 'Index'",
|
|
|
|
|
|
' END AS indexType,',
|
|
|
|
|
|
' CAST(CASE WHEN i.type = 1 THEN 1 ELSE 0 END AS BIT) AS isClustered,',
|
|
|
|
|
|
' col.name AS columnName,',
|
|
|
|
|
|
' ic.is_descending_key AS isDescending,',
|
|
|
|
|
|
' ic.key_ordinal AS keyOrdinal',
|
|
|
|
|
|
'FROM sys.indexes i',
|
|
|
|
|
|
'INNER JOIN sys.index_columns ic ON i.object_id = ic.object_id AND i.index_id = ic.index_id',
|
|
|
|
|
|
'INNER JOIN sys.columns col ON ic.object_id = col.object_id AND ic.column_id = col.column_id',
|
|
|
|
|
|
`WHERE i.object_id = OBJECT_ID('${initialTableData.schemaName}.${initialTableData.tableName}')`,
|
|
|
|
|
|
' AND ic.is_included_column = 0',
|
|
|
|
|
|
'ORDER BY i.name, ic.key_ordinal',
|
|
|
|
|
|
].join('\n')
|
|
|
|
|
|
|
|
|
|
|
|
setIndexesLoading(true)
|
|
|
|
|
|
sqlObjectManagerService
|
|
|
|
|
|
.executeQuery({ queryText: idxQuery, dataSourceCode: dataSource })
|
|
|
|
|
|
.then((res) => {
|
|
|
|
|
|
const rows: any[] = res.data?.data ?? []
|
|
|
|
|
|
const idxMap = new Map<string, TableIndex>()
|
|
|
|
|
|
rows.forEach((row) => {
|
|
|
|
|
|
if (!idxMap.has(row.indexName)) {
|
|
|
|
|
|
idxMap.set(row.indexName, {
|
|
|
|
|
|
id: crypto.randomUUID(),
|
|
|
|
|
|
indexName: row.indexName,
|
|
|
|
|
|
indexType: row.indexType as IndexType,
|
|
|
|
|
|
isClustered: row.isClustered === true || row.isClustered === 1,
|
|
|
|
|
|
columns: [],
|
|
|
|
|
|
description: '',
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
idxMap.get(row.indexName)!.columns.push({
|
|
|
|
|
|
columnName: row.columnName,
|
|
|
|
|
|
order: row.isDescending ? 'DESC' : 'ASC',
|
|
|
|
|
|
})
|
|
|
|
|
|
})
|
|
|
|
|
|
const idxDefs = Array.from(idxMap.values())
|
|
|
|
|
|
setIndexes(idxDefs)
|
|
|
|
|
|
setOriginalIndexes(idxDefs)
|
|
|
|
|
|
})
|
|
|
|
|
|
.catch(() => {})
|
|
|
|
|
|
.finally(() => setIndexesLoading(false))
|
2026-03-01 17:40:25 +00:00
|
|
|
|
}
|
|
|
|
|
|
}, [isOpen, dataSource, initialTableData])
|
|
|
|
|
|
|
|
|
|
|
|
const generatedSql = useMemo(
|
|
|
|
|
|
() =>
|
|
|
|
|
|
isEditMode
|
|
|
|
|
|
? generateAlterTableSql(
|
|
|
|
|
|
originalColumns,
|
|
|
|
|
|
columns,
|
|
|
|
|
|
settings.tableName || initialTableData?.tableName || '',
|
|
|
|
|
|
relationships,
|
|
|
|
|
|
originalRelationships,
|
2026-03-09 13:35:00 +00:00
|
|
|
|
indexes,
|
|
|
|
|
|
originalIndexes,
|
2026-08-14 11:32:31 +00:00
|
|
|
|
translate,
|
2026-03-01 17:40:25 +00:00
|
|
|
|
)
|
2026-03-09 13:35:00 +00:00
|
|
|
|
: generateCreateTableSql(columns, settings, relationships, indexes),
|
2026-03-03 07:34:25 +00:00
|
|
|
|
[
|
2026-08-14 11:32:31 +00:00
|
|
|
|
translate,
|
2026-03-03 07:34:25 +00:00
|
|
|
|
isEditMode,
|
|
|
|
|
|
originalColumns,
|
|
|
|
|
|
columns,
|
|
|
|
|
|
settings,
|
|
|
|
|
|
initialTableData,
|
|
|
|
|
|
relationships,
|
|
|
|
|
|
originalRelationships,
|
2026-03-09 13:35:00 +00:00
|
|
|
|
indexes,
|
|
|
|
|
|
originalIndexes,
|
2026-03-03 07:34:25 +00:00
|
|
|
|
],
|
2026-03-01 17:40:25 +00:00
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
// ── Column operations ──────────────────────────────────────────────────────
|
|
|
|
|
|
|
2026-05-03 19:50:51 +00:00
|
|
|
|
const [newRowId, setNewRowId] = useState<string | null>(null)
|
|
|
|
|
|
|
|
|
|
|
|
const addColumn = () => {
|
|
|
|
|
|
const col = createEmptyColumn()
|
|
|
|
|
|
setColumns((prev) => [...prev, col])
|
|
|
|
|
|
setNewRowId(col.id)
|
|
|
|
|
|
}
|
2026-03-01 17:40:25 +00:00
|
|
|
|
|
2026-03-02 21:34:19 +00:00
|
|
|
|
const clearAllColumns = () => setColumns([createEmptyColumn()])
|
|
|
|
|
|
|
2026-07-17 20:23:43 +00:00
|
|
|
|
const addAutomaticPrimaryKey = (preset: string, description: string) => {
|
|
|
|
|
|
const tableName = settings.tableName || initialTableData?.tableName || 'Table'
|
2026-03-16 09:52:15 +00:00
|
|
|
|
|
|
|
|
|
|
setIndexes((prev) => {
|
2026-07-17 20:23:43 +00:00
|
|
|
|
if (prev.some((index) => index.indexType === 'PrimaryKey')) return prev
|
2026-03-16 09:52:15 +00:00
|
|
|
|
|
|
|
|
|
|
return [
|
2026-03-09 13:35:00 +00:00
|
|
|
|
...prev,
|
|
|
|
|
|
{
|
2026-07-17 20:23:43 +00:00
|
|
|
|
id: `auto-${preset}-${crypto.randomUUID()}`,
|
2026-03-16 09:52:15 +00:00
|
|
|
|
indexName: `PK_${tableName}`,
|
2026-03-09 13:35:00 +00:00
|
|
|
|
indexType: 'PrimaryKey',
|
|
|
|
|
|
isClustered: false,
|
|
|
|
|
|
columns: [{ columnName: 'Id', order: 'ASC' }],
|
2026-07-17 20:23:43 +00:00
|
|
|
|
description,
|
2026-03-09 13:35:00 +00:00
|
|
|
|
},
|
2026-03-16 09:52:15 +00:00
|
|
|
|
]
|
|
|
|
|
|
})
|
2026-03-02 21:34:19 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-17 20:23:43 +00:00
|
|
|
|
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()]
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
addAutomaticPrimaryKey('full-audit', 'Full audited primary key (auto)')
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-02 21:34:19 +00:00
|
|
|
|
const addMultiTenantColumns = () => {
|
|
|
|
|
|
const existingNames = new Set(columns.map((c) => c.columnName.trim().toLowerCase()))
|
|
|
|
|
|
if (!existingNames.has(TENANT_COLUMN.columnName.toLowerCase())) {
|
|
|
|
|
|
setColumns((prev) => {
|
|
|
|
|
|
const nonEmpty = prev.filter((c) => c.columnName.trim() !== '')
|
|
|
|
|
|
return [...nonEmpty, { ...TENANT_COLUMN }, createEmptyColumn()]
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-06 18:31:03 +00:00
|
|
|
|
const addWorkflowColumns = () => {
|
|
|
|
|
|
const existingNames = new Set(columns.map((c) => c.columnName.trim().toLowerCase()))
|
|
|
|
|
|
const toAdd = WORKFLOW_COLUMNS.filter((c) => !existingNames.has(c.columnName.toLowerCase()))
|
|
|
|
|
|
|
|
|
|
|
|
if (toAdd.length > 0) {
|
|
|
|
|
|
setColumns((prev) => {
|
|
|
|
|
|
const nonEmpty = prev.filter((c) => c.columnName.trim() !== '')
|
|
|
|
|
|
return [...nonEmpty, ...toAdd.map((c) => ({ ...c })), createEmptyColumn()]
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
2026-07-17 20:23:43 +00:00
|
|
|
|
|
|
|
|
|
|
addAutomaticPrimaryKey('workflow', 'Workflow primary key (auto)')
|
2026-06-06 18:31:03 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-16 14:01:18 +00:00
|
|
|
|
const addTodoColumns = () => {
|
|
|
|
|
|
const existingNames = new Set(columns.map((c) => c.columnName.trim().toLowerCase()))
|
|
|
|
|
|
const toAdd = TODO_COLUMNS.filter((c) => !existingNames.has(c.columnName.toLowerCase()))
|
|
|
|
|
|
|
|
|
|
|
|
if (toAdd.length > 0) {
|
|
|
|
|
|
setColumns((prev) => {
|
|
|
|
|
|
const nonEmpty = prev.filter((c) => c.columnName.trim() !== '')
|
|
|
|
|
|
return [...nonEmpty, ...toAdd.map((c) => ({ ...c })), createEmptyColumn()]
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-17 20:23:43 +00:00
|
|
|
|
addAutomaticPrimaryKey('todo', 'Todo primary key (auto)')
|
2026-07-16 14:01:18 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-28 14:00:38 +00:00
|
|
|
|
const addGanttColumns = () => {
|
|
|
|
|
|
const existingNames = new Set(columns.map((c) => c.columnName.trim().toLowerCase()))
|
|
|
|
|
|
const toAdd = GANTT_COLUMNS.filter((c) => !existingNames.has(c.columnName.toLowerCase()))
|
|
|
|
|
|
|
|
|
|
|
|
if (toAdd.length > 0) {
|
|
|
|
|
|
setColumns((prev) => {
|
|
|
|
|
|
const nonEmpty = prev.filter((c) => c.columnName.trim() !== '')
|
|
|
|
|
|
return [...nonEmpty, ...toAdd.map((c) => ({ ...c })), createEmptyColumn()]
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
addAutomaticPrimaryKey('gantt', 'Gantt primary key (auto)')
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const addSchedulerColumns = () => {
|
|
|
|
|
|
const existingNames = new Set(columns.map((c) => c.columnName.trim().toLowerCase()))
|
|
|
|
|
|
const toAdd = SCHEDULER_COLUMNS.filter((c) => !existingNames.has(c.columnName.toLowerCase()))
|
|
|
|
|
|
|
|
|
|
|
|
if (toAdd.length > 0) {
|
|
|
|
|
|
setColumns((prev) => {
|
|
|
|
|
|
const nonEmpty = prev.filter((c) => c.columnName.trim() !== '')
|
|
|
|
|
|
return [...nonEmpty, ...toAdd.map((c) => ({ ...c })), createEmptyColumn()]
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
addAutomaticPrimaryKey('scheduler', 'Scheduler primary key (auto)')
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-21 14:42:36 +00:00
|
|
|
|
const importColumnsFromRememberedCreateTable = async () => {
|
|
|
|
|
|
let script = ''
|
|
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
|
const clipboardText = await navigator.clipboard.readText()
|
|
|
|
|
|
if (/\bCREATE\s+TABLE\b/i.test(clipboardText)) {
|
|
|
|
|
|
script = clipboardText
|
2026-07-09 17:55:07 +00:00
|
|
|
|
setLastCreateTableScript(clipboardText)
|
2026-03-21 14:42:36 +00:00
|
|
|
|
}
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
// Clipboard read can fail due to browser permissions; fallback to saved script.
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (!script) {
|
2026-07-09 17:55:07 +00:00
|
|
|
|
script = lastCreateTableScript
|
2026-03-21 14:42:36 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (!script.trim()) {
|
|
|
|
|
|
toast.push(
|
|
|
|
|
|
<Notification type="warning" title={translate('::App.Platform.Warning')}>
|
2026-08-14 14:03:34 +00:00
|
|
|
|
{translate('::App.DeveloperKitTableDesigner.CreateScriptNotFound')}
|
2026-03-21 14:42:36 +00:00
|
|
|
|
</Notification>,
|
2026-07-08 12:34:13 +00:00
|
|
|
|
{ placement: 'bottom-end' },
|
2026-03-21 14:42:36 +00:00
|
|
|
|
)
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const parsedColumns = parseCreateTableColumns(script)
|
|
|
|
|
|
if (parsedColumns.length === 0) {
|
|
|
|
|
|
toast.push(
|
|
|
|
|
|
<Notification type="warning" title={translate('::App.Platform.Warning')}>
|
2026-08-14 14:03:34 +00:00
|
|
|
|
{translate('::App.DeveloperKitTableDesigner.ColumnsNotParsed')}
|
2026-03-21 14:42:36 +00:00
|
|
|
|
</Notification>,
|
2026-07-08 12:34:13 +00:00
|
|
|
|
{ placement: 'bottom-end' },
|
2026-03-21 14:42:36 +00:00
|
|
|
|
)
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
let addedCount = 0
|
|
|
|
|
|
let skippedCount = 0
|
|
|
|
|
|
|
|
|
|
|
|
setColumns((prev) => {
|
|
|
|
|
|
const existingNonEmpty = prev.filter((c) => c.columnName.trim() !== '')
|
2026-06-25 22:55:44 +00:00
|
|
|
|
const existingNames = new Set(existingNonEmpty.map((c) => c.columnName.trim().toLowerCase()))
|
2026-03-21 14:42:36 +00:00
|
|
|
|
|
|
|
|
|
|
const toAdd: ColumnDefinition[] = []
|
|
|
|
|
|
for (const col of parsedColumns) {
|
|
|
|
|
|
const key = col.columnName.trim().toLowerCase()
|
|
|
|
|
|
if (!key) {
|
|
|
|
|
|
skippedCount++
|
|
|
|
|
|
continue
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (existingNames.has(key)) {
|
|
|
|
|
|
skippedCount++
|
|
|
|
|
|
continue
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
existingNames.add(key)
|
|
|
|
|
|
toAdd.push(col)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
addedCount = toAdd.length
|
|
|
|
|
|
return [...existingNonEmpty, ...toAdd, createEmptyColumn()]
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
toast.push(
|
|
|
|
|
|
<Notification type="success" title={translate('::App.Platform.Success')}>
|
|
|
|
|
|
{addedCount > 0
|
2026-08-14 11:32:31 +00:00
|
|
|
|
? skippedCount > 0
|
2026-08-14 14:03:34 +00:00
|
|
|
|
? translate('::App.DeveloperKitTableDesigner.ColumnsAddedWithSkipped', {
|
2026-08-14 11:32:31 +00:00
|
|
|
|
added: addedCount,
|
|
|
|
|
|
skipped: skippedCount,
|
|
|
|
|
|
})
|
2026-08-14 14:03:34 +00:00
|
|
|
|
: translate('::App.DeveloperKitTableDesigner.ColumnsAdded', { added: addedCount })
|
|
|
|
|
|
: translate('::App.DeveloperKitTableDesigner.NoColumnsAdded')}
|
2026-03-21 14:42:36 +00:00
|
|
|
|
</Notification>,
|
2026-07-08 12:34:13 +00:00
|
|
|
|
{ placement: 'bottom-end' },
|
2026-03-21 14:42:36 +00:00
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-01 17:40:25 +00:00
|
|
|
|
const removeColumn = (id: string) => setColumns((prev) => prev.filter((c) => c.id !== id))
|
|
|
|
|
|
|
|
|
|
|
|
const moveColumn = (id: string, direction: 'up' | 'down') => {
|
|
|
|
|
|
setColumns((prev) => {
|
|
|
|
|
|
const idx = prev.findIndex((c) => c.id === id)
|
|
|
|
|
|
if (idx < 0) return prev
|
|
|
|
|
|
const next = [...prev]
|
|
|
|
|
|
const swap = direction === 'up' ? idx - 1 : idx + 1
|
|
|
|
|
|
if (swap < 0 || swap >= next.length) return prev
|
|
|
|
|
|
;[next[idx], next[swap]] = [next[swap], next[idx]]
|
|
|
|
|
|
return next
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const updateColumn = useCallback(
|
|
|
|
|
|
<K extends keyof ColumnDefinition>(id: string, field: K, value: ColumnDefinition[K]) => {
|
|
|
|
|
|
setColumns((prev) => prev.map((c) => (c.id === id ? { ...c, [field]: value } : c)))
|
|
|
|
|
|
},
|
|
|
|
|
|
[],
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
// ── Settings helpers ───────────────────────────────────────────────────────
|
|
|
|
|
|
|
2026-03-16 09:52:15 +00:00
|
|
|
|
const hasTenantIdColumn = (cols: ColumnDefinition[]) =>
|
|
|
|
|
|
cols.some((c) => c.columnName.trim().toLowerCase() === 'tenantid')
|
|
|
|
|
|
|
2026-03-01 17:40:25 +00:00
|
|
|
|
const buildTableName = (prefix: string, entity: string) =>
|
2026-03-16 09:52:15 +00:00
|
|
|
|
prefix && entity ? `${prefix}_${hasTenantIdColumn(columns) ? 'T' : 'D'}_${entity}` : ''
|
2026-03-01 17:40:25 +00:00
|
|
|
|
|
2026-03-09 13:35:00 +00:00
|
|
|
|
const syncAutoPkName = (newTableName: string) => {
|
|
|
|
|
|
if (!newTableName) return
|
|
|
|
|
|
setIndexes((prev) =>
|
2026-07-17 20:23:43 +00:00
|
|
|
|
prev.map((ix) => {
|
|
|
|
|
|
if (!ix.id.startsWith('auto-') || ix.indexType !== 'PrimaryKey') return ix
|
|
|
|
|
|
return { ...ix, indexName: `PK_${newTableName}` }
|
|
|
|
|
|
}),
|
2026-03-09 13:35:00 +00:00
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-03 07:34:25 +00:00
|
|
|
|
const onMenuCodeSelect = (code: string) => {
|
|
|
|
|
|
if (isEditMode) return
|
|
|
|
|
|
const item = rawMenuItems.find((m) => m.code === code)
|
|
|
|
|
|
const prefix = item?.shortName ?? ''
|
|
|
|
|
|
setSelectedMenuCode(code)
|
2026-03-09 13:35:00 +00:00
|
|
|
|
const newTableName = buildTableName(prefix, settings.entityName)
|
2026-03-01 17:40:25 +00:00
|
|
|
|
setSettings((s) => ({
|
|
|
|
|
|
...s,
|
2026-03-03 07:34:25 +00:00
|
|
|
|
menuValue: prefix,
|
2026-03-01 17:40:25 +00:00
|
|
|
|
menuPrefix: prefix,
|
2026-03-09 13:35:00 +00:00
|
|
|
|
tableName: newTableName,
|
2026-03-01 17:40:25 +00:00
|
|
|
|
}))
|
2026-03-09 13:35:00 +00:00
|
|
|
|
syncAutoPkName(newTableName)
|
2026-03-01 17:40:25 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Strip spaces and special chars — only alphanumeric + underscore allowed
|
|
|
|
|
|
const onEntityNameChange = (value: string) => {
|
|
|
|
|
|
const sanitized = value.replace(/[^A-Za-z0-9_]/g, '')
|
2026-03-09 13:35:00 +00:00
|
|
|
|
const newTableName = buildTableName(settings.menuPrefix, sanitized)
|
2026-03-01 17:40:25 +00:00
|
|
|
|
setSettings((s) => ({
|
|
|
|
|
|
...s,
|
|
|
|
|
|
entityName: sanitized,
|
2026-03-09 13:35:00 +00:00
|
|
|
|
tableName: newTableName,
|
2026-03-01 17:40:25 +00:00
|
|
|
|
displayName: sanitized, // always mirrors entity name
|
|
|
|
|
|
}))
|
2026-03-09 13:35:00 +00:00
|
|
|
|
syncAutoPkName(newTableName)
|
2026-03-01 17:40:25 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-16 09:52:15 +00:00
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
if (isEditMode) return
|
|
|
|
|
|
if (!settings.menuPrefix || !settings.entityName) return
|
|
|
|
|
|
|
|
|
|
|
|
const recalculatedTableName = buildTableName(settings.menuPrefix, settings.entityName)
|
|
|
|
|
|
if (!recalculatedTableName || recalculatedTableName === settings.tableName) return
|
|
|
|
|
|
|
|
|
|
|
|
setSettings((s) => ({ ...s, tableName: recalculatedTableName }))
|
|
|
|
|
|
syncAutoPkName(recalculatedTableName)
|
2026-06-25 22:55:44 +00:00
|
|
|
|
}, [isEditMode, columns, settings.menuPrefix, settings.entityName, settings.tableName])
|
2026-03-16 09:52:15 +00:00
|
|
|
|
|
2026-03-01 17:40:25 +00:00
|
|
|
|
// ── FK Relationship handlers ───────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
|
|
const loadTargetColumns = (tableName: string) => {
|
|
|
|
|
|
if (!tableName || !dataSource) {
|
|
|
|
|
|
setTargetTableColumns([])
|
2026-03-18 09:00:11 +00:00
|
|
|
|
setTargetTableKeyColumns([])
|
2026-03-01 17:40:25 +00:00
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
const tbl = dbTables.find((t) => t.tableName === tableName)
|
|
|
|
|
|
if (!tbl) return
|
|
|
|
|
|
setTargetColsLoading(true)
|
2026-03-18 09:00:11 +00:00
|
|
|
|
const objectName = `[${tbl.schemaName}].[${tbl.tableName}]`
|
|
|
|
|
|
const keyColsQuery = [
|
|
|
|
|
|
'SELECT DISTINCT c.name AS columnName',
|
|
|
|
|
|
'FROM sys.indexes i',
|
|
|
|
|
|
'INNER JOIN sys.index_columns ic ON i.object_id = ic.object_id AND i.index_id = ic.index_id',
|
|
|
|
|
|
'INNER JOIN sys.columns c ON ic.object_id = c.object_id AND ic.column_id = c.column_id',
|
|
|
|
|
|
`WHERE i.object_id = OBJECT_ID('${objectName}')`,
|
|
|
|
|
|
' AND ic.is_included_column = 0',
|
|
|
|
|
|
' AND (i.is_primary_key = 1 OR i.is_unique = 1 OR i.is_unique_constraint = 1)',
|
|
|
|
|
|
'ORDER BY c.name',
|
|
|
|
|
|
].join('\n')
|
|
|
|
|
|
|
|
|
|
|
|
Promise.all([
|
|
|
|
|
|
sqlObjectManagerService.getTableColumns(dataSource, tbl.schemaName, tbl.tableName),
|
|
|
|
|
|
sqlObjectManagerService.executeQuery({ queryText: keyColsQuery, dataSourceCode: dataSource }),
|
|
|
|
|
|
])
|
|
|
|
|
|
.then(([colsRes, keyColsRes]) => {
|
|
|
|
|
|
const allCols = (colsRes.data ?? []).map((c) => c.columnName)
|
|
|
|
|
|
const keyCols = ((keyColsRes.data?.data ?? []) as any[]).map((r) => r.columnName)
|
|
|
|
|
|
setTargetTableColumns(allCols)
|
|
|
|
|
|
setTargetTableKeyColumns(keyCols)
|
|
|
|
|
|
})
|
|
|
|
|
|
.catch(() => {
|
|
|
|
|
|
setTargetTableColumns([])
|
|
|
|
|
|
setTargetTableKeyColumns([])
|
|
|
|
|
|
})
|
2026-03-01 17:40:25 +00:00
|
|
|
|
.finally(() => setTargetColsLoading(false))
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const openAddFk = () => {
|
|
|
|
|
|
setEditingFkId(null)
|
|
|
|
|
|
setFkForm(EMPTY_FK)
|
|
|
|
|
|
setTargetTableColumns([])
|
2026-03-18 09:00:11 +00:00
|
|
|
|
setTargetTableKeyColumns([])
|
2026-03-01 17:40:25 +00:00
|
|
|
|
setFkModalOpen(true)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-02 21:34:19 +00:00
|
|
|
|
const openEditFk = (rel: SqlTableRelation) => {
|
2026-03-01 17:40:25 +00:00
|
|
|
|
setEditingFkId(rel.id)
|
2026-07-06 09:44:26 +00:00
|
|
|
|
const { ...rest } = rel
|
2026-03-01 17:40:25 +00:00
|
|
|
|
setFkForm(rest)
|
|
|
|
|
|
loadTargetColumns(rest.referencedTable)
|
|
|
|
|
|
setFkModalOpen(true)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const saveFk = () => {
|
2026-06-25 22:55:44 +00:00
|
|
|
|
if (
|
|
|
|
|
|
!fkForm.fkColumnName.trim() ||
|
|
|
|
|
|
!fkForm.referencedTable.trim() ||
|
|
|
|
|
|
!fkForm.referencedColumn.trim()
|
|
|
|
|
|
) {
|
2026-03-18 09:00:11 +00:00
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const isTargetKeyColumn = targetTableKeyColumns.some(
|
|
|
|
|
|
(c) => c.toLowerCase() === fkForm.referencedColumn.trim().toLowerCase(),
|
|
|
|
|
|
)
|
|
|
|
|
|
if (!isTargetKeyColumn) {
|
|
|
|
|
|
toast.push(
|
2026-08-14 14:03:34 +00:00
|
|
|
|
<Notification type="warning" title={translate('::App.Platform.Warning')}>
|
|
|
|
|
|
{translate('::App.DeveloperKitTableDesigner.ReferenceColumnMustBeKey')}
|
2026-03-18 09:00:11 +00:00
|
|
|
|
</Notification>,
|
2026-07-08 12:34:13 +00:00
|
|
|
|
{ placement: 'bottom-end' },
|
2026-03-18 09:00:11 +00:00
|
|
|
|
)
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-01 17:40:25 +00:00
|
|
|
|
if (editingFkId) {
|
|
|
|
|
|
setRelationships((prev) =>
|
|
|
|
|
|
prev.map((r) => (r.id === editingFkId ? { ...fkForm, id: editingFkId } : r)),
|
|
|
|
|
|
)
|
|
|
|
|
|
} else {
|
|
|
|
|
|
setRelationships((prev) => [...prev, { ...fkForm, id: crypto.randomUUID() }])
|
|
|
|
|
|
}
|
|
|
|
|
|
setFkModalOpen(false)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const deleteFk = (id: string) => {
|
|
|
|
|
|
setRelationships((prev) => prev.filter((r) => r.id !== id))
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-09 13:35:00 +00:00
|
|
|
|
// ── Index / Key handlers ─────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
|
|
const buildIndexName = (type: IndexType, cols: IndexColumnEntry[]): string => {
|
|
|
|
|
|
const prefix = type === 'PrimaryKey' ? 'PK' : type === 'UniqueKey' ? 'UQ' : 'IX'
|
|
|
|
|
|
const entityName = settings.entityName || initialTableData?.tableName || ''
|
|
|
|
|
|
const colPart = cols.map((c) => c.columnName).join('_')
|
|
|
|
|
|
if (entityName && colPart) return `${prefix}_${entityName}_${colPart}`
|
|
|
|
|
|
if (entityName) return `${prefix}_${entityName}`
|
|
|
|
|
|
if (colPart) return `${prefix}_${colPart}`
|
|
|
|
|
|
return prefix
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const openAddIndex = () => {
|
|
|
|
|
|
setEditingIndexId(null)
|
|
|
|
|
|
const entityName = settings.entityName || initialTableData?.tableName || ''
|
|
|
|
|
|
setIndexForm({ ...EMPTY_INDEX, indexName: entityName ? `IX_${entityName}` : '' })
|
|
|
|
|
|
setIndexModalOpen(true)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const openEditIndex = (idx: TableIndex) => {
|
|
|
|
|
|
setEditingIndexId(idx.id)
|
2026-07-06 09:44:26 +00:00
|
|
|
|
const { ...rest } = idx
|
2026-03-09 13:35:00 +00:00
|
|
|
|
setIndexForm(rest)
|
|
|
|
|
|
setIndexModalOpen(true)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const saveIndex = () => {
|
|
|
|
|
|
if (!indexForm.indexName.trim() || indexForm.columns.length === 0) return
|
|
|
|
|
|
if (editingIndexId) {
|
|
|
|
|
|
setIndexes((prev) =>
|
|
|
|
|
|
prev.map((ix) => (ix.id === editingIndexId ? { ...indexForm, id: editingIndexId } : ix)),
|
|
|
|
|
|
)
|
|
|
|
|
|
} else {
|
|
|
|
|
|
setIndexes((prev) => [...prev, { ...indexForm, id: crypto.randomUUID() }])
|
|
|
|
|
|
}
|
|
|
|
|
|
setIndexModalOpen(false)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const deleteIndex = (id: string) => {
|
|
|
|
|
|
setIndexes((prev) => prev.filter((ix) => ix.id !== id))
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-01 17:40:25 +00:00
|
|
|
|
// ── Navigation ─────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
|
|
// Compute duplicate column names (lowercased)
|
|
|
|
|
|
const duplicateColumnNames = useMemo(() => {
|
|
|
|
|
|
const names = columns.map((c) => c.columnName.trim().toLowerCase()).filter(Boolean)
|
|
|
|
|
|
return new Set(names.filter((n, i) => names.indexOf(n) !== i))
|
|
|
|
|
|
}, [columns])
|
|
|
|
|
|
|
|
|
|
|
|
const canGoNext = (): boolean => {
|
2026-08-17 14:26:51 +00:00
|
|
|
|
if (currentStepKey === 'columns') {
|
2026-03-01 17:40:25 +00:00
|
|
|
|
const hasNamed = columns.some((c) => c.columnName.trim().length > 0)
|
|
|
|
|
|
return hasNamed && duplicateColumnNames.size === 0
|
|
|
|
|
|
}
|
2026-08-17 14:26:51 +00:00
|
|
|
|
if (currentStepKey === 'settings') {
|
2026-03-01 17:40:25 +00:00
|
|
|
|
if (isEditMode) return true // table name is locked in edit mode
|
|
|
|
|
|
const baseOk =
|
|
|
|
|
|
!!settings.tableName.trim() && !!settings.entityName.trim() && !!settings.menuValue
|
2026-03-21 14:42:36 +00:00
|
|
|
|
return baseOk
|
2026-03-01 17:40:25 +00:00
|
|
|
|
}
|
2026-08-17 14:26:51 +00:00
|
|
|
|
if (currentStepKey === 'crud') {
|
|
|
|
|
|
// Uretim isteniyorsa en az bir operasyon secilmis olmali.
|
|
|
|
|
|
return !generateCrud || crudOperations.length > 0
|
|
|
|
|
|
}
|
2026-03-01 17:40:25 +00:00
|
|
|
|
return true
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const handleNext = () => {
|
2026-08-17 14:26:51 +00:00
|
|
|
|
if (step < lastStep) setStep((s) => s + 1)
|
2026-03-01 17:40:25 +00:00
|
|
|
|
}
|
|
|
|
|
|
const handleBack = () => {
|
2026-08-17 14:26:51 +00:00
|
|
|
|
if (step > 0) setStep((s) => s - 1)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const toggleCrudOperation = (operation: CrudOperationType) => {
|
|
|
|
|
|
setCrudOperations((prev) =>
|
|
|
|
|
|
prev.includes(operation)
|
|
|
|
|
|
? prev.filter((op) => op !== operation)
|
|
|
|
|
|
: CRUD_OPERATION_TYPES.filter((op) => op === operation || prev.includes(op)),
|
|
|
|
|
|
)
|
2026-03-01 17:40:25 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ── Deploy ─────────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
|
|
const handleDeploy = async () => {
|
|
|
|
|
|
if (!dataSource) {
|
|
|
|
|
|
toast.push(
|
2026-08-14 14:03:34 +00:00
|
|
|
|
<Notification type="warning" title={translate('::App.Platform.Warning')}>
|
2026-03-03 06:15:23 +00:00
|
|
|
|
{translate('::App.SqlQueryManager.PleaseSelectDataSource')}
|
2026-03-01 17:40:25 +00:00
|
|
|
|
</Notification>,
|
2026-07-08 12:34:13 +00:00
|
|
|
|
{ placement: 'bottom-end' },
|
2026-03-01 17:40:25 +00:00
|
|
|
|
)
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
setIsDeploying(true)
|
|
|
|
|
|
try {
|
2026-05-03 19:50:51 +00:00
|
|
|
|
// Strip GO batch separators — they are SSMS-only and not valid T-SQL
|
|
|
|
|
|
const executableSql = generatedSql
|
|
|
|
|
|
.split(/^\s*GO\s*$/im)
|
|
|
|
|
|
.map((b) => b.trim())
|
|
|
|
|
|
.filter(Boolean)
|
|
|
|
|
|
.join('\n')
|
|
|
|
|
|
|
2026-03-01 17:40:25 +00:00
|
|
|
|
const result = await sqlObjectManagerService.executeQuery({
|
2026-05-03 19:50:51 +00:00
|
|
|
|
queryText: executableSql,
|
2026-03-01 17:40:25 +00:00
|
|
|
|
dataSourceCode: dataSource,
|
|
|
|
|
|
})
|
|
|
|
|
|
if (result.data.success) {
|
|
|
|
|
|
const deployedTable = settings.tableName || initialTableData?.tableName || ''
|
|
|
|
|
|
toast.push(
|
2026-03-29 08:59:07 +00:00
|
|
|
|
<Notification type="success" title={translate('::App.Platform.Success')}>
|
2026-03-03 06:15:23 +00:00
|
|
|
|
{`${translate(isEditMode ? '::App.SqlQueryManager.TableUpdated' : '::App.SqlQueryManager.TableCreated')}: [dbo].[${deployedTable}]`}
|
2026-03-01 17:40:25 +00:00
|
|
|
|
</Notification>,
|
2026-07-08 12:34:13 +00:00
|
|
|
|
{ placement: 'bottom-end' },
|
2026-03-01 17:40:25 +00:00
|
|
|
|
)
|
2026-08-17 05:56:52 +00:00
|
|
|
|
// Save seed file to the seeds folder (configs/seeds/SqlData) after successful deploy.
|
2026-05-03 19:50:51 +00:00
|
|
|
|
// Always use the full CREATE TABLE script (not the ALTER diff) so the seed file
|
|
|
|
|
|
// can recreate the table from scratch when the database is wiped.
|
|
|
|
|
|
try {
|
|
|
|
|
|
const seedSql = generateCreateTableSql(columns, settings, relationships, indexes)
|
|
|
|
|
|
await sqlObjectManagerService.saveTableScript({
|
|
|
|
|
|
fileName: deployedTable,
|
|
|
|
|
|
sqlScript: seedSql,
|
|
|
|
|
|
})
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
// Non-blocking: seed file save failure does not affect deploy success
|
|
|
|
|
|
}
|
2026-08-17 14:26:51 +00:00
|
|
|
|
// Kullanici CRUD adiminda istediyse endpoint'leri uretir; secilmeyen
|
|
|
|
|
|
// operasyonlar pasif olarak kaydedilir. Hata deploy'u geri almaz.
|
|
|
|
|
|
if (canManageCrudEndpoints && generateCrud && crudOperations.length > 0 && deployedTable) {
|
|
|
|
|
|
try {
|
|
|
|
|
|
await developerKitService.generateCrudEndpoints(
|
|
|
|
|
|
toEntityName(deployedTable),
|
|
|
|
|
|
crudOperations,
|
|
|
|
|
|
)
|
|
|
|
|
|
toast.push(
|
|
|
|
|
|
<Notification type="success" title={translate('::App.Platform.Success')}>
|
|
|
|
|
|
{`${translate('::App.SqlQueryManager.CrudEndpoints')}: ${crudOperations.join(', ')}`}
|
|
|
|
|
|
</Notification>,
|
|
|
|
|
|
{ placement: 'bottom-end' },
|
|
|
|
|
|
)
|
|
|
|
|
|
} catch (crudError) {
|
|
|
|
|
|
toast.push(
|
|
|
|
|
|
<Notification type="warning" title={translate('::App.Platform.Warning')}>
|
|
|
|
|
|
{getErrorMessage(crudError, translate('::App.Platform.IslemBasarisiz'))}
|
|
|
|
|
|
</Notification>,
|
|
|
|
|
|
{ placement: 'bottom-end' },
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-06 18:31:03 +00:00
|
|
|
|
await onDeployed?.({
|
|
|
|
|
|
schemaName: initialTableData?.schemaName || 'dbo',
|
|
|
|
|
|
tableName: deployedTable,
|
|
|
|
|
|
})
|
2026-03-01 17:40:25 +00:00
|
|
|
|
handleClose()
|
|
|
|
|
|
} else {
|
|
|
|
|
|
toast.push(
|
2026-03-29 08:59:07 +00:00
|
|
|
|
<Notification type="danger" title={translate('::App.Platform.Error')}>
|
2026-03-03 06:15:23 +00:00
|
|
|
|
{result.data.message || translate('::App.SqlQueryManager.TableCreationFailed')}
|
2026-03-01 17:40:25 +00:00
|
|
|
|
</Notification>,
|
2026-07-08 12:34:13 +00:00
|
|
|
|
{ placement: 'bottom-end' },
|
2026-03-01 17:40:25 +00:00
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch (error: any) {
|
|
|
|
|
|
toast.push(
|
2026-03-29 08:59:07 +00:00
|
|
|
|
<Notification type="danger" title={translate('::App.Platform.Error')}>
|
2026-03-03 07:34:25 +00:00
|
|
|
|
{error.response?.data?.error?.message ||
|
|
|
|
|
|
translate('::App.SqlQueryManager.TableDeployFailed')}
|
2026-03-01 17:40:25 +00:00
|
|
|
|
</Notification>,
|
2026-07-08 12:34:13 +00:00
|
|
|
|
{ placement: 'bottom-end' },
|
2026-03-01 17:40:25 +00:00
|
|
|
|
)
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
setIsDeploying(false)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const handleClose = () => {
|
|
|
|
|
|
setStep(0)
|
|
|
|
|
|
setColumns([createEmptyColumn()])
|
|
|
|
|
|
setOriginalColumns([])
|
|
|
|
|
|
setSettings(DEFAULT_SETTINGS)
|
|
|
|
|
|
setRelationships([])
|
|
|
|
|
|
setOriginalRelationships([])
|
2026-03-09 13:35:00 +00:00
|
|
|
|
setIndexes([])
|
|
|
|
|
|
setOriginalIndexes([])
|
2026-03-01 17:40:25 +00:00
|
|
|
|
setDbTables([])
|
|
|
|
|
|
setTargetTableColumns([])
|
2026-03-18 09:00:11 +00:00
|
|
|
|
setTargetTableKeyColumns([])
|
2026-03-03 07:34:25 +00:00
|
|
|
|
setSelectedMenuCode('')
|
|
|
|
|
|
setMenuAddDialogOpen(false)
|
2026-08-19 07:50:33 +00:00
|
|
|
|
setTemplatesOpen(false)
|
2026-08-17 14:26:51 +00:00
|
|
|
|
setGenerateCrud(false)
|
|
|
|
|
|
setCrudOperations([...CRUD_OPERATION_TYPES])
|
2026-03-01 17:40:25 +00:00
|
|
|
|
onClose()
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ── Step Indicator ─────────────────────────────────────────────────────────
|
|
|
|
|
|
|
2026-08-17 14:26:51 +00:00
|
|
|
|
const STEP_LABEL: Record<StepKey, string> = {
|
|
|
|
|
|
columns: translate('::App.SqlQueryManager.ColumnDesign'),
|
|
|
|
|
|
settings: translate('::App.SqlQueryManager.EntitySettings'),
|
|
|
|
|
|
indexes: translate('::App.SqlQueryManager.IndexKeys'),
|
|
|
|
|
|
relationships: translate('::App.SqlQueryManager.Relationships'),
|
|
|
|
|
|
crud: translate('::App.SqlQueryManager.CrudEndpoints'),
|
|
|
|
|
|
sql: translate('::App.SqlQueryManager.TSqlPreview'),
|
|
|
|
|
|
}
|
|
|
|
|
|
const STEP_LABELS = stepKeys.map((key) => STEP_LABEL[key])
|
2026-03-01 17:40:25 +00:00
|
|
|
|
|
|
|
|
|
|
const renderStepIndicator = () => (
|
2026-03-02 18:31:49 +00:00
|
|
|
|
<div className="flex items-center justify-between mb-2">
|
2026-03-01 17:40:25 +00:00
|
|
|
|
{STEP_LABELS.map((label, i) => {
|
|
|
|
|
|
const isDone = i < step
|
|
|
|
|
|
const isActive = i === step
|
|
|
|
|
|
return (
|
2026-03-02 18:31:49 +00:00
|
|
|
|
<div key={i} className="flex items-center flex-1">
|
|
|
|
|
|
<div className="flex items-center gap-2">
|
|
|
|
|
|
<div
|
|
|
|
|
|
className={`w-7 h-7 rounded-full flex items-center justify-center text-xs font-bold transition-colors
|
|
|
|
|
|
${isDone ? 'bg-green-500 text-white' : isActive ? 'bg-blue-600 text-white' : 'bg-gray-200 text-gray-500 dark:bg-gray-700 dark:text-gray-400'}`}
|
|
|
|
|
|
>
|
|
|
|
|
|
{isDone ? <FaCheck /> : i + 1}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<span
|
|
|
|
|
|
className={`text-sm font-medium whitespace-nowrap ${isActive ? 'text-blue-600 dark:text-blue-400' : 'text-gray-500'}`}
|
|
|
|
|
|
>
|
|
|
|
|
|
{label}
|
|
|
|
|
|
</span>
|
2026-03-01 17:40:25 +00:00
|
|
|
|
</div>
|
|
|
|
|
|
{i < STEP_LABELS.length - 1 && (
|
2026-03-02 18:31:49 +00:00
|
|
|
|
<div className="flex-1 mx-3 h-px bg-gray-200 dark:bg-gray-700" />
|
2026-03-01 17:40:25 +00:00
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)
|
|
|
|
|
|
})}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
// ── Step 0: Column Designer ────────────────────────────────────────────────
|
|
|
|
|
|
|
2026-08-19 07:50:33 +00:00
|
|
|
|
/** Hazir kolon setleri; buton kalabaligi yerine tek bir sablon panelinde toplanir. */
|
|
|
|
|
|
const columnTemplates: {
|
|
|
|
|
|
key: string
|
|
|
|
|
|
label: string
|
|
|
|
|
|
hint: string
|
|
|
|
|
|
icon: React.ReactNode
|
|
|
|
|
|
color: string
|
|
|
|
|
|
apply: () => void
|
|
|
|
|
|
}[] = [
|
|
|
|
|
|
{
|
|
|
|
|
|
key: 'multiTenant',
|
|
|
|
|
|
label: translate('::App.SqlQueryManager.AddMultiTenantColumns'),
|
|
|
|
|
|
hint: 'TenantId',
|
|
|
|
|
|
icon: <FaUsers />,
|
|
|
|
|
|
color: 'bg-emerald-500',
|
|
|
|
|
|
apply: addMultiTenantColumns,
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
key: 'fullAudited',
|
|
|
|
|
|
label: translate('::App.SqlQueryManager.AddFullAuditedColumns'),
|
|
|
|
|
|
hint: 'Creation / Modification / Deletion',
|
|
|
|
|
|
icon: <FaHistory />,
|
|
|
|
|
|
color: 'bg-blue-500',
|
|
|
|
|
|
apply: addFullAuditedColumns,
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
key: 'workflow',
|
|
|
|
|
|
label: translate('::App.SqlQueryManager.AddWorkflowColumns'),
|
|
|
|
|
|
hint: 'Workflow state columns',
|
|
|
|
|
|
icon: <FaProjectDiagram />,
|
|
|
|
|
|
color: 'bg-indigo-500',
|
|
|
|
|
|
apply: addWorkflowColumns,
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
key: 'todo',
|
|
|
|
|
|
label: 'Todo Columns',
|
|
|
|
|
|
hint: 'Status / Priority / DueDate',
|
|
|
|
|
|
icon: <FaTasks />,
|
|
|
|
|
|
color: 'bg-cyan-500',
|
|
|
|
|
|
apply: addTodoColumns,
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
key: 'gantt',
|
|
|
|
|
|
label: 'Gantt Columns',
|
|
|
|
|
|
hint: 'Start / End / Progress',
|
|
|
|
|
|
icon: <FaChartBar />,
|
|
|
|
|
|
color: 'bg-violet-500',
|
|
|
|
|
|
apply: addGanttColumns,
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
key: 'scheduler',
|
|
|
|
|
|
label: 'Scheduler Columns',
|
|
|
|
|
|
hint: 'Event start / end / recurrence',
|
|
|
|
|
|
icon: <FaRegCalendarAlt />,
|
|
|
|
|
|
color: 'bg-teal-500',
|
|
|
|
|
|
apply: addSchedulerColumns,
|
|
|
|
|
|
},
|
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
if (!templatesOpen) return
|
|
|
|
|
|
const onDocClick = (e: MouseEvent) => {
|
|
|
|
|
|
if (templatesRef.current && !templatesRef.current.contains(e.target as Node)) {
|
|
|
|
|
|
setTemplatesOpen(false)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
const onEsc = (e: KeyboardEvent) => {
|
|
|
|
|
|
if (e.key === 'Escape') setTemplatesOpen(false)
|
|
|
|
|
|
}
|
|
|
|
|
|
document.addEventListener('mousedown', onDocClick)
|
|
|
|
|
|
document.addEventListener('keydown', onEsc)
|
|
|
|
|
|
return () => {
|
|
|
|
|
|
document.removeEventListener('mousedown', onDocClick)
|
|
|
|
|
|
document.removeEventListener('keydown', onEsc)
|
|
|
|
|
|
}
|
|
|
|
|
|
}, [templatesOpen])
|
|
|
|
|
|
|
2026-03-01 17:40:25 +00:00
|
|
|
|
const renderColumnDesigner = () => (
|
|
|
|
|
|
<div className="flex flex-col">
|
|
|
|
|
|
{colsLoading && (
|
|
|
|
|
|
<div className="flex items-center justify-center py-12 text-gray-500">
|
2026-03-03 07:34:25 +00:00
|
|
|
|
<span className="animate-spin mr-2">◠</span>{' '}
|
|
|
|
|
|
{translate('::App.SqlQueryManager.LoadingColumns')}
|
2026-03-01 17:40:25 +00:00
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
{!colsLoading && (
|
2026-03-03 07:34:25 +00:00
|
|
|
|
<>
|
2026-08-19 07:50:33 +00:00
|
|
|
|
<div className="flex flex-wrap items-center justify-between gap-2 py-2">
|
|
|
|
|
|
<div className="flex items-center gap-2">
|
|
|
|
|
|
<div className="relative" ref={templatesRef}>
|
2026-07-28 14:00:38 +00:00
|
|
|
|
<Button
|
|
|
|
|
|
size="xs"
|
|
|
|
|
|
variant="solid"
|
2026-08-19 07:50:33 +00:00
|
|
|
|
color="blue-600"
|
|
|
|
|
|
icon={<FaLayerGroup />}
|
|
|
|
|
|
onClick={() => setTemplatesOpen((o) => !o)}
|
2026-07-28 14:00:38 +00:00
|
|
|
|
>
|
2026-08-19 07:50:33 +00:00
|
|
|
|
<span className="inline-flex items-center gap-1.5">
|
|
|
|
|
|
{translate('::App.DeveloperKitTableDesigner.ColumnTemplates')}
|
|
|
|
|
|
<FaChevronDown
|
|
|
|
|
|
className={`text-[9px] transition-transform ${templatesOpen ? 'rotate-180' : ''}`}
|
|
|
|
|
|
/>
|
|
|
|
|
|
</span>
|
2026-07-28 14:00:38 +00:00
|
|
|
|
</Button>
|
2026-08-19 07:50:33 +00:00
|
|
|
|
|
|
|
|
|
|
{templatesOpen && (
|
|
|
|
|
|
<div className="absolute left-0 top-full z-50 mt-1.5 w-[440px] max-w-[85vw] rounded-lg border border-gray-200 bg-white p-2 shadow-xl dark:border-gray-700 dark:bg-gray-800">
|
|
|
|
|
|
<div className="px-1.5 pb-1.5 text-[10px] font-semibold uppercase tracking-wider text-gray-400">
|
|
|
|
|
|
{translate('::App.DeveloperKitTableDesigner.ColumnTemplatesHint')}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className="grid grid-cols-2 gap-0.5">
|
|
|
|
|
|
{columnTemplates.map((tpl) => (
|
|
|
|
|
|
<button
|
|
|
|
|
|
key={tpl.key}
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
className="flex items-start gap-2 rounded-md p-2 text-left transition-colors hover:bg-gray-100 dark:hover:bg-gray-700/60"
|
|
|
|
|
|
onClick={() => {
|
|
|
|
|
|
tpl.apply()
|
|
|
|
|
|
setTemplatesOpen(false)
|
|
|
|
|
|
}}
|
|
|
|
|
|
>
|
|
|
|
|
|
<span
|
|
|
|
|
|
className={`mt-0.5 flex h-6 w-6 shrink-0 items-center justify-center rounded-md text-[11px] text-white ${tpl.color}`}
|
|
|
|
|
|
>
|
|
|
|
|
|
{tpl.icon}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
<span className="min-w-0">
|
|
|
|
|
|
<span className="block truncate text-xs font-semibold text-gray-700 dark:text-gray-100">
|
|
|
|
|
|
{tpl.label}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
<span className="block truncate text-[10px] leading-snug text-gray-400">
|
|
|
|
|
|
{tpl.hint}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
</span>
|
|
|
|
|
|
</button>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
2026-07-28 14:00:38 +00:00
|
|
|
|
</div>
|
2026-08-19 07:50:33 +00:00
|
|
|
|
|
|
|
|
|
|
<Button
|
|
|
|
|
|
size="xs"
|
|
|
|
|
|
variant="plain"
|
|
|
|
|
|
icon={<FaRegClipboard />}
|
|
|
|
|
|
onClick={importColumnsFromRememberedCreateTable}
|
|
|
|
|
|
disabled={isEditMode}
|
|
|
|
|
|
>
|
|
|
|
|
|
{translate('::App.DeveloperKitTableDesigner.PasteCreateTableColumns')}
|
|
|
|
|
|
</Button>
|
2026-03-01 17:40:25 +00:00
|
|
|
|
</div>
|
2026-08-19 07:50:33 +00:00
|
|
|
|
|
|
|
|
|
|
<div className="flex items-center gap-2">
|
|
|
|
|
|
<span className="rounded-full bg-gray-100 px-2 py-0.5 text-[10px] font-medium text-gray-500 dark:bg-gray-700 dark:text-gray-300">
|
|
|
|
|
|
{columns.length} {translate('::App.SqlQueryManager.ColumnName')}
|
|
|
|
|
|
</span>
|
2026-03-03 07:34:25 +00:00
|
|
|
|
<Button
|
2026-05-13 11:31:15 +00:00
|
|
|
|
size="xs"
|
2026-08-19 07:50:33 +00:00
|
|
|
|
variant="plain"
|
2026-03-03 07:34:25 +00:00
|
|
|
|
icon={<FaTrash />}
|
|
|
|
|
|
onClick={clearAllColumns}
|
2026-08-19 07:50:33 +00:00
|
|
|
|
title={translate('::App.SqlQueryManager.ClearAllColumns')}
|
2026-03-01 17:40:25 +00:00
|
|
|
|
>
|
2026-03-03 07:34:25 +00:00
|
|
|
|
{translate('::App.SqlQueryManager.ClearAllColumns')}
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
<Button
|
2026-05-13 11:31:15 +00:00
|
|
|
|
size="xs"
|
2026-03-03 07:34:25 +00:00
|
|
|
|
variant="solid"
|
|
|
|
|
|
color="blue-600"
|
|
|
|
|
|
icon={<FaPlus />}
|
|
|
|
|
|
onClick={addColumn}
|
|
|
|
|
|
>
|
|
|
|
|
|
{translate('::App.SqlQueryManager.AddColumn')}
|
|
|
|
|
|
</Button>
|
2026-03-01 17:40:25 +00:00
|
|
|
|
</div>
|
2026-03-03 07:34:25 +00:00
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
{/* Header row */}
|
|
|
|
|
|
<div className="grid grid-cols-12 gap-1 px-1 py-1 bg-gray-50 dark:bg-gray-800 rounded text-xs font-semibold text-gray-600 dark:text-gray-300">
|
2026-03-09 13:35:00 +00:00
|
|
|
|
<div className="col-span-4">{translate('::App.SqlQueryManager.ColumnName')}</div>
|
2026-03-03 07:34:25 +00:00
|
|
|
|
<div className="col-span-3">{translate('::App.SqlQueryManager.DataType')}</div>
|
|
|
|
|
|
<div className="col-span-1 text-center">{translate('::App.SqlQueryManager.Max')}</div>
|
|
|
|
|
|
<div className="col-span-1 text-center">
|
|
|
|
|
|
{translate('::App.SqlQueryManager.Nullable')}
|
2026-03-01 17:40:25 +00:00
|
|
|
|
</div>
|
2026-08-14 14:03:34 +00:00
|
|
|
|
<div className="col-span-2">{translate('::Abp.Mailing.Default')}</div>
|
2026-03-03 07:34:25 +00:00
|
|
|
|
<div className="col-span-1 text-center">
|
|
|
|
|
|
{translate('::App.SqlQueryManager.Actions')}
|
2026-03-01 17:40:25 +00:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
2026-03-03 07:34:25 +00:00
|
|
|
|
{/* Editable column rows */}
|
|
|
|
|
|
{duplicateColumnNames.size > 0 && (
|
|
|
|
|
|
<div className="px-1 py-1.5 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded text-xs text-red-600 dark:text-red-400">
|
2026-08-14 14:03:34 +00:00
|
|
|
|
⚠️ {translate('::App.DeveloperKitTableDesigner.DuplicateColumnName')}:{' '}
|
2026-03-03 07:34:25 +00:00
|
|
|
|
{[...duplicateColumnNames].map((n) => (
|
|
|
|
|
|
<code key={n} className="bg-red-100 dark:bg-red-900/40 px-1 rounded mr-1">
|
|
|
|
|
|
{n}
|
|
|
|
|
|
</code>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
2026-06-08 08:52:30 +00:00
|
|
|
|
<div className="flex flex-col gap-1 pr-1">
|
2026-03-03 07:34:25 +00:00
|
|
|
|
{columns.map((col, idx) => {
|
|
|
|
|
|
const isDuplicate =
|
|
|
|
|
|
col.columnName.trim() !== '' &&
|
|
|
|
|
|
duplicateColumnNames.has(col.columnName.trim().toLowerCase())
|
|
|
|
|
|
const isNewRow = isEditMode && !originalColumns.some((o) => o.id === col.id)
|
|
|
|
|
|
|
|
|
|
|
|
const origRow = isEditMode ? originalColumns.find((o) => o.id === col.id) : undefined
|
|
|
|
|
|
const isRenamedRow =
|
|
|
|
|
|
isEditMode &&
|
|
|
|
|
|
!!origRow &&
|
|
|
|
|
|
origRow.columnName.trim().toLowerCase() !== col.columnName.trim().toLowerCase()
|
|
|
|
|
|
const isModifiedRow =
|
|
|
|
|
|
isEditMode &&
|
|
|
|
|
|
!!origRow &&
|
|
|
|
|
|
!isNewRow &&
|
|
|
|
|
|
(origRow.dataType !== col.dataType ||
|
|
|
|
|
|
origRow.maxLength !== col.maxLength ||
|
|
|
|
|
|
origRow.isNullable !== col.isNullable)
|
|
|
|
|
|
|
|
|
|
|
|
const rowBg = isDuplicate
|
|
|
|
|
|
? 'outline outline-1 outline-red-400'
|
|
|
|
|
|
: isNewRow
|
|
|
|
|
|
? 'bg-green-50 dark:bg-green-900/20 outline outline-1 outline-green-400'
|
|
|
|
|
|
: isRenamedRow && isModifiedRow
|
|
|
|
|
|
? 'bg-purple-50 dark:bg-purple-900/20 outline outline-1 outline-purple-400'
|
|
|
|
|
|
: isRenamedRow
|
|
|
|
|
|
? 'bg-blue-50 dark:bg-blue-900/20 outline outline-1 outline-blue-400'
|
|
|
|
|
|
: isModifiedRow
|
|
|
|
|
|
? 'bg-yellow-50 dark:bg-yellow-900/20 outline outline-1 outline-yellow-400'
|
|
|
|
|
|
: ''
|
|
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
|
<div
|
|
|
|
|
|
key={col.id}
|
|
|
|
|
|
className={`grid grid-cols-12 gap-2 bg-white dark:bg-gray-800 rounded items-center ${rowBg}`}
|
|
|
|
|
|
>
|
2026-03-09 13:35:00 +00:00
|
|
|
|
<div className="col-span-4">
|
2026-08-20 12:01:10 +00:00
|
|
|
|
<Input
|
2026-08-20 14:54:11 +00:00
|
|
|
|
size="sm"
|
2026-03-03 07:34:25 +00:00
|
|
|
|
type="text"
|
2026-08-20 14:54:11 +00:00
|
|
|
|
className={isDuplicate ? 'w-full border-red-400' : 'w-full'}
|
2026-03-03 07:34:25 +00:00
|
|
|
|
placeholder={translate('::App.SqlQueryManager.ColumnNamePlaceholder')}
|
|
|
|
|
|
value={col.columnName}
|
|
|
|
|
|
onChange={(e) => updateColumn(col.id, 'columnName', e.target.value)}
|
2026-05-03 19:50:51 +00:00
|
|
|
|
ref={(el) => {
|
|
|
|
|
|
if (el && col.id === newRowId) {
|
|
|
|
|
|
el.focus()
|
|
|
|
|
|
setNewRowId(null)
|
|
|
|
|
|
}
|
|
|
|
|
|
}}
|
2026-03-03 07:34:25 +00:00
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className="col-span-3">
|
2026-08-20 14:54:11 +00:00
|
|
|
|
<Select
|
|
|
|
|
|
size="sm"
|
|
|
|
|
|
className="w-full"
|
|
|
|
|
|
maxMenuHeight={200}
|
|
|
|
|
|
menuPortalTarget={document.body}
|
|
|
|
|
|
options={dataTypeOptions}
|
|
|
|
|
|
value={dataTypeOptions.filter((option) => option.value === col.dataType)}
|
|
|
|
|
|
onChange={(option) => {
|
|
|
|
|
|
if (!option) return
|
|
|
|
|
|
const dt = option.value as SqlDataType
|
2026-03-03 07:34:25 +00:00
|
|
|
|
updateColumn(col.id, 'dataType', dt)
|
|
|
|
|
|
if (dt !== 'nvarchar') updateColumn(col.id, 'maxLength', '')
|
|
|
|
|
|
else if (!col.maxLength) updateColumn(col.id, 'maxLength', '100')
|
|
|
|
|
|
}}
|
2026-08-20 14:54:11 +00:00
|
|
|
|
/>
|
2026-03-03 07:34:25 +00:00
|
|
|
|
</div>
|
|
|
|
|
|
<div className="col-span-1">
|
2026-08-20 12:01:10 +00:00
|
|
|
|
<Input
|
2026-08-20 14:54:11 +00:00
|
|
|
|
size="sm"
|
2026-03-03 07:34:25 +00:00
|
|
|
|
type="number"
|
2026-08-20 14:54:11 +00:00
|
|
|
|
className="w-full text-center"
|
2026-03-03 07:34:25 +00:00
|
|
|
|
placeholder="-"
|
|
|
|
|
|
value={col.maxLength}
|
|
|
|
|
|
disabled={col.dataType !== 'nvarchar'}
|
|
|
|
|
|
onChange={(e) => updateColumn(col.id, 'maxLength', e.target.value)}
|
|
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className="col-span-1 flex justify-center">
|
|
|
|
|
|
<Checkbox
|
|
|
|
|
|
checked={col.isNullable}
|
|
|
|
|
|
onChange={(checked) => updateColumn(col.id, 'isNullable', checked as boolean)}
|
|
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className="col-span-2">
|
2026-08-20 12:01:10 +00:00
|
|
|
|
<Input
|
2026-08-20 14:54:11 +00:00
|
|
|
|
size="sm"
|
2026-03-03 07:34:25 +00:00
|
|
|
|
type="text"
|
2026-08-20 14:54:11 +00:00
|
|
|
|
className="w-full"
|
2026-08-14 14:03:34 +00:00
|
|
|
|
placeholder={translate('::Abp.Mailing.Default')}
|
2026-03-03 07:34:25 +00:00
|
|
|
|
value={col.defaultValue}
|
|
|
|
|
|
onChange={(e) => updateColumn(col.id, 'defaultValue', e.target.value)}
|
|
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className="col-span-1 flex items-center justify-center gap-1">
|
2026-06-25 22:55:44 +00:00
|
|
|
|
<Button
|
2026-03-03 07:34:25 +00:00
|
|
|
|
onClick={() => moveColumn(col.id, 'up')}
|
|
|
|
|
|
disabled={idx === 0}
|
2026-06-25 22:55:44 +00:00
|
|
|
|
size="xs"
|
|
|
|
|
|
variant="plain"
|
|
|
|
|
|
shape="circle"
|
|
|
|
|
|
icon={<FaArrowUp className="text-xs" />}
|
|
|
|
|
|
className="!h-6 !w-6 !px-0 hover:!bg-gray-100 dark:hover:!bg-gray-700 disabled:opacity-30 text-gray-500"
|
2026-08-14 14:03:34 +00:00
|
|
|
|
title={translate('::App.Platform.MoveUp')}
|
2026-06-25 22:55:44 +00:00
|
|
|
|
/>
|
|
|
|
|
|
<Button
|
2026-03-03 07:34:25 +00:00
|
|
|
|
onClick={() => moveColumn(col.id, 'down')}
|
|
|
|
|
|
disabled={idx === columns.length - 1}
|
2026-06-25 22:55:44 +00:00
|
|
|
|
size="xs"
|
|
|
|
|
|
variant="plain"
|
|
|
|
|
|
shape="circle"
|
|
|
|
|
|
icon={<FaArrowDown className="text-xs" />}
|
|
|
|
|
|
className="!h-6 !w-6 !px-0 hover:!bg-gray-100 dark:hover:!bg-gray-700 disabled:opacity-30 text-gray-500"
|
2026-08-14 14:03:34 +00:00
|
|
|
|
title={translate('::App.Platform.MoveDown')}
|
2026-06-25 22:55:44 +00:00
|
|
|
|
/>
|
|
|
|
|
|
<Button
|
2026-03-03 07:34:25 +00:00
|
|
|
|
onClick={() => removeColumn(col.id)}
|
2026-06-25 22:55:44 +00:00
|
|
|
|
size="xs"
|
|
|
|
|
|
variant="plain"
|
|
|
|
|
|
shape="circle"
|
|
|
|
|
|
icon={<FaTrash className="text-xs" />}
|
|
|
|
|
|
className="!h-6 !w-6 !px-0 hover:!bg-red-50 dark:hover:!bg-red-900/30 text-red-500"
|
2026-08-14 14:03:34 +00:00
|
|
|
|
title={translate('::App.Platform.Delete')}
|
2026-06-25 22:55:44 +00:00
|
|
|
|
/>
|
2026-03-03 07:34:25 +00:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)
|
|
|
|
|
|
})}
|
2026-03-01 17:40:25 +00:00
|
|
|
|
</div>
|
2026-03-03 07:34:25 +00:00
|
|
|
|
|
2026-03-21 14:42:36 +00:00
|
|
|
|
{/* PK guidance */}
|
2026-03-03 07:34:25 +00:00
|
|
|
|
{!isEditMode && !columns.some((c) => c.columnName.trim().toLowerCase() === 'id') && (
|
|
|
|
|
|
<div className="px-2 py-1.5 mt-2 bg-orange-50 dark:bg-orange-900/20 border border-orange-300 dark:border-orange-700 rounded text-xs text-orange-700 dark:text-orange-300">
|
2026-08-14 14:03:34 +00:00
|
|
|
|
{translate('::App.DeveloperKitTableDesigner.PkIdHint')}
|
2026-03-03 07:34:25 +00:00
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</>
|
2026-03-01 17:40:25 +00:00
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
// ── Step 1: Entity Settings ────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
|
|
const renderEntitySettings = () => (
|
|
|
|
|
|
<div className="flex flex-col gap-2">
|
|
|
|
|
|
<div className="grid grid-cols-2 gap-2">
|
|
|
|
|
|
{/* Menu Name */}
|
2026-03-02 21:34:19 +00:00
|
|
|
|
<div className="col-span-2">
|
2026-03-03 07:34:25 +00:00
|
|
|
|
<div className="flex items-center justify-between mb-1">
|
|
|
|
|
|
<label className="block text-sm font-medium">
|
|
|
|
|
|
{translate('::App.SqlQueryManager.MenuName')} <span className="text-red-500">*</span>
|
|
|
|
|
|
</label>
|
|
|
|
|
|
<div className="flex items-center gap-2">
|
2026-06-25 22:55:44 +00:00
|
|
|
|
<Button
|
2026-03-03 07:34:25 +00:00
|
|
|
|
type="button"
|
|
|
|
|
|
disabled={isEditMode}
|
|
|
|
|
|
onClick={() => setMenuAddDialogOpen(true)}
|
2026-06-25 22:55:44 +00:00
|
|
|
|
size="xs"
|
|
|
|
|
|
variant="solid"
|
|
|
|
|
|
color="green-500"
|
2026-07-06 09:44:26 +00:00
|
|
|
|
className="flex items-center gap-2 !h-auto !px-2 !py-1 hover:!bg-green-600 disabled:opacity-40 disabled:cursor-not-allowed"
|
2026-03-03 07:34:25 +00:00
|
|
|
|
>
|
2026-08-14 14:03:34 +00:00
|
|
|
|
<FaPlus className="text-xs" /> {translate('::App.Platform.AddNewMenu')}
|
2026-06-25 22:55:44 +00:00
|
|
|
|
</Button>
|
2026-03-03 07:34:25 +00:00
|
|
|
|
{settings.menuValue && !isEditMode && (
|
2026-06-25 22:55:44 +00:00
|
|
|
|
<Button
|
2026-03-03 07:34:25 +00:00
|
|
|
|
type="button"
|
|
|
|
|
|
onClick={() => {
|
|
|
|
|
|
setSelectedMenuCode('')
|
|
|
|
|
|
setSettings((s) => ({ ...s, menuValue: '', menuPrefix: '', tableName: '' }))
|
|
|
|
|
|
}}
|
2026-06-25 22:55:44 +00:00
|
|
|
|
size="xs"
|
|
|
|
|
|
variant="default"
|
2026-07-06 09:44:26 +00:00
|
|
|
|
className="flex items-center gap-2!h-auto !px-2 !py-1 border-gray-300 dark:border-gray-600 text-gray-500 hover:text-red-500 hover:border-red-400"
|
2026-03-03 07:34:25 +00:00
|
|
|
|
>
|
2026-08-14 14:03:34 +00:00
|
|
|
|
<FaTimes className="text-xs" /> {translate('::App.Platform.ClearSelection')}
|
2026-06-25 22:55:44 +00:00
|
|
|
|
</Button>
|
2026-03-03 07:34:25 +00:00
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<SimpleMenuTreeSelect
|
|
|
|
|
|
selectedCode={selectedMenuCode}
|
|
|
|
|
|
onSelect={isEditMode ? () => {} : onMenuCodeSelect}
|
|
|
|
|
|
nodes={menuTree}
|
|
|
|
|
|
isLoading={menuLoading}
|
|
|
|
|
|
invalid={!settings.menuValue && !isEditMode && !menuLoading}
|
|
|
|
|
|
/>
|
|
|
|
|
|
{settings.menuValue && (
|
|
|
|
|
|
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
|
|
|
|
|
|
<span className="font-mono font-semibold text-indigo-600 dark:text-indigo-400">
|
|
|
|
|
|
{settings.menuValue}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
</p>
|
|
|
|
|
|
)}
|
|
|
|
|
|
<MenuAddDialog
|
|
|
|
|
|
isOpen={menuAddDialogOpen}
|
|
|
|
|
|
onClose={() => setMenuAddDialogOpen(false)}
|
|
|
|
|
|
initialParentCode={selectedMenuCode}
|
|
|
|
|
|
initialOrder={999}
|
|
|
|
|
|
rawItems={rawMenuItems}
|
2026-06-05 09:30:40 +00:00
|
|
|
|
onSaved={(menu) =>
|
|
|
|
|
|
reloadMenus((items) => {
|
|
|
|
|
|
const savedMenu = items.find((item) => item.code === menu.code)
|
|
|
|
|
|
if (savedMenu?.code && savedMenu.shortName) {
|
|
|
|
|
|
onMenuCodeSelect(savedMenu.code)
|
|
|
|
|
|
}
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
2026-03-03 07:34:25 +00:00
|
|
|
|
/>
|
2026-03-01 17:40:25 +00:00
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
{/* Entity Name */}
|
|
|
|
|
|
<div>
|
|
|
|
|
|
<label className="block text-sm font-medium mb-1">
|
2026-03-03 06:15:23 +00:00
|
|
|
|
{translate('::App.SqlQueryManager.EntityName')} <span className="text-red-500">*</span>
|
2026-03-01 17:40:25 +00:00
|
|
|
|
</label>
|
2026-08-20 12:01:10 +00:00
|
|
|
|
<Input
|
2026-08-20 14:54:11 +00:00
|
|
|
|
size="sm"
|
2026-03-01 17:40:25 +00:00
|
|
|
|
type="text"
|
2026-03-02 21:34:19 +00:00
|
|
|
|
disabled={isEditMode} // Entity name (and thus table name) cannot be changed in edit mode
|
2026-08-20 14:54:11 +00:00
|
|
|
|
className="w-full"
|
2026-03-01 17:40:25 +00:00
|
|
|
|
value={settings.entityName}
|
|
|
|
|
|
onChange={(e) => onEntityNameChange(e.target.value)}
|
2026-03-03 06:15:23 +00:00
|
|
|
|
placeholder={translate('::App.SqlQueryManager.EntityNamePlaceholder')}
|
2026-03-01 17:40:25 +00:00
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
{/* Table Name (readonly, auto-generated) */}
|
|
|
|
|
|
<div>
|
2026-03-03 07:34:25 +00:00
|
|
|
|
<label className="block text-sm font-medium mb-1">
|
2026-03-29 08:59:07 +00:00
|
|
|
|
{translate('::App.Listform.ListformField.TableName')}
|
2026-03-03 07:34:25 +00:00
|
|
|
|
</label>
|
2026-08-20 12:01:10 +00:00
|
|
|
|
<Input
|
2026-08-20 14:54:11 +00:00
|
|
|
|
size="sm"
|
2026-03-01 17:40:25 +00:00
|
|
|
|
type="text"
|
|
|
|
|
|
readOnly
|
2026-08-20 14:54:11 +00:00
|
|
|
|
className="w-full bg-gray-50 cursor-not-allowed"
|
2026-03-01 17:40:25 +00:00
|
|
|
|
value={isEditMode ? (initialTableData?.tableName ?? '') : settings.tableName}
|
2026-03-03 06:15:23 +00:00
|
|
|
|
placeholder={translate('::App.SqlQueryManager.TableNameAutoGenerated')}
|
2026-03-01 17:40:25 +00:00
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
2026-03-02 21:34:19 +00:00
|
|
|
|
{/* Warning: no Id column */}
|
2026-03-03 07:34:25 +00:00
|
|
|
|
{!isEditMode && !columns.some((c) => c.columnName.trim().toLowerCase() === 'id') && (
|
2026-03-21 14:42:36 +00:00
|
|
|
|
<div className="px-3 py-2 bg-amber-50 dark:bg-amber-900/20 border border-amber-300 dark:border-amber-700 rounded text-xs text-amber-700 dark:text-amber-300">
|
2026-08-14 14:03:34 +00:00
|
|
|
|
{translate('::App.DeveloperKitTableDesigner.ManualPkHint')}
|
2026-03-03 07:34:25 +00:00
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
2026-03-01 17:40:25 +00:00
|
|
|
|
</div>
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
// ── Step 2: Relationships ─────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
|
|
const renderRelationships = () => (
|
|
|
|
|
|
<div className="space-y-3">
|
|
|
|
|
|
{/* Loading indicator for edit mode */}
|
|
|
|
|
|
{fksLoading && (
|
|
|
|
|
|
<div className="flex items-center justify-center py-10 text-gray-500 text-sm gap-2">
|
2026-03-03 07:34:25 +00:00
|
|
|
|
<span className="animate-spin">◠</span>{' '}
|
|
|
|
|
|
{translate('::App.SqlQueryManager.LoadingFkConstraints')}
|
2026-03-01 17:40:25 +00:00
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
2026-03-03 07:34:25 +00:00
|
|
|
|
{!fksLoading && (
|
|
|
|
|
|
<>
|
|
|
|
|
|
{/* Header */}
|
|
|
|
|
|
<div className="flex items-center justify-between">
|
|
|
|
|
|
<span className="text-xs text-gray-500">
|
|
|
|
|
|
{relationships.length === 0
|
|
|
|
|
|
? translate('::App.SqlQueryManager.NoRelationshipsDefined')
|
|
|
|
|
|
: `${relationships.length} ${translate('::App.SqlQueryManager.Relationship')}`}
|
|
|
|
|
|
</span>
|
2026-06-25 22:55:44 +00:00
|
|
|
|
<Button
|
2026-03-03 07:34:25 +00:00
|
|
|
|
onClick={openAddFk}
|
2026-06-25 22:55:44 +00:00
|
|
|
|
size="xs"
|
|
|
|
|
|
variant="solid"
|
|
|
|
|
|
color="indigo-600"
|
2026-07-06 09:44:26 +00:00
|
|
|
|
className="flex items-center gap-2 !h-auto !px-2 !py-1 !rounded-lg hover:!bg-indigo-700 transition-colors"
|
2026-03-03 07:34:25 +00:00
|
|
|
|
>
|
|
|
|
|
|
<FaPlus className="w-2.5 h-2.5" />{' '}
|
|
|
|
|
|
{translate('::App.SqlQueryManager.AddRelationship')}
|
2026-06-25 22:55:44 +00:00
|
|
|
|
</Button>
|
2026-03-03 07:34:25 +00:00
|
|
|
|
</div>
|
2026-03-01 17:40:25 +00:00
|
|
|
|
|
2026-03-03 07:34:25 +00:00
|
|
|
|
{/* Empty state */}
|
|
|
|
|
|
{relationships.length === 0 && (
|
|
|
|
|
|
<div className="py-8 text-center border-2 border-dashed border-gray-200 dark:border-gray-700 rounded-xl bg-gray-50 dark:bg-gray-800/50">
|
|
|
|
|
|
<FaLink className="text-3xl mx-auto text-gray-300 dark:text-gray-600 mb-2" />
|
|
|
|
|
|
<p className="text-sm text-gray-500">
|
|
|
|
|
|
{translate('::App.SqlQueryManager.NoRelationshipsYet')}
|
|
|
|
|
|
</p>
|
|
|
|
|
|
<p className="text-xs text-gray-400 mt-1">
|
|
|
|
|
|
{translate('::App.SqlQueryManager.StepIsOptional')}
|
|
|
|
|
|
</p>
|
2026-03-01 17:40:25 +00:00
|
|
|
|
</div>
|
2026-03-03 07:34:25 +00:00
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
|
|
{/* Relationship cards */}
|
|
|
|
|
|
<div className="space-y-2 max-h-72 overflow-y-auto pr-1">
|
|
|
|
|
|
{relationships.map((rel) => (
|
|
|
|
|
|
<div
|
|
|
|
|
|
key={rel.id}
|
|
|
|
|
|
className="border border-slate-200 dark:border-gray-700 rounded-xl px-4 py-3 flex items-start gap-3 bg-white dark:bg-gray-800"
|
2026-03-01 17:40:25 +00:00
|
|
|
|
>
|
2026-03-03 07:34:25 +00:00
|
|
|
|
<div className="p-2 rounded-lg mt-0.5 flex-shrink-0 bg-indigo-50 dark:bg-indigo-900/20 text-indigo-600 dark:text-indigo-400">
|
|
|
|
|
|
<FaLink className="w-3 h-3" />
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className="flex-1 min-w-0">
|
|
|
|
|
|
<div className="flex items-center gap-2 flex-wrap">
|
|
|
|
|
|
<code className="text-sm font-semibold text-slate-900 dark:text-white">
|
|
|
|
|
|
[{rel.fkColumnName}]
|
|
|
|
|
|
</code>
|
|
|
|
|
|
<FaArrowRight className="w-3 h-3 text-gray-400 flex-shrink-0" />
|
|
|
|
|
|
<code className="text-sm font-semibold text-slate-900 dark:text-white">
|
|
|
|
|
|
[dbo].[{rel.referencedTable}].[{rel.referencedColumn || 'Id'}]
|
|
|
|
|
|
</code>
|
|
|
|
|
|
<span className="text-xs px-2 py-0.5 rounded-full bg-indigo-100 dark:bg-indigo-900/30 text-indigo-700 dark:text-indigo-300 font-medium">
|
|
|
|
|
|
{REL_TYPES.find((t) => t.value === rel.relationshipType)?.label}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className="flex items-center gap-3 mt-1 text-xs text-gray-500 flex-wrap">
|
|
|
|
|
|
<span>
|
|
|
|
|
|
ON DELETE: <strong>{rel.cascadeDelete}</strong>
|
|
|
|
|
|
</span>
|
|
|
|
|
|
<span>
|
|
|
|
|
|
ON UPDATE: <strong>{rel.cascadeUpdate}</strong>
|
|
|
|
|
|
</span>
|
|
|
|
|
|
{rel.isRequired && (
|
|
|
|
|
|
<span className="text-orange-600 dark:text-orange-400">
|
2026-08-14 14:03:34 +00:00
|
|
|
|
{translate('::App.Listform.ListformField.Required')}
|
2026-03-03 07:34:25 +00:00
|
|
|
|
</span>
|
|
|
|
|
|
)}
|
|
|
|
|
|
{rel.description && (
|
|
|
|
|
|
<span className="text-gray-400 italic">{rel.description}</span>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className="flex items-center gap-1 flex-shrink-0">
|
2026-06-25 22:55:44 +00:00
|
|
|
|
<Button
|
2026-03-03 07:34:25 +00:00
|
|
|
|
onClick={() => openEditFk(rel)}
|
2026-08-14 14:03:34 +00:00
|
|
|
|
title={translate('::App.Platform.Edit')}
|
2026-06-25 22:55:44 +00:00
|
|
|
|
size="xs"
|
|
|
|
|
|
variant="plain"
|
|
|
|
|
|
shape="circle"
|
|
|
|
|
|
icon={<FaEdit className="w-3.5 h-3.5" />}
|
|
|
|
|
|
className="!h-7 !w-7 !px-0 text-gray-400 hover:text-indigo-600 hover:!bg-indigo-50 dark:hover:!bg-indigo-900/20 transition-colors"
|
|
|
|
|
|
/>
|
|
|
|
|
|
<Button
|
2026-03-03 07:34:25 +00:00
|
|
|
|
onClick={() => deleteFk(rel.id)}
|
2026-08-14 14:03:34 +00:00
|
|
|
|
title={translate('::App.Platform.Delete')}
|
2026-06-25 22:55:44 +00:00
|
|
|
|
size="xs"
|
|
|
|
|
|
variant="plain"
|
|
|
|
|
|
shape="circle"
|
|
|
|
|
|
icon={<FaTrash className="w-3.5 h-3.5" />}
|
|
|
|
|
|
className="!h-7 !w-7 !px-0 text-gray-400 hover:text-red-600 hover:!bg-red-50 dark:hover:!bg-red-900/20 transition-colors"
|
|
|
|
|
|
/>
|
2026-03-03 07:34:25 +00:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
))}
|
2026-03-01 17:40:25 +00:00
|
|
|
|
</div>
|
2026-03-03 07:34:25 +00:00
|
|
|
|
</>
|
|
|
|
|
|
)}
|
2026-03-01 17:40:25 +00:00
|
|
|
|
|
|
|
|
|
|
{/* FK Add/Edit Modal */}
|
2026-03-03 07:34:25 +00:00
|
|
|
|
{fkModalOpen &&
|
|
|
|
|
|
createPortal(
|
|
|
|
|
|
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50 backdrop-blur-sm">
|
|
|
|
|
|
<div className="bg-white dark:bg-gray-800 rounded-2xl shadow-2xl w-full max-w-lg flex flex-col">
|
|
|
|
|
|
{/* Modal Header */}
|
|
|
|
|
|
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-200 dark:border-gray-700">
|
|
|
|
|
|
<h2 className="text-base font-bold text-gray-900 dark:text-white">
|
|
|
|
|
|
{editingFkId
|
|
|
|
|
|
? translate('::App.SqlQueryManager.EditRelationship')
|
|
|
|
|
|
: translate('::App.SqlQueryManager.AddNewRelationship')}
|
|
|
|
|
|
</h2>
|
2026-06-25 22:55:44 +00:00
|
|
|
|
<Button
|
2026-03-03 07:34:25 +00:00
|
|
|
|
onClick={() => setFkModalOpen(false)}
|
2026-06-25 22:55:44 +00:00
|
|
|
|
size="sm"
|
|
|
|
|
|
variant="plain"
|
|
|
|
|
|
shape="circle"
|
|
|
|
|
|
icon={<FaTimes className="w-4 h-4" />}
|
|
|
|
|
|
className="!h-8 !w-8 !px-0 text-gray-400 hover:text-gray-600 transition-colors"
|
|
|
|
|
|
/>
|
2026-03-01 17:40:25 +00:00
|
|
|
|
</div>
|
|
|
|
|
|
|
2026-03-03 07:34:25 +00:00
|
|
|
|
{/* Modal Body */}
|
|
|
|
|
|
<div className="flex-1 overflow-y-auto px-6 py-4 space-y-4">
|
|
|
|
|
|
{/* Relationship type */}
|
2026-03-01 17:40:25 +00:00
|
|
|
|
<div>
|
2026-03-03 07:34:25 +00:00
|
|
|
|
<label className="block text-xs font-semibold text-gray-700 dark:text-gray-300 mb-2">
|
|
|
|
|
|
{translate('::App.SqlQueryManager.RelationshipType')}
|
2026-03-01 17:40:25 +00:00
|
|
|
|
</label>
|
2026-03-03 07:34:25 +00:00
|
|
|
|
<div className="flex gap-2">
|
|
|
|
|
|
{REL_TYPES.map((t) => (
|
2026-06-25 22:55:44 +00:00
|
|
|
|
<Button
|
2026-03-03 07:34:25 +00:00
|
|
|
|
key={t.value}
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
onClick={() => setFkForm((f) => ({ ...f, relationshipType: t.value }))}
|
2026-06-25 22:55:44 +00:00
|
|
|
|
variant="plain"
|
|
|
|
|
|
shape="none"
|
|
|
|
|
|
className={`flex-1 !h-auto !rounded-lg border !py-2 !px-3 text-sm font-medium transition-all ${
|
2026-03-03 07:34:25 +00:00
|
|
|
|
fkForm.relationshipType === t.value
|
|
|
|
|
|
? 'bg-indigo-600 border-indigo-600 text-white'
|
|
|
|
|
|
: 'bg-white dark:bg-gray-700 border-gray-200 dark:border-gray-600 text-gray-600 dark:text-gray-300 hover:border-indigo-300'
|
|
|
|
|
|
}`}
|
|
|
|
|
|
>
|
|
|
|
|
|
{t.label}
|
2026-08-14 11:32:31 +00:00
|
|
|
|
<span className="block text-xs opacity-70">{translate('::' + t.desc)}</span>
|
2026-06-25 22:55:44 +00:00
|
|
|
|
</Button>
|
2026-03-03 07:34:25 +00:00
|
|
|
|
))}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
{/* FK column / Referenced table & column */}
|
|
|
|
|
|
<div className="grid grid-cols-2 gap-3">
|
|
|
|
|
|
<div>
|
|
|
|
|
|
<label className="block text-xs font-semibold text-gray-700 dark:text-gray-300 mb-1.5">
|
|
|
|
|
|
{translate('::App.SqlQueryManager.FkColumnInThisTable')}
|
|
|
|
|
|
</label>
|
2026-08-20 14:54:11 +00:00
|
|
|
|
<Select
|
|
|
|
|
|
size="sm"
|
|
|
|
|
|
className="w-full"
|
|
|
|
|
|
isClearable
|
|
|
|
|
|
placeholder={translate('::App.Platform.Select')}
|
|
|
|
|
|
menuPortalTarget={document.body}
|
|
|
|
|
|
options={fkColumnOptions}
|
|
|
|
|
|
value={fkColumnOptions.filter(
|
|
|
|
|
|
(option) => option.value === fkForm.fkColumnName,
|
|
|
|
|
|
)}
|
|
|
|
|
|
onChange={(option) =>
|
|
|
|
|
|
setFkForm((f) => ({ ...f, fkColumnName: option?.value ?? '' }))
|
|
|
|
|
|
}
|
|
|
|
|
|
/>
|
2026-03-03 07:34:25 +00:00
|
|
|
|
</div>
|
|
|
|
|
|
<div>
|
|
|
|
|
|
<label className="block text-xs font-semibold text-gray-700 dark:text-gray-300 mb-1.5">
|
|
|
|
|
|
{translate('::App.SqlQueryManager.TargetTable')}
|
|
|
|
|
|
</label>
|
2026-08-20 14:54:11 +00:00
|
|
|
|
<Select
|
|
|
|
|
|
size="sm"
|
|
|
|
|
|
className="w-full"
|
|
|
|
|
|
isClearable
|
|
|
|
|
|
placeholder={translate('::App.Platform.Select')}
|
|
|
|
|
|
menuPortalTarget={document.body}
|
|
|
|
|
|
options={referencedTableOptions}
|
|
|
|
|
|
value={referencedTableOptions.filter(
|
|
|
|
|
|
(option) => option.value === fkForm.referencedTable,
|
|
|
|
|
|
)}
|
|
|
|
|
|
onChange={(option) => {
|
|
|
|
|
|
const val = option?.value ?? ''
|
2026-03-03 07:34:25 +00:00
|
|
|
|
setFkForm((f) => ({ ...f, referencedTable: val, referencedColumn: '' }))
|
|
|
|
|
|
loadTargetColumns(val)
|
|
|
|
|
|
}}
|
2026-08-20 14:54:11 +00:00
|
|
|
|
/>
|
2026-03-03 07:34:25 +00:00
|
|
|
|
</div>
|
2026-03-01 17:40:25 +00:00
|
|
|
|
</div>
|
2026-03-03 07:34:25 +00:00
|
|
|
|
|
2026-03-01 17:40:25 +00:00
|
|
|
|
<div>
|
|
|
|
|
|
<label className="block text-xs font-semibold text-gray-700 dark:text-gray-300 mb-1.5">
|
2026-03-03 07:34:25 +00:00
|
|
|
|
{translate('::App.SqlQueryManager.TargetColumn')}
|
2026-08-19 07:50:33 +00:00
|
|
|
|
{targetColsLoading
|
|
|
|
|
|
? ` — ${translate('::App.Platform.LoadingWithThreeDot')}`
|
|
|
|
|
|
: ''}
|
2026-03-01 17:40:25 +00:00
|
|
|
|
</label>
|
2026-08-20 14:54:11 +00:00
|
|
|
|
<Select
|
|
|
|
|
|
size="sm"
|
|
|
|
|
|
className="w-full"
|
|
|
|
|
|
isClearable
|
|
|
|
|
|
isDisabled={targetColsLoading || targetTableColumns.length === 0}
|
|
|
|
|
|
placeholder={translate('::App.SqlQueryManager.SelectTargetTableFirst')}
|
|
|
|
|
|
menuPortalTarget={document.body}
|
|
|
|
|
|
options={referencedColumnOptions}
|
|
|
|
|
|
value={referencedColumnOptions.filter(
|
|
|
|
|
|
(option) => option.value === fkForm.referencedColumn,
|
|
|
|
|
|
)}
|
|
|
|
|
|
onChange={(option) =>
|
|
|
|
|
|
setFkForm((f) => ({ ...f, referencedColumn: option?.value ?? '' }))
|
|
|
|
|
|
}
|
|
|
|
|
|
/>
|
2026-06-25 22:55:44 +00:00
|
|
|
|
{fkForm.referencedTable &&
|
|
|
|
|
|
!targetColsLoading &&
|
|
|
|
|
|
targetTableKeyColumns.length === 0 && (
|
|
|
|
|
|
<p className="mt-1 text-xs text-amber-600 dark:text-amber-400">
|
2026-08-14 14:03:34 +00:00
|
|
|
|
{translate('::App.DeveloperKitTableDesigner.NoKeyColumnInTarget')}
|
2026-06-25 22:55:44 +00:00
|
|
|
|
</p>
|
|
|
|
|
|
)}
|
2026-03-01 17:40:25 +00:00
|
|
|
|
</div>
|
|
|
|
|
|
|
2026-03-03 07:34:25 +00:00
|
|
|
|
{/* Cascade */}
|
|
|
|
|
|
<div className="grid grid-cols-2 gap-3">
|
|
|
|
|
|
<div>
|
|
|
|
|
|
<label className="block text-xs font-semibold text-gray-700 dark:text-gray-300 mb-1.5">
|
|
|
|
|
|
{translate('::App.SqlQueryManager.CascadeUpdate')}
|
|
|
|
|
|
</label>
|
2026-08-20 14:54:11 +00:00
|
|
|
|
<Select
|
|
|
|
|
|
size="sm"
|
|
|
|
|
|
className="w-full"
|
|
|
|
|
|
menuPortalTarget={document.body}
|
|
|
|
|
|
options={cascadeOptions}
|
|
|
|
|
|
value={cascadeOptions.filter(
|
|
|
|
|
|
(option) => option.value === fkForm.cascadeUpdate,
|
|
|
|
|
|
)}
|
|
|
|
|
|
onChange={(option) =>
|
|
|
|
|
|
option &&
|
2026-03-03 07:34:25 +00:00
|
|
|
|
setFkForm((f) => ({
|
|
|
|
|
|
...f,
|
2026-08-20 14:54:11 +00:00
|
|
|
|
cascadeUpdate: option.value as CascadeBehavior,
|
2026-03-03 07:34:25 +00:00
|
|
|
|
}))
|
|
|
|
|
|
}
|
2026-08-20 14:54:11 +00:00
|
|
|
|
/>
|
2026-03-03 07:34:25 +00:00
|
|
|
|
</div>
|
|
|
|
|
|
<div>
|
|
|
|
|
|
<label className="block text-xs font-semibold text-gray-700 dark:text-gray-300 mb-1.5">
|
|
|
|
|
|
{translate('::App.SqlQueryManager.CascadeDelete')}
|
|
|
|
|
|
</label>
|
2026-08-20 14:54:11 +00:00
|
|
|
|
<Select
|
|
|
|
|
|
size="sm"
|
|
|
|
|
|
className="w-full"
|
|
|
|
|
|
menuPortalTarget={document.body}
|
|
|
|
|
|
options={cascadeOptions}
|
|
|
|
|
|
value={cascadeOptions.filter(
|
|
|
|
|
|
(option) => option.value === fkForm.cascadeDelete,
|
|
|
|
|
|
)}
|
|
|
|
|
|
onChange={(option) =>
|
|
|
|
|
|
option &&
|
2026-03-03 07:34:25 +00:00
|
|
|
|
setFkForm((f) => ({
|
|
|
|
|
|
...f,
|
2026-08-20 14:54:11 +00:00
|
|
|
|
cascadeDelete: option.value as CascadeBehavior,
|
2026-03-03 07:34:25 +00:00
|
|
|
|
}))
|
|
|
|
|
|
}
|
2026-08-20 14:54:11 +00:00
|
|
|
|
/>
|
2026-03-03 07:34:25 +00:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
2026-03-01 17:40:25 +00:00
|
|
|
|
|
2026-03-03 07:34:25 +00:00
|
|
|
|
{/* Options */}
|
|
|
|
|
|
<div className="flex items-center gap-6">
|
|
|
|
|
|
<label className="flex items-center gap-2 cursor-pointer">
|
2026-08-20 12:01:10 +00:00
|
|
|
|
<Input
|
2026-08-20 14:54:11 +00:00
|
|
|
|
size="sm"
|
2026-03-03 07:34:25 +00:00
|
|
|
|
type="checkbox"
|
|
|
|
|
|
checked={fkForm.isRequired}
|
|
|
|
|
|
onChange={(e) => setFkForm((f) => ({ ...f, isRequired: e.target.checked }))}
|
2026-08-20 14:54:11 +00:00
|
|
|
|
className="w-4 text-indigo-600"
|
2026-03-03 07:34:25 +00:00
|
|
|
|
/>
|
|
|
|
|
|
<span className="text-sm text-gray-700 dark:text-gray-300">
|
2026-08-14 14:03:34 +00:00
|
|
|
|
{translate('::App.Listform.ListformField.Required')}
|
2026-03-03 07:34:25 +00:00
|
|
|
|
</span>
|
2026-03-01 17:40:25 +00:00
|
|
|
|
</label>
|
|
|
|
|
|
</div>
|
2026-03-03 07:34:25 +00:00
|
|
|
|
|
2026-03-01 17:40:25 +00:00
|
|
|
|
<div>
|
|
|
|
|
|
<label className="block text-xs font-semibold text-gray-700 dark:text-gray-300 mb-1.5">
|
2026-08-14 14:03:34 +00:00
|
|
|
|
{translate('::App.Listform.ListformField.Description')}
|
2026-03-01 17:40:25 +00:00
|
|
|
|
</label>
|
2026-03-03 07:34:25 +00:00
|
|
|
|
<textarea
|
|
|
|
|
|
value={fkForm.description}
|
|
|
|
|
|
onChange={(e) => setFkForm((f) => ({ ...f, description: e.target.value }))}
|
|
|
|
|
|
rows={2}
|
|
|
|
|
|
placeholder={translate('::App.SqlQueryManager.OptionalDescriptionPlaceholder')}
|
|
|
|
|
|
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg text-sm dark:bg-gray-700 dark:text-white focus:ring-2 focus:ring-indigo-500 resize-none"
|
2026-03-01 17:40:25 +00:00
|
|
|
|
/>
|
2026-03-03 07:34:25 +00:00
|
|
|
|
</div>
|
2026-03-01 17:40:25 +00:00
|
|
|
|
</div>
|
|
|
|
|
|
|
2026-03-03 07:34:25 +00:00
|
|
|
|
{/* Modal Footer */}
|
|
|
|
|
|
<div className="flex items-center justify-end gap-2 px-6 py-4 border-t border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800/50 rounded-b-2xl">
|
2026-06-25 22:55:44 +00:00
|
|
|
|
<Button
|
2026-03-03 07:34:25 +00:00
|
|
|
|
onClick={() => setFkModalOpen(false)}
|
2026-06-25 22:55:44 +00:00
|
|
|
|
size="sm"
|
|
|
|
|
|
variant="plain"
|
|
|
|
|
|
className="text-gray-600 dark:text-gray-300 hover:!bg-gray-100 dark:hover:!bg-gray-700 !rounded-lg transition-colors"
|
2026-03-03 07:34:25 +00:00
|
|
|
|
>
|
2026-08-14 14:03:34 +00:00
|
|
|
|
{translate('::App.Platform.Cancel')}
|
2026-06-25 22:55:44 +00:00
|
|
|
|
</Button>
|
|
|
|
|
|
<Button
|
2026-03-03 07:34:25 +00:00
|
|
|
|
onClick={saveFk}
|
2026-03-18 09:00:11 +00:00
|
|
|
|
disabled={
|
|
|
|
|
|
!fkForm.fkColumnName.trim() ||
|
|
|
|
|
|
!fkForm.referencedTable.trim() ||
|
|
|
|
|
|
!fkForm.referencedColumn.trim() ||
|
|
|
|
|
|
!targetTableKeyColumns.some(
|
|
|
|
|
|
(c) => c.toLowerCase() === fkForm.referencedColumn.trim().toLowerCase(),
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
2026-06-25 22:55:44 +00:00
|
|
|
|
size="sm"
|
|
|
|
|
|
variant="solid"
|
|
|
|
|
|
color="indigo-600"
|
|
|
|
|
|
className="hover:!bg-indigo-700 disabled:opacity-50 !rounded-lg transition-colors"
|
2026-03-03 07:34:25 +00:00
|
|
|
|
>
|
2026-08-14 14:03:34 +00:00
|
|
|
|
{translate('::App.Platform.Save')}
|
2026-06-25 22:55:44 +00:00
|
|
|
|
</Button>
|
2026-03-01 17:40:25 +00:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
2026-03-03 07:34:25 +00:00
|
|
|
|
</div>,
|
|
|
|
|
|
document.body,
|
|
|
|
|
|
)}
|
2026-03-01 17:40:25 +00:00
|
|
|
|
</div>
|
|
|
|
|
|
)
|
|
|
|
|
|
|
2026-03-09 13:35:00 +00:00
|
|
|
|
// ── Step 3: Index / Key ────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
|
|
const INDEX_TYPE_STYLES: Record<IndexType, { icon: string; badge: string }> = {
|
|
|
|
|
|
PrimaryKey: {
|
|
|
|
|
|
icon: 'bg-yellow-50 dark:bg-yellow-900/20 text-yellow-600 dark:text-yellow-400',
|
|
|
|
|
|
badge: 'bg-yellow-100 dark:bg-yellow-900/30 text-yellow-700 dark:text-yellow-300',
|
|
|
|
|
|
},
|
|
|
|
|
|
UniqueKey: {
|
|
|
|
|
|
icon: 'bg-green-50 dark:bg-green-900/20 text-green-600 dark:text-green-400',
|
|
|
|
|
|
badge: 'bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-300',
|
|
|
|
|
|
},
|
|
|
|
|
|
Index: {
|
|
|
|
|
|
icon: 'bg-blue-50 dark:bg-blue-900/20 text-blue-600 dark:text-blue-400',
|
|
|
|
|
|
badge: 'bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-300',
|
|
|
|
|
|
},
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const renderIndexes = () => (
|
|
|
|
|
|
<div className="space-y-3">
|
|
|
|
|
|
{indexesLoading && (
|
|
|
|
|
|
<div className="flex items-center justify-center py-10 text-gray-500 text-sm gap-2">
|
2026-08-19 07:50:33 +00:00
|
|
|
|
<span className="animate-spin">◠</span>{' '}
|
|
|
|
|
|
{translate('::App.Platform.LoadingWithThreeDot')}
|
2026-03-09 13:35:00 +00:00
|
|
|
|
</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>
|
2026-06-25 22:55:44 +00:00
|
|
|
|
<Button
|
2026-03-09 13:35:00 +00:00
|
|
|
|
onClick={openAddIndex}
|
2026-06-25 22:55:44 +00:00
|
|
|
|
size="xs"
|
|
|
|
|
|
variant="solid"
|
|
|
|
|
|
color="indigo-600"
|
2026-07-06 09:44:26 +00:00
|
|
|
|
className="flex items-center gap-2 !h-auto !px-2 !py-1 !rounded-lg hover:!bg-indigo-700 transition-colors"
|
2026-03-09 13:35:00 +00:00
|
|
|
|
>
|
|
|
|
|
|
<FaPlus className="w-2.5 h-2.5" /> {translate('::App.SqlQueryManager.AddIndexKey')}
|
2026-06-25 22:55:44 +00:00
|
|
|
|
</Button>
|
2026-03-09 13:35:00 +00:00
|
|
|
|
</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" />
|
2026-06-25 22:55:44 +00:00
|
|
|
|
<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>
|
2026-03-09 13:35:00 +00:00
|
|
|
|
</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">
|
2026-06-25 22:55:44 +00:00
|
|
|
|
<Button
|
2026-03-09 13:35:00 +00:00
|
|
|
|
onClick={() => openEditIndex(idx)}
|
2026-08-14 14:03:34 +00:00
|
|
|
|
title={translate('::App.Platform.Edit')}
|
2026-06-25 22:55:44 +00:00
|
|
|
|
size="xs"
|
|
|
|
|
|
variant="plain"
|
|
|
|
|
|
shape="circle"
|
|
|
|
|
|
icon={<FaEdit className="w-3.5 h-3.5" />}
|
|
|
|
|
|
className="!h-7 !w-7 !px-0 text-gray-400 hover:text-indigo-600 hover:!bg-indigo-50 dark:hover:!bg-indigo-900/20 transition-colors"
|
|
|
|
|
|
/>
|
|
|
|
|
|
<Button
|
2026-03-09 13:35:00 +00:00
|
|
|
|
onClick={() => deleteIndex(idx.id)}
|
2026-08-14 14:03:34 +00:00
|
|
|
|
title={translate('::App.Platform.Delete')}
|
2026-06-25 22:55:44 +00:00
|
|
|
|
size="xs"
|
|
|
|
|
|
variant="plain"
|
|
|
|
|
|
shape="circle"
|
|
|
|
|
|
icon={<FaTrash className="w-3.5 h-3.5" />}
|
|
|
|
|
|
className="!h-7 !w-7 !px-0 text-gray-400 hover:text-red-600 hover:!bg-red-50 dark:hover:!bg-red-900/20 transition-colors"
|
|
|
|
|
|
/>
|
2026-03-09 13:35:00 +00:00
|
|
|
|
</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">
|
2026-06-25 22:55:44 +00:00
|
|
|
|
{editingIndexId
|
|
|
|
|
|
? translate('::App.SqlQueryManager.EditIndexKey')
|
|
|
|
|
|
: translate('::App.SqlQueryManager.AddNewIndexKey')}
|
2026-03-09 13:35:00 +00:00
|
|
|
|
</h2>
|
2026-06-25 22:55:44 +00:00
|
|
|
|
<Button
|
2026-03-09 13:35:00 +00:00
|
|
|
|
onClick={() => setIndexModalOpen(false)}
|
2026-06-25 22:55:44 +00:00
|
|
|
|
size="sm"
|
|
|
|
|
|
variant="plain"
|
|
|
|
|
|
shape="circle"
|
|
|
|
|
|
icon={<FaTimes className="w-4 h-4" />}
|
|
|
|
|
|
className="!h-8 !w-8 !px-0 text-gray-400 hover:text-gray-600 transition-colors"
|
|
|
|
|
|
/>
|
2026-03-09 13:35:00 +00:00
|
|
|
|
</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) => (
|
2026-06-25 22:55:44 +00:00
|
|
|
|
<Button
|
2026-03-09 13:35:00 +00:00
|
|
|
|
key={t.value}
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
onClick={() =>
|
|
|
|
|
|
setIndexForm((f) => ({
|
|
|
|
|
|
...f,
|
|
|
|
|
|
indexType: t.value,
|
|
|
|
|
|
indexName: buildIndexName(t.value, f.columns),
|
|
|
|
|
|
}))
|
|
|
|
|
|
}
|
2026-06-25 22:55:44 +00:00
|
|
|
|
variant="plain"
|
|
|
|
|
|
shape="none"
|
|
|
|
|
|
className={`flex-1 !h-auto !rounded-lg border !py-2 !px-3 text-sm font-medium transition-all ${
|
2026-03-09 13:35:00 +00:00
|
|
|
|
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>
|
2026-06-25 22:55:44 +00:00
|
|
|
|
</Button>
|
2026-03-09 13:35:00 +00:00
|
|
|
|
))}
|
|
|
|
|
|
</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>
|
2026-08-20 12:01:10 +00:00
|
|
|
|
<Input
|
2026-08-20 14:54:11 +00:00
|
|
|
|
size="sm"
|
2026-03-09 13:35:00 +00:00
|
|
|
|
type="text"
|
|
|
|
|
|
value={indexForm.indexName}
|
|
|
|
|
|
onChange={(e) => setIndexForm((f) => ({ ...f, indexName: e.target.value }))}
|
2026-06-25 22:55:44 +00:00
|
|
|
|
placeholder={
|
|
|
|
|
|
buildIndexName(indexForm.indexType, indexForm.columns) || `PK_EntityName_Id`
|
|
|
|
|
|
}
|
2026-08-20 14:54:11 +00:00
|
|
|
|
className="w-full"
|
2026-03-09 13:35:00 +00:00
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
{/* Clustered */}
|
|
|
|
|
|
<div className="flex items-center gap-3">
|
|
|
|
|
|
<label className="flex items-center gap-2 cursor-pointer">
|
2026-08-20 12:01:10 +00:00
|
|
|
|
<Input
|
2026-08-20 14:54:11 +00:00
|
|
|
|
size="sm"
|
2026-03-09 13:35:00 +00:00
|
|
|
|
type="checkbox"
|
|
|
|
|
|
checked={indexForm.isClustered}
|
|
|
|
|
|
onChange={(e) =>
|
|
|
|
|
|
setIndexForm((f) => ({ ...f, isClustered: e.target.checked }))
|
|
|
|
|
|
}
|
2026-08-20 14:54:11 +00:00
|
|
|
|
className="w-4 text-indigo-600"
|
2026-03-09 13:35:00 +00:00
|
|
|
|
/>
|
|
|
|
|
|
<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">
|
2026-08-14 14:03:34 +00:00
|
|
|
|
{translate('::App.Platform.Columns')}
|
2026-03-09 13:35:00 +00:00
|
|
|
|
</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" />
|
2026-06-25 22:55:44 +00:00
|
|
|
|
<div className="col-span-7">
|
|
|
|
|
|
{translate('::App.Listform.ListformField.Column')}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className="col-span-4">
|
|
|
|
|
|
{translate('::App.SqlQueryManager.SortOrder')}
|
|
|
|
|
|
</div>
|
2026-03-09 13:35:00 +00:00
|
|
|
|
</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">
|
2026-08-20 12:01:10 +00:00
|
|
|
|
<Input
|
2026-08-20 14:54:11 +00:00
|
|
|
|
size="sm"
|
2026-03-09 13:35:00 +00:00
|
|
|
|
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),
|
|
|
|
|
|
}
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
}}
|
2026-08-20 14:54:11 +00:00
|
|
|
|
className="w-4 text-indigo-600"
|
2026-03-09 13:35:00 +00:00
|
|
|
|
/>
|
|
|
|
|
|
</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 && (
|
2026-08-20 14:54:11 +00:00
|
|
|
|
<Select
|
|
|
|
|
|
size="sm"
|
|
|
|
|
|
className="w-full"
|
|
|
|
|
|
menuPortalTarget={document.body}
|
|
|
|
|
|
options={indexOrderOptions}
|
|
|
|
|
|
value={indexOrderOptions.filter(
|
|
|
|
|
|
(option) => option.value === existing.order,
|
|
|
|
|
|
)}
|
|
|
|
|
|
onChange={(option) =>
|
|
|
|
|
|
option &&
|
2026-03-09 13:35:00 +00:00
|
|
|
|
setIndexForm((f) => ({
|
|
|
|
|
|
...f,
|
|
|
|
|
|
columns: f.columns.map((ic) =>
|
|
|
|
|
|
ic.columnName === col.columnName
|
2026-08-20 14:54:11 +00:00
|
|
|
|
? { ...ic, order: option.value }
|
2026-03-09 13:35:00 +00:00
|
|
|
|
: ic,
|
|
|
|
|
|
),
|
|
|
|
|
|
}))
|
|
|
|
|
|
}
|
2026-08-20 14:54:11 +00:00
|
|
|
|
/>
|
2026-03-09 13:35:00 +00:00
|
|
|
|
)}
|
|
|
|
|
|
</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>
|
|
|
|
|
|
|
|
|
|
|
|
<div>
|
|
|
|
|
|
<label className="block text-xs font-semibold text-gray-700 dark:text-gray-300 mb-1.5">
|
2026-08-14 14:03:34 +00:00
|
|
|
|
{translate('::App.Listform.ListformField.Description')}
|
2026-03-09 13:35:00 +00:00
|
|
|
|
</label>
|
|
|
|
|
|
<textarea
|
|
|
|
|
|
value={indexForm.description}
|
2026-06-25 22:55:44 +00:00
|
|
|
|
onChange={(e) => setIndexForm((f) => ({ ...f, description: e.target.value }))}
|
2026-03-09 13:35:00 +00:00
|
|
|
|
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">
|
2026-06-25 22:55:44 +00:00
|
|
|
|
<Button
|
2026-03-09 13:35:00 +00:00
|
|
|
|
onClick={() => setIndexModalOpen(false)}
|
2026-06-25 22:55:44 +00:00
|
|
|
|
size="sm"
|
|
|
|
|
|
variant="plain"
|
|
|
|
|
|
className="text-gray-600 dark:text-gray-300 hover:!bg-gray-100 dark:hover:!bg-gray-700 !rounded-lg transition-colors"
|
2026-03-09 13:35:00 +00:00
|
|
|
|
>
|
2026-08-14 14:03:34 +00:00
|
|
|
|
{translate('::App.Platform.Cancel')}
|
2026-06-25 22:55:44 +00:00
|
|
|
|
</Button>
|
|
|
|
|
|
<Button
|
2026-03-09 13:35:00 +00:00
|
|
|
|
onClick={saveIndex}
|
|
|
|
|
|
disabled={!indexForm.indexName.trim() || indexForm.columns.length === 0}
|
2026-06-25 22:55:44 +00:00
|
|
|
|
size="sm"
|
|
|
|
|
|
variant="solid"
|
|
|
|
|
|
color="indigo-600"
|
|
|
|
|
|
className="hover:!bg-indigo-700 disabled:opacity-50 !rounded-lg transition-colors"
|
2026-03-09 13:35:00 +00:00
|
|
|
|
>
|
2026-08-14 14:03:34 +00:00
|
|
|
|
{translate('::App.Platform.Save')}
|
2026-06-25 22:55:44 +00:00
|
|
|
|
</Button>
|
2026-03-09 13:35:00 +00:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>,
|
|
|
|
|
|
document.body,
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)
|
|
|
|
|
|
|
2026-08-17 14:26:51 +00:00
|
|
|
|
// ── Step: CRUD Endpoints ───────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
|
|
const crudEntityName = toEntityName(
|
|
|
|
|
|
settings.tableName || initialTableData?.tableName || settings.entityName || '',
|
|
|
|
|
|
)
|
|
|
|
|
|
const allCrudSelected = crudOperations.length === CRUD_OPERATION_TYPES.length
|
|
|
|
|
|
|
|
|
|
|
|
const renderCrudEndpoints = () => (
|
|
|
|
|
|
<div className="flex flex-col gap-4">
|
|
|
|
|
|
<label className="flex items-start gap-3 cursor-pointer rounded-lg border p-3 dark:border-gray-700">
|
|
|
|
|
|
<Checkbox checked={generateCrud} onChange={(checked) => setGenerateCrud(checked)} />
|
|
|
|
|
|
<span>
|
|
|
|
|
|
<span className="block font-medium">
|
|
|
|
|
|
{translate('::App.SqlQueryManager.GenerateCrudEndpointsAfterDeploy')}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
<span className="block text-xs text-gray-500 dark:text-gray-400">
|
|
|
|
|
|
{translate('::App.SqlQueryManager.GenerateCrudEndpointsHint')}
|
|
|
|
|
|
{crudEntityName ? ` — ${crudEntityName}` : ''}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
</span>
|
|
|
|
|
|
</label>
|
|
|
|
|
|
|
|
|
|
|
|
<div className={generateCrud ? '' : 'opacity-40 pointer-events-none'}>
|
|
|
|
|
|
<div className="mb-2 flex items-center justify-between">
|
|
|
|
|
|
<span className="text-sm font-medium">
|
|
|
|
|
|
{translate('::App.SqlQueryManager.SelectCrudOperations')}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
<Button
|
|
|
|
|
|
size="xs"
|
|
|
|
|
|
variant="plain"
|
|
|
|
|
|
className="!h-auto !px-1 !py-0 text-xs text-blue-500 hover:underline"
|
2026-08-19 07:50:33 +00:00
|
|
|
|
onClick={() => setCrudOperations(allCrudSelected ? [] : [...CRUD_OPERATION_TYPES])}
|
2026-08-17 14:26:51 +00:00
|
|
|
|
>
|
|
|
|
|
|
{allCrudSelected
|
|
|
|
|
|
? translate('::App.SqlQueryManager.ClearSelection')
|
|
|
|
|
|
: translate('::App.Platform.SelectAll')}
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div className="flex flex-col gap-2">
|
|
|
|
|
|
{CRUD_OPERATION_TYPES.map((operation) => {
|
|
|
|
|
|
const meta = getOperationMeta(operation)
|
|
|
|
|
|
const checked = crudOperations.includes(operation)
|
|
|
|
|
|
return (
|
|
|
|
|
|
<label
|
|
|
|
|
|
key={operation}
|
|
|
|
|
|
className="flex items-center gap-3 cursor-pointer rounded-lg border px-3 py-2 dark:border-gray-700"
|
|
|
|
|
|
>
|
|
|
|
|
|
<Checkbox checked={checked} onChange={() => toggleCrudOperation(operation)} />
|
|
|
|
|
|
<span className={meta.color}>{meta.icon}</span>
|
|
|
|
|
|
<span className="text-sm font-medium">{operation}</span>
|
|
|
|
|
|
<span className="ml-auto text-xs text-gray-400">
|
|
|
|
|
|
{translate(`::App.SqlQueryManager.CrudOperation${operation}`)}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
</label>
|
|
|
|
|
|
)
|
|
|
|
|
|
})}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
{generateCrud && crudOperations.length === 0 && (
|
|
|
|
|
|
<p className="mt-2 mb-0 text-xs text-amber-600 dark:text-amber-400">
|
|
|
|
|
|
{translate('::App.SqlQueryManager.SelectAtLeastOneCrudOperation')}
|
|
|
|
|
|
</p>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<p className="mb-0 text-xs text-gray-500 dark:text-gray-400">
|
|
|
|
|
|
{translate('::App.SqlQueryManager.CrudEndpointsInactiveNote')}
|
|
|
|
|
|
</p>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
// ── Step: T-SQL Preview ────────────────────────────────────────────────────
|
2026-03-01 17:40:25 +00:00
|
|
|
|
|
|
|
|
|
|
const renderSqlPreview = () => (
|
|
|
|
|
|
<div className="flex flex-col gap-3">
|
|
|
|
|
|
<div className="flex items-center justify-between">
|
|
|
|
|
|
<p className="text-sm text-gray-600 dark:text-gray-400">
|
2026-03-03 06:15:23 +00:00
|
|
|
|
{translate('::App.SqlQueryManager.GeneratedSqlDescription')}
|
2026-03-01 17:40:25 +00:00
|
|
|
|
</p>
|
2026-06-25 22:55:44 +00:00
|
|
|
|
<Button
|
|
|
|
|
|
size="xs"
|
|
|
|
|
|
variant="plain"
|
|
|
|
|
|
className="!h-auto !px-1 !py-0 text-xs text-blue-500 hover:underline"
|
2026-03-01 17:40:25 +00:00
|
|
|
|
onClick={() => navigator.clipboard.writeText(generatedSql)}
|
|
|
|
|
|
>
|
2026-08-14 14:03:34 +00:00
|
|
|
|
{translate('::App.Platform.Copy')}
|
2026-06-25 22:55:44 +00:00
|
|
|
|
</Button>
|
2026-03-01 17:40:25 +00:00
|
|
|
|
</div>
|
2026-03-02 21:34:19 +00:00
|
|
|
|
<pre className="bg-gray-900 text-green-300 rounded-lg p-4 text-xs overflow-auto max-h-96 leading-relaxed whitespace-pre">
|
2026-03-01 17:40:25 +00:00
|
|
|
|
{generatedSql}
|
|
|
|
|
|
</pre>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
// ── Render ─────────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
|
|
return (
|
2026-07-16 14:01:18 +00:00
|
|
|
|
<Dialog isOpen={isOpen} onClose={handleClose} onRequestClose={handleClose} width={1200}>
|
2026-06-08 11:57:43 +00:00
|
|
|
|
<Dialog.Header className="flex flex-col gap-2 -mt-5 p-2 ">
|
2026-03-01 17:40:25 +00:00
|
|
|
|
{/* Header */}
|
2026-05-13 11:31:15 +00:00
|
|
|
|
<div className="flex items-center gap-3 border-b pb-3 flex-shrink-0">
|
2026-03-01 17:40:25 +00:00
|
|
|
|
<FaTable className="text-2xl text-blue-500" />
|
|
|
|
|
|
<div>
|
|
|
|
|
|
<h5 className="font-bold">
|
|
|
|
|
|
{isEditMode
|
2026-03-03 06:15:23 +00:00
|
|
|
|
? `${translate('::App.SqlQueryManager.EditTable')} — ${initialTableData?.tableName}`
|
|
|
|
|
|
: translate('::App.SqlQueryManager.TableDesigner')}
|
2026-03-01 17:40:25 +00:00
|
|
|
|
</h5>
|
|
|
|
|
|
<p className="text-xs text-gray-500">
|
|
|
|
|
|
{isEditMode
|
2026-03-03 06:15:23 +00:00
|
|
|
|
? translate('::App.SqlQueryManager.EditModeDescription')
|
|
|
|
|
|
: translate('::App.SqlQueryManager.NewModeDescription')}
|
2026-03-01 17:40:25 +00:00
|
|
|
|
</p>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
{/* Steps */}
|
2026-05-13 11:31:15 +00:00
|
|
|
|
<div className="flex-shrink-0">{renderStepIndicator()}</div>
|
2026-06-08 08:52:30 +00:00
|
|
|
|
</Dialog.Header>
|
2026-03-01 17:40:25 +00:00
|
|
|
|
|
2026-06-08 08:52:30 +00:00
|
|
|
|
<Dialog.Body className="flex flex-col gap-2">
|
2026-03-01 17:40:25 +00:00
|
|
|
|
{/* Content */}
|
2026-05-13 11:31:15 +00:00
|
|
|
|
<StepContentWrapper>
|
2026-08-17 14:26:51 +00:00
|
|
|
|
{currentStepKey === 'columns' && renderColumnDesigner()}
|
|
|
|
|
|
{currentStepKey === 'settings' && renderEntitySettings()}
|
|
|
|
|
|
{currentStepKey === 'indexes' && renderIndexes()}
|
|
|
|
|
|
{currentStepKey === 'relationships' && renderRelationships()}
|
|
|
|
|
|
{currentStepKey === 'crud' && renderCrudEndpoints()}
|
|
|
|
|
|
{currentStepKey === 'sql' && renderSqlPreview()}
|
2026-05-13 11:31:15 +00:00
|
|
|
|
</StepContentWrapper>
|
|
|
|
|
|
</Dialog.Body>
|
|
|
|
|
|
|
|
|
|
|
|
<Dialog.Footer className="flex justify-between items-center border-t pt-3 mt-1">
|
|
|
|
|
|
<Button variant="plain" onClick={handleClose}>
|
2026-08-14 14:03:34 +00:00
|
|
|
|
{translate('::App.Platform.Cancel')}
|
2026-05-13 11:31:15 +00:00
|
|
|
|
</Button>
|
|
|
|
|
|
<div className="flex items-center gap-2">
|
|
|
|
|
|
{step > 0 && (
|
|
|
|
|
|
<Button variant="default" onClick={handleBack}>
|
2026-08-14 14:03:34 +00:00
|
|
|
|
{translate('::App.Platform.Back')}
|
2026-05-13 11:31:15 +00:00
|
|
|
|
</Button>
|
|
|
|
|
|
)}
|
2026-08-17 14:26:51 +00:00
|
|
|
|
{step < lastStep ? (
|
2026-05-13 11:31:15 +00:00
|
|
|
|
<Button variant="solid" color="blue-600" onClick={handleNext} disabled={!canGoNext()}>
|
2026-08-14 14:03:34 +00:00
|
|
|
|
{translate('::App.Platform.Next')}
|
2026-05-13 11:31:15 +00:00
|
|
|
|
</Button>
|
|
|
|
|
|
) : (
|
|
|
|
|
|
<Button
|
|
|
|
|
|
variant="solid"
|
|
|
|
|
|
color="green-600"
|
|
|
|
|
|
icon={<FaCloudUploadAlt />}
|
|
|
|
|
|
onClick={handleDeploy}
|
|
|
|
|
|
loading={isDeploying}
|
|
|
|
|
|
disabled={
|
|
|
|
|
|
!dataSource ||
|
|
|
|
|
|
isDeploying ||
|
2026-08-14 11:32:31 +00:00
|
|
|
|
(isEditMode &&
|
2026-08-19 07:50:33 +00:00
|
|
|
|
generatedSql.includes(translate('::App.TableDesignerDiff.NoChanges')))
|
2026-05-13 11:31:15 +00:00
|
|
|
|
}
|
|
|
|
|
|
>
|
|
|
|
|
|
{translate('::App.Platform.Deploy')}
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
)}
|
2026-03-01 17:40:25 +00:00
|
|
|
|
</div>
|
2026-05-13 11:31:15 +00:00
|
|
|
|
</Dialog.Footer>
|
2026-03-01 17:40:25 +00:00
|
|
|
|
</Dialog>
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-01 20:43:25 +00:00
|
|
|
|
export default SqlTableDesignerDialog
|