MenuAddDialog eklendi

This commit is contained in:
Sedat ÖZTÜRK 2026-03-03 10:34:25 +03:00
parent 490a73dde8
commit c5ad419a27
4 changed files with 1111 additions and 804 deletions

View file

@ -15836,6 +15836,12 @@
"en": "Used to generate ListForm Code and Menu Code", "en": "Used to generate ListForm Code and Menu Code",
"tr": "ListForm Kodu ve Menü Kodu oluşturmak için kullanılır" "tr": "ListForm Kodu ve Menü Kodu oluşturmak için kullanılır"
}, },
{
"resourceName": "Platform",
"key": "ListForms.Wizard.Step1.Optional",
"en": "Optional",
"tr": "İsteğe Bağlı"
},
{ {
"resourceName": "Platform", "resourceName": "Platform",
"key": "ListForms.Wizard.Step1.WizardName", "key": "ListForms.Wizard.Step1.WizardName",

View file

@ -1,4 +1,4 @@
import { Button, Dialog, FormItem, Input, Notification, Select, toast } from '@/components/ui' import { Button, FormItem, Input, Notification, Select, toast } from '@/components/ui'
import { ListFormWizardDto } from '@/proxy/admin/list-form/models' import { ListFormWizardDto } from '@/proxy/admin/list-form/models'
import { MenuDto } from '@/proxy/menus/models' import { MenuDto } from '@/proxy/menus/models'
import { SelectBoxOption } from '@/types/shared' import { SelectBoxOption } from '@/types/shared'
@ -6,7 +6,7 @@ import navigationIcon from '@/proxy/menus/navigation-icon.config'
import { MenuItem } from '@/proxy/menus/menu' import { MenuItem } from '@/proxy/menus/menu'
import { MenuService } from '@/services/menu.service' import { MenuService } from '@/services/menu.service'
import { Field, FieldProps, FormikErrors, FormikTouched } from 'formik' import { Field, FieldProps, FormikErrors, FormikTouched } from 'formik'
import { useEffect, useRef, useState } from 'react' import { useEffect, useState } from 'react'
import CreatableSelect from 'react-select/creatable' import CreatableSelect from 'react-select/creatable'
import { import {
FaArrowRight, FaArrowRight,
@ -19,6 +19,7 @@ import {
FaTrash, FaTrash,
} from 'react-icons/fa' } from 'react-icons/fa'
import { useLocalization } from '@/utils/hooks/useLocalization' import { useLocalization } from '@/utils/hooks/useLocalization'
import { IconPickerField, MenuAddDialog } from '@/views/shared/MenuAddDialog'
// ─── Types (exported for Wizard.tsx) ───────────────────────────────────────── // ─── Types (exported for Wizard.tsx) ─────────────────────────────────────────
@ -371,332 +372,6 @@ function MenuTreeInline({
) )
} }
// ─── IconPickerField ──────────────────────────────────────────────────────────
interface IconPickerFieldProps {
value: string
onChange: (iconKey: string) => void
invalid?: boolean
}
const ALL_ICON_ENTRIES = Object.entries(navigationIcon)
const ICON_PAGE_SIZE = 100
function IconPickerField({ value, onChange, invalid }: IconPickerFieldProps) {
const [open, setOpen] = useState(false)
const [search, setSearch] = useState('')
const [limit, setLimit] = useState(ICON_PAGE_SIZE)
const wrapperRef = useRef<HTMLDivElement>(null)
const SelectedIcon = value ? navigationIcon[value] : null
const filtered = search.trim()
? ALL_ICON_ENTRIES.filter(([key]) => key.toLowerCase().includes(search.toLowerCase()))
: ALL_ICON_ENTRIES
const displayed = filtered.slice(0, limit)
const hasMore = displayed.length < filtered.length
const { translate } = useLocalization()
useEffect(() => {
setLimit(ICON_PAGE_SIZE)
}, [search])
useEffect(() => {
function handleClickOutside(e: MouseEvent) {
if (wrapperRef.current && !wrapperRef.current.contains(e.target as Node)) setOpen(false)
}
if (open) document.addEventListener('mousedown', handleClickOutside)
return () => document.removeEventListener('mousedown', handleClickOutside)
}, [open])
return (
<div ref={wrapperRef} className="relative">
<button
type="button"
onClick={() => setOpen((v) => !v)}
className={`flex items-center gap-2 w-full h-11 px-3 py-2 rounded-lg border text-sm text-left transition-colors
${invalid ? 'border-red-500' : 'border-gray-300 dark:border-gray-600'}
bg-white dark:bg-gray-800 text-gray-700 dark:text-gray-200 hover:border-indigo-400`}
>
{SelectedIcon ? (
<span className="flex items-center gap-2 flex-1 truncate">
<SelectedIcon className="text-xl shrink-0" />
<span className="text-xs text-gray-500 truncate">{value}</span>
</span>
) : (
<span className="text-gray-400 flex-1">{translate('::ListForms.Wizard.Step1.SelectIcon') || 'Select icon...'}</span>
)}
<FaChevronDown
className={`text-gray-400 text-xs transition-transform ${open ? 'rotate-180' : ''}`}
/>
</button>
{open && (
<div className="absolute z-50 mt-1 left-0 w-[360px] bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-600 rounded-lg shadow-xl">
<div className="p-2 border-b border-gray-100 dark:border-gray-700 flex items-center gap-2">
<input
autoFocus
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Search icons… (FaHome, FcSettings)"
className="flex-1 px-2 py-1.5 text-sm rounded border border-gray-200 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-100 outline-none focus:border-indigo-400"
/>
<span className="text-xs text-gray-400 shrink-0">{filtered.length} icons</span>
</div>
<div className="grid grid-cols-10 gap-0.5 p-2 max-h-[208px] overflow-y-auto">
{displayed.map(([key, Icon]) => (
<button
key={key}
type="button"
title={key}
onClick={() => {
onChange(key)
setOpen(false)
setSearch('')
}}
className={`flex items-center justify-center h-10 w-full rounded text-xl transition-colors
${value === key ? 'bg-indigo-500 text-white' : 'hover:bg-gray-100 dark:hover:bg-gray-700 text-gray-600 dark:text-gray-300'}`}
>
<Icon />
</button>
))}
</div>
{hasMore && (
<div className="px-3 py-1.5 border-t border-gray-100 dark:border-gray-700 flex items-center justify-between">
<span className="text-xs text-gray-400">
{displayed.length} / {filtered.length}
</span>
<button
type="button"
onClick={() => setLimit((l) => l + ICON_PAGE_SIZE)}
className="text-xs px-3 py-1 rounded bg-indigo-50 dark:bg-indigo-900/30 text-indigo-600 dark:text-indigo-400 hover:bg-indigo-100 dark:hover:bg-indigo-900/50 font-medium"
>
Load More
</button>
</div>
)}
</div>
)}
</div>
)
}
// ─── MenuAddDialog ────────────────────────────────────────────────────────────
interface MenuAddDialogProps {
isOpen: boolean
onClose: () => void
initialParentCode: string
initialOrder: number
rawItems: (MenuItem & { id?: string })[]
onSaved: () => void
}
function MenuAddDialog({
isOpen,
onClose,
initialParentCode,
initialOrder,
rawItems,
onSaved,
}: MenuAddDialogProps) {
const [form, setForm] = useState({
name: '',
code: '',
menuTextEn: '',
menuTextTr: '',
parentCode: initialParentCode,
icon: '',
shortName: '',
order: initialOrder,
})
const [saving, setSaving] = useState(false)
const { translate } = useLocalization()
useEffect(() => {
if (isOpen)
setForm({
name: '',
code: '',
menuTextEn: '',
menuTextTr: '',
parentCode: initialParentCode,
icon: '',
shortName: '',
order: initialOrder,
})
}, [isOpen, initialParentCode, initialOrder])
const handleSave = async () => {
if (!form.code.trim() || !form.menuTextEn.trim()) return
setSaving(true)
try {
// Menü oluşturuluyor
await menuService.createWithLanguageKeyText({
code: form.code.trim(),
displayName: form.code.trim(),
parentCode: form.parentCode.trim() || undefined,
icon: form.icon || undefined,
shortName: form.shortName.trim() || undefined,
order: form.order,
isDisabled: false,
menuTextTr: form.menuTextTr.trim(),
menuTextEn: form.menuTextEn.trim(),
} as MenuDto)
onSaved()
onClose()
} catch (e: any) {
toast.push(<Notification title={e.message} type="danger" />, { placement: 'top-end' })
} finally {
setSaving(false)
}
}
// suppress unused warning — rawItems kept for future use
void rawItems
const fieldCls =
'h-11 px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-sm text-gray-700 dark:text-gray-200 outline-none focus:border-indigo-400 w-full'
const disabledCls =
'h-11 px-3 py-2 rounded-lg border border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-700 text-sm text-gray-400 dark:text-gray-500 cursor-not-allowed w-full'
const labelCls = 'text-xs font-medium text-gray-500 dark:text-gray-400 mb-1'
return (
<Dialog isOpen={isOpen} onClose={onClose} onRequestClose={onClose} width={680}>
<div className="flex flex-col gap-5 p-5">
{/* Header */}
<div className="flex items-center gap-2 pb-1 border-b border-gray-100 dark:border-gray-700">
<FaPlus className="text-green-500 text-sm" />
<h5 className="text-base font-semibold text-gray-800 dark:text-gray-100">{translate('::ListForms.Wizard.Step1.AddNewMenu')}</h5>
</div>
{/* Row 1 — Name | Code */}
<div className="grid grid-cols-2 gap-4">
<div className="flex flex-col">
<label className={labelCls}>
{translate('::ListForms.Wizard.Step1.Name') || 'Name'} <span className="text-red-500">*</span>
</label>
<input
autoFocus
value={form.name}
onChange={(e) =>
setForm((p) => ({
...p,
name: e.target.value.replace(/\s+/g, ''),
code: `App.Wizard.${e.target.value.replace(/\s+/g, '')}`,
}))
}
placeholder="MyMenu"
className={fieldCls}
/>
</div>
<div className="flex flex-col">
<label className={labelCls}>
{translate('::ListForms.Wizard.Step1.Code') || 'Code'} <span className="text-red-500">*</span>
</label>
<input
value={form.code}
disabled
placeholder="App.Wizard.MyMenu"
className={disabledCls}
/>
</div>
</div>
{/* Row 3 — Icon (full width) */}
<div className="flex flex-col">
<label className={labelCls}>
{translate('::ListForms.Wizard.Step4.Icon')} <span className="text-red-500">*</span>
</label>
<IconPickerField
value={form.icon}
onChange={(key) => setForm((p) => ({ ...p, icon: key }))}
/>
</div>
{/* Row 2 — Display Name EN | Display Name TR */}
<div className="grid grid-cols-2 gap-4">
<div className="flex flex-col">
<label className={labelCls}>
{translate('::ListForms.Wizard.Step1.DisplayNameEnglish')} <span className="text-red-500">*</span>
</label>
<input
value={form.menuTextEn}
onChange={(e) => setForm((p) => ({ ...p, menuTextEn: e.target.value }))}
placeholder="My Menu"
className={fieldCls}
/>
</div>
<div className="flex flex-col">
<label className={labelCls}>
{translate('::ListForms.Wizard.Step1.DisplayNameTurkish')} <span className="text-red-500">*</span>
</label>
<input
value={form.menuTextTr}
onChange={(e) => setForm((p) => ({ ...p, menuTextTr: e.target.value }))}
placeholder="Menüm"
className={fieldCls}
/>
</div>
</div>
{/* Row 4 — Menu Parent | Order */}
<div className="grid grid-cols-2 gap-4">
<div className="flex flex-col">
<label className={labelCls}>{translate('::ListForms.Wizard.Step1.MenuParent')}</label>
<input
disabled
value={form.parentCode || translate('::ListForms.Wizard.Step1.MainMenu') || '(Ana Menü)'}
className={disabledCls}
/>
</div>
<div className="flex flex-col">
<label className={labelCls}>{translate('::ListForms.Wizard.Step1.Order') || 'Sıra (Order)'}</label>
<input
type="number"
value={form.order}
onChange={(e) => setForm((p) => ({ ...p, order: Number(e.target.value) }))}
className={fieldCls}
/>
</div>
</div>
{/* Row 5 — Short Name (full width) */}
<div className="flex flex-col">
<label className={labelCls}>{translate('::ListForms.Wizard.Step1.ShortName')}</label>
<input
value={form.shortName}
onChange={(e) => setForm((p) => ({ ...p, shortName: e.target.value }))}
placeholder="My Menu (short)"
className={fieldCls}
/>
</div>
{/* Footer */}
<div className="flex justify-end gap-2 pt-1 border-t border-gray-100 dark:border-gray-700">
<Button size="sm" variant="plain" onClick={onClose}>
{translate('::ListForms.Wizard.Cancel') || 'İptal'}
</Button>
<Button
size="sm"
variant="solid"
loading={saving}
disabled={
!form.code.trim() ||
!form.menuTextEn.trim() ||
!form.menuTextTr.trim() ||
!form.icon.trim()
}
onClick={handleSave}
>
{translate('::ListForms.Wizard.Save') || 'Kaydet'}
</Button>
</div>
</div>
</Dialog>
)
}
// ─── WizardStep1 ────────────────────────────────────────────────────────────── // ─── WizardStep1 ──────────────────────────────────────────────────────────────
export interface WizardStep1Props { export interface WizardStep1Props {

View file

@ -13,11 +13,21 @@ import {
FaEdit, FaEdit,
FaTimes, FaTimes,
FaArrowRight, FaArrowRight,
FaChevronDown,
FaChevronRight,
} from 'react-icons/fa' } from 'react-icons/fa'
import { sqlObjectManagerService } from '@/services/sql-query-manager.service' import { sqlObjectManagerService } from '@/services/sql-query-manager.service'
import { MenuService } from '@/services/menu.service' import { getMenus } from '@/services/menu.service'
import { useLocalization } from '@/utils/hooks/useLocalization' import { useLocalization } from '@/utils/hooks/useLocalization'
import { CascadeBehavior, SqlTableRelation, RelationshipType } from '@/proxy/developerKit/models' import { CascadeBehavior, SqlTableRelation, RelationshipType } from '@/proxy/developerKit/models'
import navigationIcon from '@/proxy/menus/navigation-icon.config'
import { MenuItem } from '@/proxy/menus/menu'
import {
MenuTreeNode,
buildMenuTree,
filterNonLinkNodes,
} from '@/views/admin/listForm/WizardStep1'
import { MenuAddDialog } from '../shared/MenuAddDialog'
// ─── Types ──────────────────────────────────────────────────────────────────── // ─── Types ────────────────────────────────────────────────────────────────────
@ -44,11 +54,6 @@ interface ColumnDefinition {
description: string description: string
} }
interface MenuOption {
value: string
label: string
}
interface TableSettings { interface TableSettings {
menuValue: string menuValue: string
menuPrefix: string menuPrefix: string
@ -234,13 +239,27 @@ function generateCreateTableSql(
for (const rel of relationships) { for (const rel of relationships) {
if (!rel.fkColumnName.trim() || !rel.referencedTable.trim()) continue if (!rel.fkColumnName.trim() || !rel.referencedTable.trim()) continue
const constraintName = `FK_${tableName}_${rel.fkColumnName}` const constraintName = `FK_${tableName}_${rel.fkColumnName}`
const cascadeDelete = rel.cascadeDelete === 'NoAction' ? 'NO ACTION' : rel.cascadeDelete.replace(/([A-Z])/g, ' $1').trim().toUpperCase() const cascadeDelete =
const cascadeUpdate = rel.cascadeUpdate === 'NoAction' ? 'NO ACTION' : rel.cascadeUpdate.replace(/([A-Z])/g, ' $1').trim().toUpperCase() rel.cascadeDelete === 'NoAction'
? 'NO ACTION'
: rel.cascadeDelete
.replace(/([A-Z])/g, ' $1')
.trim()
.toUpperCase()
const cascadeUpdate =
rel.cascadeUpdate === 'NoAction'
? 'NO ACTION'
: rel.cascadeUpdate
.replace(/([A-Z])/g, ' $1')
.trim()
.toUpperCase()
fkLines.push('') fkLines.push('')
fkLines.push(`ALTER TABLE ${fullTableName}`) fkLines.push(`ALTER TABLE ${fullTableName}`)
fkLines.push(` ADD CONSTRAINT [${constraintName}]`) fkLines.push(` ADD CONSTRAINT [${constraintName}]`)
fkLines.push(` FOREIGN KEY ([${rel.fkColumnName}])`) fkLines.push(` FOREIGN KEY ([${rel.fkColumnName}])`)
fkLines.push(` REFERENCES [dbo].[${rel.referencedTable}] ([${rel.referencedColumn || 'Id'}])`) fkLines.push(
` REFERENCES [dbo].[${rel.referencedTable}] ([${rel.referencedColumn || 'Id'}])`,
)
fkLines.push(` ON DELETE ${cascadeDelete}`) fkLines.push(` ON DELETE ${cascadeDelete}`)
fkLines.push(` ON UPDATE ${cascadeUpdate};`) fkLines.push(` ON UPDATE ${cascadeUpdate};`)
} }
@ -359,7 +378,8 @@ function generateAlterTableSql(
const orig = origById.get(col.id) const orig = origById.get(col.id)
if (!orig) return // new column, already handled above if (!orig) return // new column, already handled above
const nameChanged = orig.columnName.trim().toLowerCase() !== col.columnName.trim().toLowerCase() const nameChanged =
orig.columnName.trim().toLowerCase() !== col.columnName.trim().toLowerCase()
const typeChanged = orig.dataType !== col.dataType || orig.maxLength !== col.maxLength const typeChanged = orig.dataType !== col.dataType || orig.maxLength !== col.maxLength
const nullChanged = orig.isNullable !== col.isNullable const nullChanged = orig.isNullable !== col.isNullable
@ -390,7 +410,12 @@ function generateAlterTableSql(
// 🔗 FK Diff: drop removed / drop+re-add modified / add new // 🔗 FK Diff: drop removed / drop+re-add modified / add new
const fkCascadeSql = (b: CascadeBehavior) => const fkCascadeSql = (b: CascadeBehavior) =>
b === 'NoAction' ? 'NO ACTION' : b.replace(/([A-Z])/g, ' $1').trim().toUpperCase() b === 'NoAction'
? 'NO ACTION'
: b
.replace(/([A-Z])/g, ' $1')
.trim()
.toUpperCase()
const addFkSql = (rel: SqlTableRelation) => { const addFkSql = (rel: SqlTableRelation) => {
const cname = rel.constraintName ?? `FK_${tableName}_${rel.fkColumnName}` const cname = rel.constraintName ?? `FK_${tableName}_${rel.fkColumnName}`
@ -461,6 +486,138 @@ function generateAlterTableSql(
const STEPS = ['Sütun Tasarımı', 'Entity Ayarları', 'İlişkiler', 'T-SQL Önizleme'] as const const STEPS = ['Sütun Tasarımı', 'Entity Ayarları', 'İlişkiler', 'T-SQL Önizleme'] as const
type Step = 0 | 1 | 2 | 3 type Step = 0 | 1 | 2 | 3
// ─── Simple Menu Tree (read-only selection) ───────────────────────────────────
interface SimpleMenuTreeNodeProps {
node: MenuTreeNode & { shortName?: string }
depth: number
selectedCode: string
onSelect: (code: string) => void
expanded: Set<string>
onToggle: (code: string) => void
}
function SimpleMenuTreeNode({
node,
depth,
selectedCode,
onSelect,
expanded,
onToggle,
}: SimpleMenuTreeNodeProps) {
const hasChildren = node.children.length > 0
const isExpanded = expanded.has(node.code)
const isSelected = node.code === selectedCode
const NodeIcon = node.icon ? navigationIcon[node.icon] : null
const { translate } = useLocalization()
return (
<div>
<div
className={`flex items-center gap-1 px-2 py-1.5 rounded mx-1 cursor-pointer ${
isSelected
? 'bg-indigo-500 text-white'
: 'hover:bg-gray-100 dark:hover:bg-gray-700 text-gray-700 dark:text-gray-200'
}`}
style={{ paddingLeft: `${8 + depth * 16}px` }}
onClick={() => onSelect(node.code)}
>
<span>
{hasChildren ? (
isExpanded ? (
<FaChevronDown className="text-gray-400" />
) : (
<FaChevronRight className="text-gray-400" />
)
) : null}
</span>
{NodeIcon && (
<NodeIcon
className={`text-base shrink-0 ${isSelected ? 'text-indigo-200' : 'text-gray-400'}`}
/>
)}
<span className="flex-1 text-sm truncate">{translate('::' + node.code)}</span>
{node.shortName && (
<span
className={`text-xs shrink-0 font-mono px-1.5 py-0.5 rounded ${
isSelected
? 'bg-indigo-400 text-indigo-100'
: 'bg-gray-100 dark:bg-gray-700 text-gray-500 dark:text-gray-400'
}`}
>
{node.shortName}
</span>
)}
</div>
{isExpanded &&
node.children.map((child: MenuTreeNode) => (
<SimpleMenuTreeNode
key={child.code}
node={child as MenuTreeNode & { shortName?: string }}
depth={depth + 1}
selectedCode={selectedCode}
onSelect={onSelect}
expanded={expanded}
onToggle={onToggle}
/>
))}
</div>
)
}
interface SimpleMenuTreeSelectProps {
selectedCode: string
onSelect: (code: string) => void
nodes: MenuTreeNode[]
isLoading: boolean
invalid?: boolean
}
function SimpleMenuTreeSelect({
selectedCode,
onSelect,
nodes,
isLoading,
invalid,
}: SimpleMenuTreeSelectProps) {
const [expanded, setExpanded] = useState<Set<string>>(new Set())
const toggle = (code: string) =>
setExpanded((prev) => {
const n = new Set(prev)
n.has(code) ? n.delete(code) : n.add(code)
return n
})
return (
<div
className={`rounded-lg border ${
invalid ? 'border-red-500' : 'border-gray-300 dark:border-gray-600'
} bg-white dark:bg-gray-800 overflow-hidden`}
>
<div className="h-56 overflow-y-auto py-1">
{isLoading ? (
<div className="px-4 py-3 text-sm text-gray-400">Loading</div>
) : nodes.length === 0 ? (
<div className="px-4 py-3 text-sm text-gray-400">No menus available</div>
) : (
nodes.map((node) => (
<SimpleMenuTreeNode
key={node.code}
node={node as MenuTreeNode & { shortName?: string }}
depth={0}
selectedCode={selectedCode}
onSelect={onSelect}
expanded={expanded}
onToggle={toggle}
/>
))
)}
</div>
</div>
)
}
const createEmptyColumn = (): ColumnDefinition => ({ const createEmptyColumn = (): ColumnDefinition => ({
id: crypto.randomUUID(), id: crypto.randomUUID(),
columnName: '', columnName: '',
@ -497,7 +654,10 @@ const SqlTableDesignerDialog = ({
const [originalColumns, setOriginalColumns] = useState<ColumnDefinition[]>([]) const [originalColumns, setOriginalColumns] = useState<ColumnDefinition[]>([])
const [colsLoading, setColsLoading] = useState(false) const [colsLoading, setColsLoading] = useState(false)
const [settings, setSettings] = useState<TableSettings>(DEFAULT_SETTINGS) const [settings, setSettings] = useState<TableSettings>(DEFAULT_SETTINGS)
const [menuOptions, setMenuOptions] = useState<MenuOption[]>([]) const [rawMenuItems, setRawMenuItems] = useState<MenuItem[]>([])
const [menuTree, setMenuTree] = useState<MenuTreeNode[]>([])
const [selectedMenuCode, setSelectedMenuCode] = useState('')
const [menuAddDialogOpen, setMenuAddDialogOpen] = useState(false)
const [menuLoading, setMenuLoading] = useState(false) const [menuLoading, setMenuLoading] = useState(false)
const [relationships, setRelationships] = useState<SqlTableRelation[]>([]) const [relationships, setRelationships] = useState<SqlTableRelation[]>([])
const [originalRelationships, setOriginalRelationships] = useState<SqlTableRelation[]>([]) const [originalRelationships, setOriginalRelationships] = useState<SqlTableRelation[]>([])
@ -509,21 +669,32 @@ const SqlTableDesignerDialog = ({
const [targetTableColumns, setTargetTableColumns] = useState<string[]>([]) const [targetTableColumns, setTargetTableColumns] = useState<string[]>([])
const [targetColsLoading, setTargetColsLoading] = useState(false) const [targetColsLoading, setTargetColsLoading] = useState(false)
useEffect(() => { const reloadMenus = (onLoaded?: (items: MenuItem[]) => void) => {
if (!isOpen) return
setMenuLoading(true) setMenuLoading(true)
const svc = new MenuService() getMenus(0, 1000)
svc
.getListMainMenu()
.then((res) => { .then((res) => {
const opts: MenuOption[] = (res.data || []).map((m: any) => ({ const items = (res.data?.items ?? []) as MenuItem[]
value: m.shortName, const filtered = items.filter((m) => !!m.shortName?.trim())
label: m.displayName, setRawMenuItems(filtered)
})) const tree = filterNonLinkNodes(buildMenuTree(filtered))
setMenuOptions(opts) setMenuTree(tree)
onLoaded?.(filtered)
}) })
.catch(() => {}) .catch(() => {})
.finally(() => setMenuLoading(false)) .finally(() => setMenuLoading(false))
}
useEffect(() => {
if (!isOpen) return
reloadMenus((items) => {
// In edit mode, auto-select the matching menu code by shortName
if (initialTableData) {
const parts = initialTableData.tableName.split('_')
const derivedShortName = parts[0] ?? ''
const match = items.find((m) => m.shortName === derivedShortName)
if (match?.code) setSelectedMenuCode(match.code)
}
})
if (dataSource) { if (dataSource) {
sqlObjectManagerService sqlObjectManagerService
@ -618,7 +789,15 @@ const SqlTableDesignerDialog = ({
originalRelationships, originalRelationships,
) )
: generateCreateTableSql(columns, settings, relationships), : generateCreateTableSql(columns, settings, relationships),
[isEditMode, originalColumns, columns, settings, initialTableData, relationships, originalRelationships], [
isEditMode,
originalColumns,
columns,
settings,
initialTableData,
relationships,
originalRelationships,
],
) )
// ── Column operations ────────────────────────────────────────────────────── // ── Column operations ──────────────────────────────────────────────────────
@ -629,9 +808,7 @@ const SqlTableDesignerDialog = ({
const addFullAuditedColumns = () => { const addFullAuditedColumns = () => {
const existingNames = new Set(columns.map((c) => c.columnName.trim().toLowerCase())) const existingNames = new Set(columns.map((c) => c.columnName.trim().toLowerCase()))
const toAdd = FULL_AUDIT_COLUMNS.filter( const toAdd = FULL_AUDIT_COLUMNS.filter((c) => !existingNames.has(c.columnName.toLowerCase()))
(c) => !existingNames.has(c.columnName.toLowerCase()),
)
setColumns((prev) => { setColumns((prev) => {
const nonEmpty = prev.filter((c) => c.columnName.trim() !== '') const nonEmpty = prev.filter((c) => c.columnName.trim() !== '')
return [...nonEmpty, ...toAdd.map((c) => ({ ...c })), createEmptyColumn()] return [...nonEmpty, ...toAdd.map((c) => ({ ...c })), createEmptyColumn()]
@ -674,12 +851,14 @@ const SqlTableDesignerDialog = ({
const buildTableName = (prefix: string, entity: string) => const buildTableName = (prefix: string, entity: string) =>
prefix && entity ? `${prefix}_D_${entity}` : '' prefix && entity ? `${prefix}_D_${entity}` : ''
const onMenuChange = (value: string) => { const onMenuCodeSelect = (code: string) => {
const opt = menuOptions.find((o) => o.value === value) if (isEditMode) return
const prefix = opt?.value ?? '' const item = rawMenuItems.find((m) => m.code === code)
const prefix = item?.shortName ?? ''
setSelectedMenuCode(code)
setSettings((s) => ({ setSettings((s) => ({
...s, ...s,
menuValue: value, menuValue: prefix,
menuPrefix: prefix, menuPrefix: prefix,
tableName: buildTableName(prefix, s.entityName), tableName: buildTableName(prefix, s.entityName),
})) }))
@ -813,7 +992,8 @@ const SqlTableDesignerDialog = ({
} catch (error: any) { } catch (error: any) {
toast.push( toast.push(
<Notification type="danger" title={translate('::App.SqlQueryManager.Error')}> <Notification type="danger" title={translate('::App.SqlQueryManager.Error')}>
{error.response?.data?.error?.message || translate('::App.SqlQueryManager.TableDeployFailed')} {error.response?.data?.error?.message ||
translate('::App.SqlQueryManager.TableDeployFailed')}
</Notification>, </Notification>,
{ placement: 'top-center' }, { placement: 'top-center' },
) )
@ -831,6 +1011,8 @@ const SqlTableDesignerDialog = ({
setOriginalRelationships([]) setOriginalRelationships([])
setDbTables([]) setDbTables([])
setTargetTableColumns([]) setTargetTableColumns([])
setSelectedMenuCode('')
setMenuAddDialogOpen(false)
onClose() onClose()
} }
@ -878,7 +1060,8 @@ const SqlTableDesignerDialog = ({
<div className="flex flex-col"> <div className="flex flex-col">
{colsLoading && ( {colsLoading && (
<div className="flex items-center justify-center py-12 text-gray-500"> <div className="flex items-center justify-center py-12 text-gray-500">
<span className="animate-spin mr-2">&#9696;</span> {translate('::App.SqlQueryManager.LoadingColumns')} <span className="animate-spin mr-2">&#9696;</span>{' '}
{translate('::App.SqlQueryManager.LoadingColumns')}
</div> </div>
)} )}
{!colsLoading && ( {!colsLoading && (
@ -893,10 +1076,22 @@ const SqlTableDesignerDialog = ({
</Button> </Button>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Button size="xs" variant="solid" color="red-600" icon={<FaTrash />} onClick={clearAllColumns}> <Button
size="xs"
variant="solid"
color="red-600"
icon={<FaTrash />}
onClick={clearAllColumns}
>
{translate('::App.SqlQueryManager.ClearAllColumns')} {translate('::App.SqlQueryManager.ClearAllColumns')}
</Button> </Button>
<Button size="xs" variant="solid" color="blue-600" icon={<FaPlus />} onClick={addColumn}> <Button
size="xs"
variant="solid"
color="blue-600"
icon={<FaPlus />}
onClick={addColumn}
>
{translate('::App.SqlQueryManager.AddColumn')} {translate('::App.SqlQueryManager.AddColumn')}
</Button> </Button>
</div> </div>
@ -907,10 +1102,14 @@ const SqlTableDesignerDialog = ({
<div className="col-span-3">{translate('::App.SqlQueryManager.ColumnName')}</div> <div className="col-span-3">{translate('::App.SqlQueryManager.ColumnName')}</div>
<div className="col-span-3">{translate('::App.SqlQueryManager.DataType')}</div> <div className="col-span-3">{translate('::App.SqlQueryManager.DataType')}</div>
<div className="col-span-1 text-center">{translate('::App.SqlQueryManager.Max')}</div> <div className="col-span-1 text-center">{translate('::App.SqlQueryManager.Max')}</div>
<div className="col-span-1 text-center">{translate('::App.SqlQueryManager.Nullable')}</div> <div className="col-span-1 text-center">
{translate('::App.SqlQueryManager.Nullable')}
</div>
<div className="col-span-2">{translate('::App.SqlQueryManager.DefaultValue')}</div> <div className="col-span-2">{translate('::App.SqlQueryManager.DefaultValue')}</div>
<div className="col-span-1">{translate('::App.SqlQueryManager.Note')}</div> <div className="col-span-1">{translate('::App.SqlQueryManager.Note')}</div>
<div className="col-span-1 text-center">{translate('::App.SqlQueryManager.Actions')}</div> <div className="col-span-1 text-center">
{translate('::App.SqlQueryManager.Actions')}
</div>
</div> </div>
{/* Editable column rows */} {/* Editable column rows */}
@ -930,9 +1129,7 @@ const SqlTableDesignerDialog = ({
const isDuplicate = const isDuplicate =
col.columnName.trim() !== '' && col.columnName.trim() !== '' &&
duplicateColumnNames.has(col.columnName.trim().toLowerCase()) duplicateColumnNames.has(col.columnName.trim().toLowerCase())
const isNewRow = const isNewRow = isEditMode && !originalColumns.some((o) => o.id === col.id)
isEditMode &&
!originalColumns.some((o) => o.id === col.id)
const origRow = isEditMode ? originalColumns.find((o) => o.id === col.id) : undefined const origRow = isEditMode ? originalColumns.find((o) => o.id === col.id) : undefined
const isRenamedRow = const isRenamedRow =
@ -1047,7 +1244,8 @@ const SqlTableDesignerDialog = ({
<button <button
onClick={() => removeColumn(col.id)} onClick={() => removeColumn(col.id)}
className="p-1 rounded hover:bg-red-50 dark:hover:bg-red-900/30 text-red-500" className="p-1 rounded hover:bg-red-50 dark:hover:bg-red-900/30 text-red-500"
title={translate('::App.SqlQueryManager.Delete')}> title={translate('::App.SqlQueryManager.Delete')}
>
<FaTrash className="text-xs" /> <FaTrash className="text-xs" />
</button> </button>
</div> </div>
@ -1057,8 +1255,7 @@ const SqlTableDesignerDialog = ({
</div> </div>
{/* Id warning */} {/* Id warning */}
{!isEditMode && {!isEditMode && !columns.some((c) => c.columnName.trim().toLowerCase() === 'id') && (
!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"> <div className="px-2 py-1.5 mt-2 bg-orange-50 dark:bg-orange-900/20 border border-orange-300 dark:border-orange-700 rounded text-xs text-orange-700 dark:text-orange-300">
{translate('::App.SqlQueryManager.NoIdColumnWarning')} {translate('::App.SqlQueryManager.NoIdColumnWarning')}
</div> </div>
@ -1075,23 +1272,57 @@ const SqlTableDesignerDialog = ({
<div className="grid grid-cols-2 gap-2"> <div className="grid grid-cols-2 gap-2">
{/* Menu Name */} {/* Menu Name */}
<div className="col-span-2"> <div className="col-span-2">
<label className="block text-sm font-medium mb-1"> <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> {translate('::App.SqlQueryManager.MenuName')} <span className="text-red-500">*</span>
</label> </label>
<select <div className="flex items-center gap-2">
className="w-full px-3 py-2 text-sm border rounded dark:bg-gray-700 dark:border-gray-600 dark:text-white" <button
value={settings.menuValue} type="button"
disabled={menuLoading || isEditMode} // Menu cannot be changed in edit mode (as it determines the table name) disabled={isEditMode}
onChange={(e) => onMenuChange(e.target.value)} onClick={() => setMenuAddDialogOpen(true)}
className="flex items-center gap-1 px-2 py-0.5 text-xs rounded bg-green-500 text-white hover:bg-green-600 disabled:opacity-40 disabled:cursor-not-allowed"
> >
<option value=""></option> <FaPlus className="text-xs" />{' '}
{menuOptions.map((opt) => ( {translate('::ListForms.Wizard.Step1.AddNewMenu') || 'Add Menu'}
<option key={opt.value} value={opt.value}> </button>
{opt.label} ({opt.value}) {settings.menuValue && !isEditMode && (
</option> <button
))} type="button"
</select> onClick={() => {
{menuLoading && <p className="text-xs text-gray-400 mt-1">{translate('::App.SqlQueryManager.Loading')}</p>} setSelectedMenuCode('')
setSettings((s) => ({ ...s, menuValue: '', menuPrefix: '', tableName: '' }))
}}
className="flex items-center gap-1 px-2 py-0.5 text-xs rounded border border-gray-300 dark:border-gray-600 text-gray-500 hover:text-red-500 hover:border-red-400"
>
<FaTimes className="text-xs" />{' '}
{translate('::ListForms.Wizard.ClearSelection') || 'Clear'}
</button>
)}
</div>
</div>
<SimpleMenuTreeSelect
selectedCode={selectedMenuCode}
onSelect={isEditMode ? () => {} : onMenuCodeSelect}
nodes={menuTree}
isLoading={menuLoading}
invalid={!settings.menuValue && !isEditMode && !menuLoading}
/>
{settings.menuValue && (
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
<span className="font-mono font-semibold text-indigo-600 dark:text-indigo-400">
{settings.menuValue}
</span>
</p>
)}
<MenuAddDialog
isOpen={menuAddDialogOpen}
onClose={() => setMenuAddDialogOpen(false)}
initialParentCode={selectedMenuCode}
initialOrder={999}
rawItems={rawMenuItems}
onSaved={() => reloadMenus()}
/>
</div> </div>
{/* Entity Name */} {/* Entity Name */}
@ -1111,7 +1342,9 @@ const SqlTableDesignerDialog = ({
{/* Table Name (readonly, auto-generated) */} {/* Table Name (readonly, auto-generated) */}
<div> <div>
<label className="block text-sm font-medium mb-1">{translate('::App.SqlQueryManager.TableName')}</label> <label className="block text-sm font-medium mb-1">
{translate('::App.SqlQueryManager.TableName')}
</label>
<input <input
type="text" type="text"
readOnly readOnly
@ -1122,10 +1355,8 @@ const SqlTableDesignerDialog = ({
</div> </div>
</div> </div>
{/* Warning: no Id column */} {/* Warning: no Id column */}
{!isEditMode && {!isEditMode && !columns.some((c) => c.columnName.trim().toLowerCase() === 'id') && (
!columns.some((c) => c.columnName.trim().toLowerCase() === 'id') && (
<div className="px-3 py-2 bg-red-50 dark:bg-red-900/20 border border-red-300 dark:border-red-700 rounded text-xs text-red-700 dark:text-red-300"> <div className="px-3 py-2 bg-red-50 dark:bg-red-900/20 border border-red-300 dark:border-red-700 rounded text-xs text-red-700 dark:text-red-300">
{translate('::App.SqlQueryManager.NoIdColumnError')} {translate('::App.SqlQueryManager.NoIdColumnError')}
</div> </div>
@ -1140,10 +1371,12 @@ const SqlTableDesignerDialog = ({
{/* Loading indicator for edit mode */} {/* Loading indicator for edit mode */}
{fksLoading && ( {fksLoading && (
<div className="flex items-center justify-center py-10 text-gray-500 text-sm gap-2"> <div className="flex items-center justify-center py-10 text-gray-500 text-sm gap-2">
<span className="animate-spin">&#9696;</span> {translate('::App.SqlQueryManager.LoadingFkConstraints')} <span className="animate-spin">&#9696;</span>{' '}
{translate('::App.SqlQueryManager.LoadingFkConstraints')}
</div> </div>
)} )}
{!fksLoading && <> {!fksLoading && (
<>
{/* Header */} {/* Header */}
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<span className="text-xs text-gray-500"> <span className="text-xs text-gray-500">
@ -1155,7 +1388,8 @@ const SqlTableDesignerDialog = ({
onClick={openAddFk} onClick={openAddFk}
className="flex items-center gap-1 px-3 py-1.5 bg-indigo-600 hover:bg-indigo-700 text-white text-xs rounded-lg transition-colors" className="flex items-center gap-1 px-3 py-1.5 bg-indigo-600 hover:bg-indigo-700 text-white text-xs rounded-lg transition-colors"
> >
<FaPlus className="w-2.5 h-2.5" /> {translate('::App.SqlQueryManager.AddRelationship')} <FaPlus className="w-2.5 h-2.5" />{' '}
{translate('::App.SqlQueryManager.AddRelationship')}
</button> </button>
</div> </div>
@ -1163,8 +1397,12 @@ const SqlTableDesignerDialog = ({
{relationships.length === 0 && ( {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"> <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" /> <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-sm text-gray-500">
<p className="text-xs text-gray-400 mt-1">{translate('::App.SqlQueryManager.StepIsOptional')}</p> {translate('::App.SqlQueryManager.NoRelationshipsYet')}
</p>
<p className="text-xs text-gray-400 mt-1">
{translate('::App.SqlQueryManager.StepIsOptional')}
</p>
</div> </div>
)} )}
@ -1192,10 +1430,16 @@ const SqlTableDesignerDialog = ({
</span> </span>
</div> </div>
<div className="flex items-center gap-3 mt-1 text-xs text-gray-500 flex-wrap"> <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>
<span>ON UPDATE: <strong>{rel.cascadeUpdate}</strong></span> ON DELETE: <strong>{rel.cascadeDelete}</strong>
</span>
<span>
ON UPDATE: <strong>{rel.cascadeUpdate}</strong>
</span>
{rel.isRequired && ( {rel.isRequired && (
<span className="text-orange-600 dark:text-orange-400">{translate('::App.SqlQueryManager.Required')}</span> <span className="text-orange-600 dark:text-orange-400">
{translate('::App.SqlQueryManager.Required')}
</span>
)} )}
{rel.description && ( {rel.description && (
<span className="text-gray-400 italic">{rel.description}</span> <span className="text-gray-400 italic">{rel.description}</span>
@ -1221,16 +1465,20 @@ const SqlTableDesignerDialog = ({
</div> </div>
))} ))}
</div> </div>
</>} </>
)}
{/* FK Add/Edit Modal */} {/* FK Add/Edit Modal */}
{fkModalOpen && createPortal( {fkModalOpen &&
createPortal(
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50 backdrop-blur-sm"> <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"> <div className="bg-white dark:bg-gray-800 rounded-2xl shadow-2xl w-full max-w-lg flex flex-col">
{/* Modal Header */} {/* Modal Header */}
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-200 dark:border-gray-700"> <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"> <h2 className="text-base font-bold text-gray-900 dark:text-white">
{editingFkId ? translate('::App.SqlQueryManager.EditRelationship') : translate('::App.SqlQueryManager.AddNewRelationship')} {editingFkId
? translate('::App.SqlQueryManager.EditRelationship')
: translate('::App.SqlQueryManager.AddNewRelationship')}
</h2> </h2>
<button <button
onClick={() => setFkModalOpen(false)} onClick={() => setFkModalOpen(false)}
@ -1277,7 +1525,9 @@ const SqlTableDesignerDialog = ({
onChange={(e) => setFkForm((f) => ({ ...f, fkColumnName: e.target.value }))} onChange={(e) => setFkForm((f) => ({ ...f, fkColumnName: e.target.value }))}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg text-sm dark:bg-gray-700 dark:text-white focus:ring-2 focus:ring-indigo-500" className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg text-sm dark:bg-gray-700 dark:text-white focus:ring-2 focus:ring-indigo-500"
> >
<option value="">{translate('::App.SqlQueryManager.SelectPlaceholder')}</option> <option value="">
{translate('::App.SqlQueryManager.SelectPlaceholder')}
</option>
{columns {columns
.filter((c) => c.columnName.trim()) .filter((c) => c.columnName.trim())
.map((c) => ( .map((c) => (
@ -1300,7 +1550,9 @@ const SqlTableDesignerDialog = ({
}} }}
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" className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg text-sm dark:bg-gray-700 dark:text-white focus:ring-2 focus:ring-indigo-500"
> >
<option value="">{translate('::App.SqlQueryManager.SelectPlaceholder')}</option> <option value="">
{translate('::App.SqlQueryManager.SelectPlaceholder')}
</option>
{dbTables.map((t) => ( {dbTables.map((t) => (
<option key={`${t.schemaName}.${t.tableName}`} value={t.tableName}> <option key={`${t.schemaName}.${t.tableName}`} value={t.tableName}>
{t.tableName} {t.tableName}
@ -1312,7 +1564,8 @@ const SqlTableDesignerDialog = ({
<div> <div>
<label className="block text-xs font-semibold text-gray-700 dark:text-gray-300 mb-1.5"> <label className="block text-xs font-semibold text-gray-700 dark:text-gray-300 mb-1.5">
{translate('::App.SqlQueryManager.TargetColumn')}{targetColsLoading ? `${translate('::App.SqlQueryManager.Loading')}` : ''} {translate('::App.SqlQueryManager.TargetColumn')}
{targetColsLoading ? `${translate('::App.SqlQueryManager.Loading')}` : ''}
</label> </label>
<select <select
value={fkForm.referencedColumn} value={fkForm.referencedColumn}
@ -1320,7 +1573,9 @@ const SqlTableDesignerDialog = ({
disabled={targetColsLoading || targetTableColumns.length === 0} disabled={targetColsLoading || targetTableColumns.length === 0}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg text-sm dark:bg-gray-700 dark:text-white focus:ring-2 focus:ring-indigo-500 disabled:opacity-60" className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg text-sm dark:bg-gray-700 dark:text-white focus:ring-2 focus:ring-indigo-500 disabled:opacity-60"
> >
<option value="">{translate('::App.SqlQueryManager.SelectTargetTableFirst')}</option> <option value="">
{translate('::App.SqlQueryManager.SelectTargetTableFirst')}
</option>
{targetTableColumns.map((col) => ( {targetTableColumns.map((col) => (
<option key={col} value={col}> <option key={col} value={col}>
{col} {col}
@ -1338,12 +1593,17 @@ const SqlTableDesignerDialog = ({
<select <select
value={fkForm.cascadeUpdate} value={fkForm.cascadeUpdate}
onChange={(e) => onChange={(e) =>
setFkForm((f) => ({ ...f, cascadeUpdate: e.target.value as CascadeBehavior })) setFkForm((f) => ({
...f,
cascadeUpdate: e.target.value as CascadeBehavior,
}))
} }
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg text-sm dark:bg-gray-700 dark:text-white focus:ring-2 focus:ring-indigo-500" className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg text-sm dark:bg-gray-700 dark:text-white focus:ring-2 focus:ring-indigo-500"
> >
{CASCADE_OPTIONS.map((o) => ( {CASCADE_OPTIONS.map((o) => (
<option key={o.value} value={o.value}>{o.label}</option> <option key={o.value} value={o.value}>
{o.label}
</option>
))} ))}
</select> </select>
</div> </div>
@ -1354,12 +1614,17 @@ const SqlTableDesignerDialog = ({
<select <select
value={fkForm.cascadeDelete} value={fkForm.cascadeDelete}
onChange={(e) => onChange={(e) =>
setFkForm((f) => ({ ...f, cascadeDelete: e.target.value as CascadeBehavior })) setFkForm((f) => ({
...f,
cascadeDelete: e.target.value as CascadeBehavior,
}))
} }
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg text-sm dark:bg-gray-700 dark:text-white focus:ring-2 focus:ring-indigo-500" className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg text-sm dark:bg-gray-700 dark:text-white focus:ring-2 focus:ring-indigo-500"
> >
{CASCADE_OPTIONS.map((o) => ( {CASCADE_OPTIONS.map((o) => (
<option key={o.value} value={o.value}>{o.label}</option> <option key={o.value} value={o.value}>
{o.label}
</option>
))} ))}
</select> </select>
</div> </div>
@ -1374,7 +1639,9 @@ const SqlTableDesignerDialog = ({
onChange={(e) => setFkForm((f) => ({ ...f, isRequired: e.target.checked }))} onChange={(e) => setFkForm((f) => ({ ...f, isRequired: e.target.checked }))}
className="w-4 h-4 text-indigo-600 rounded" className="w-4 h-4 text-indigo-600 rounded"
/> />
<span className="text-sm text-gray-700 dark:text-gray-300">{translate('::App.SqlQueryManager.Required')}</span> <span className="text-sm text-gray-700 dark:text-gray-300">
{translate('::App.SqlQueryManager.Required')}
</span>
</label> </label>
</div> </div>
@ -1411,7 +1678,7 @@ const SqlTableDesignerDialog = ({
</div> </div>
</div> </div>
</div>, </div>,
document.body document.body,
)} )}
</div> </div>
) )
@ -1492,7 +1759,11 @@ const SqlTableDesignerDialog = ({
icon={<FaCloudUploadAlt />} icon={<FaCloudUploadAlt />}
onClick={handleDeploy} onClick={handleDeploy}
loading={isDeploying} loading={isDeploying}
disabled={!dataSource || isDeploying || (isEditMode && generatedSql.includes('Henüz değişiklik yapılmadı'))} disabled={
!dataSource ||
isDeploying ||
(isEditMode && generatedSql.includes('Henüz değişiklik yapılmadı'))
}
> >
{translate('::App.SqlQueryManager.Deploy')} {translate('::App.SqlQueryManager.Deploy')}
</Button> </Button>

View file

@ -0,0 +1,355 @@
import { Button, Dialog, Notification, toast } from '@/components/ui'
import { MenuDto } from '@/proxy/menus/models'
import { MenuItem } from '@/proxy/menus/menu'
import { MenuService } from '@/services/menu.service'
import navigationIcon from '@/proxy/menus/navigation-icon.config'
import { useEffect, useRef, useState } from 'react'
import { FaChevronDown, FaPlus } from 'react-icons/fa'
import { useLocalization } from '@/utils/hooks/useLocalization'
const menuService = new MenuService()
// ─── IconPickerField ──────────────────────────────────────────────────────────
interface IconPickerFieldProps {
value: string
onChange: (iconKey: string) => void
invalid?: boolean
}
const ALL_ICON_ENTRIES = Object.entries(navigationIcon)
const ICON_PAGE_SIZE = 100
export function IconPickerField({ value, onChange, invalid }: IconPickerFieldProps) {
const [open, setOpen] = useState(false)
const [search, setSearch] = useState('')
const [limit, setLimit] = useState(ICON_PAGE_SIZE)
const wrapperRef = useRef<HTMLDivElement>(null)
const SelectedIcon = value ? navigationIcon[value] : null
const filtered = search.trim()
? ALL_ICON_ENTRIES.filter(([key]) => key.toLowerCase().includes(search.toLowerCase()))
: ALL_ICON_ENTRIES
const displayed = filtered.slice(0, limit)
const hasMore = displayed.length < filtered.length
const { translate } = useLocalization()
useEffect(() => {
setLimit(ICON_PAGE_SIZE)
}, [search])
useEffect(() => {
function handleClickOutside(e: MouseEvent) {
if (wrapperRef.current && !wrapperRef.current.contains(e.target as Node)) setOpen(false)
}
if (open) document.addEventListener('mousedown', handleClickOutside)
return () => document.removeEventListener('mousedown', handleClickOutside)
}, [open])
return (
<div ref={wrapperRef} className="relative">
<button
type="button"
onClick={() => setOpen((v) => !v)}
className={`flex items-center gap-2 w-full h-11 px-3 py-2 rounded-lg border text-sm text-left transition-colors
${invalid ? 'border-red-500' : 'border-gray-300 dark:border-gray-600'}
bg-white dark:bg-gray-800 text-gray-700 dark:text-gray-200 hover:border-indigo-400`}
>
{SelectedIcon ? (
<span className="flex items-center gap-2 flex-1 truncate">
<SelectedIcon className="text-xl shrink-0" />
<span className="text-xs text-gray-500 truncate">{value}</span>
</span>
) : (
<span className="text-gray-400 flex-1">
{translate('::ListForms.Wizard.Step1.SelectIcon') || 'Select icon...'}
</span>
)}
<FaChevronDown
className={`text-gray-400 text-xs transition-transform ${open ? 'rotate-180' : ''}`}
/>
</button>
{open && (
<div className="absolute z-50 mt-1 left-0 w-[360px] bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-600 rounded-lg shadow-xl">
<div className="p-2 border-b border-gray-100 dark:border-gray-700 flex items-center gap-2">
<input
autoFocus
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Search icons… (FaHome, FcSettings)"
className="flex-1 px-2 py-1.5 text-sm rounded border border-gray-200 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-100 outline-none focus:border-indigo-400"
/>
<span className="text-xs text-gray-400 shrink-0">{filtered.length} icons</span>
</div>
<div className="grid grid-cols-10 gap-0.5 p-2 max-h-[208px] overflow-y-auto">
{displayed.map(([key, Icon]) => (
<button
key={key}
type="button"
title={key}
onClick={() => {
onChange(key)
setOpen(false)
setSearch('')
}}
className={`flex items-center justify-center h-10 w-full rounded text-xl transition-colors
${value === key ? 'bg-indigo-500 text-white' : 'hover:bg-gray-100 dark:hover:bg-gray-700 text-gray-600 dark:text-gray-300'}`}
>
<Icon />
</button>
))}
</div>
{hasMore && (
<div className="px-3 py-1.5 border-t border-gray-100 dark:border-gray-700 flex items-center justify-between">
<span className="text-xs text-gray-400">
{displayed.length} / {filtered.length}
</span>
<button
type="button"
onClick={() => setLimit((l) => l + ICON_PAGE_SIZE)}
className="text-xs px-3 py-1 rounded bg-indigo-50 dark:bg-indigo-900/30 text-indigo-600 dark:text-indigo-400 hover:bg-indigo-100 dark:hover:bg-indigo-900/50 font-medium"
>
Load More
</button>
</div>
)}
</div>
)}
</div>
)
}
// ─── MenuAddDialog ────────────────────────────────────────────────────────────
export interface MenuAddDialogProps {
isOpen: boolean
onClose: () => void
initialParentCode: string
initialOrder: number
rawItems: (MenuItem & { id?: string })[]
onSaved: () => void
}
export function MenuAddDialog({
isOpen,
onClose,
initialParentCode,
initialOrder,
rawItems,
onSaved,
}: MenuAddDialogProps) {
const [form, setForm] = useState({
name: '',
code: '',
menuTextEn: '',
menuTextTr: '',
parentCode: initialParentCode,
icon: '',
shortName: '',
order: initialOrder,
})
const [saving, setSaving] = useState(false)
const { translate } = useLocalization()
useEffect(() => {
if (isOpen)
setForm({
name: '',
code: '',
menuTextEn: '',
menuTextTr: '',
parentCode: initialParentCode,
icon: '',
shortName: '',
order: initialOrder,
})
}, [isOpen, initialParentCode, initialOrder])
const shortNameRequired = !form.parentCode.trim()
console.log(form.parentCode.length)
const handleSave = async () => {
if (!form.code.trim() || !form.menuTextEn.trim()) return
if (shortNameRequired && !form.shortName.trim()) return
setSaving(true)
try {
await menuService.createWithLanguageKeyText({
code: form.code.trim(),
displayName: form.code.trim(),
parentCode: form.parentCode.trim() || undefined,
icon: form.icon || undefined,
shortName: form.shortName.trim() || undefined,
order: form.order,
isDisabled: false,
menuTextTr: form.menuTextTr.trim(),
menuTextEn: form.menuTextEn.trim(),
} as MenuDto)
onSaved()
onClose()
} catch (e: any) {
toast.push(<Notification title={e.message} type="danger" />, { placement: 'top-end' })
} finally {
setSaving(false)
}
}
// suppress unused warning — rawItems kept for future use
void rawItems
const fieldCls =
'h-11 px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-sm text-gray-700 dark:text-gray-200 outline-none focus:border-indigo-400 w-full'
const disabledCls =
'h-11 px-3 py-2 rounded-lg border border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-700 text-sm text-gray-400 dark:text-gray-500 cursor-not-allowed w-full'
const labelCls = 'text-xs font-medium text-gray-500 dark:text-gray-400 mb-1'
return (
<Dialog isOpen={isOpen} onClose={onClose} onRequestClose={onClose} width={680}>
<div className="flex flex-col gap-5 p-5">
{/* Header */}
<div className="flex items-center gap-2 pb-1 border-b border-gray-100 dark:border-gray-700">
<FaPlus className="text-green-500 text-sm" />
<h5 className="text-base font-semibold text-gray-800 dark:text-gray-100">
{translate('::ListForms.Wizard.Step1.AddNewMenu')}
</h5>
</div>
{/* Row 1 — Name | Code */}
<div className="grid grid-cols-2 gap-4">
<div className="flex flex-col">
<label className={labelCls}>
{translate('::App.Platform.Name') || 'Name'}{' '}
<span className="text-red-500">*</span>
</label>
<input
autoFocus
value={form.name}
onChange={(e) =>
setForm((p) => ({
...p,
name: e.target.value.replace(/\s+/g, ''),
code: `App.Wizard.${e.target.value.replace(/\s+/g, '')}`,
}))
}
placeholder="MyMenu"
className={fieldCls}
/>
</div>
<div className="flex flex-col">
<label className={labelCls}>
{translate('::App.Platform.Code') || 'Code'}{' '}
<span className="text-red-500">*</span>
</label>
<input value={form.code} disabled placeholder="App.Wizard.MyMenu" className={disabledCls} />
</div>
</div>
{/* Row 2 — Icon (full width) */}
<div className="flex flex-col">
<label className={labelCls}>
{translate('::ListForms.Wizard.Step4.Icon')} <span className="text-red-500">*</span>
</label>
<IconPickerField
value={form.icon}
onChange={(key) => setForm((p) => ({ ...p, icon: key }))}
/>
</div>
{/* Row 3 — Display Name EN | Display Name TR */}
<div className="grid grid-cols-2 gap-4">
<div className="flex flex-col">
<label className={labelCls}>
{translate('::ListForms.Wizard.Step1.DisplayNameEnglish')}{' '}
<span className="text-red-500">*</span>
</label>
<input
value={form.menuTextEn}
onChange={(e) => setForm((p) => ({ ...p, menuTextEn: e.target.value }))}
placeholder="My Menu"
className={fieldCls}
/>
</div>
<div className="flex flex-col">
<label className={labelCls}>
{translate('::ListForms.Wizard.Step1.DisplayNameTurkish')}{' '}
<span className="text-red-500">*</span>
</label>
<input
value={form.menuTextTr}
onChange={(e) => setForm((p) => ({ ...p, menuTextTr: e.target.value }))}
placeholder="Menüm"
className={fieldCls}
/>
</div>
</div>
{/* Row 4 — Menu Parent | Order */}
<div className="grid grid-cols-2 gap-4">
<div className="flex flex-col">
<label className={labelCls}>{translate('::ListForms.Wizard.Step1.MenuParent')}</label>
<input disabled value={form.parentCode} className={disabledCls} />
</div>
<div className="flex flex-col">
<label className={labelCls}>
{translate('::ListForms.Wizard.Step1.Order') || 'Sıra (Order)'}
</label>
<input
type="number"
value={form.order}
onChange={(e) => setForm((p) => ({ ...p, order: Number(e.target.value) }))}
className={fieldCls}
/>
</div>
</div>
{/* Row 5 — Short Name (full width) */}
<div className="flex flex-col">
<label className={labelCls}>
{translate('::ListForms.Wizard.Step1.ShortName')}
{shortNameRequired ? (
<span className="text-red-500 ml-0.5">*</span>
) : (
<span className="ml-1 text-gray-400 font-normal">
({translate('::ListForms.Wizard.Step1.Optional') || 'opsiyonel'})
</span>
)}
</label>
<input
value={form.shortName}
onChange={(e) => setForm((p) => ({ ...p, shortName: e.target.value }))}
placeholder="Sas, Finance, Hr…"
className={`${fieldCls}`}
/>
{shortNameRequired && (
<p className="text-xs text-gray-400 mt-1">
{translate('::ListForms.Wizard.Step1.ShortNameHint') ||
'Tablo prefix olarak kullanılır (örn. "Sas" → Sas_D_TableName)'}
</p>
)}
</div>
{/* Footer */}
<div className="flex justify-end gap-2 pt-1 border-t border-gray-100 dark:border-gray-700">
<Button size="sm" variant="plain" onClick={onClose}>
{translate('::Cancel') || 'İptal'}
</Button>
<Button
size="sm"
variant="solid"
loading={saving}
disabled={
!form.code.trim() ||
!form.menuTextEn.trim() ||
!form.menuTextTr.trim() ||
!form.icon.trim() ||
(shortNameRequired && !form.shortName.trim())
}
onClick={handleSave}
>
{translate('::App.Platform.Save') || 'Kaydet'}
</Button>
</div>
</div>
</Dialog>
)
}