MenuAddDialog eklendi
This commit is contained in:
parent
490a73dde8
commit
c5ad419a27
4 changed files with 1111 additions and 804 deletions
|
|
@ -15836,6 +15836,12 @@
|
|||
"en": "Used to generate ListForm Code and Menu Code",
|
||||
"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",
|
||||
"key": "ListForms.Wizard.Step1.WizardName",
|
||||
|
|
|
|||
|
|
@ -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 { MenuDto } from '@/proxy/menus/models'
|
||||
import { SelectBoxOption } from '@/types/shared'
|
||||
|
|
@ -6,7 +6,7 @@ import navigationIcon from '@/proxy/menus/navigation-icon.config'
|
|||
import { MenuItem } from '@/proxy/menus/menu'
|
||||
import { MenuService } from '@/services/menu.service'
|
||||
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 {
|
||||
FaArrowRight,
|
||||
|
|
@ -19,6 +19,7 @@ import {
|
|||
FaTrash,
|
||||
} from 'react-icons/fa'
|
||||
import { useLocalization } from '@/utils/hooks/useLocalization'
|
||||
import { IconPickerField, MenuAddDialog } from '@/views/shared/MenuAddDialog'
|
||||
|
||||
// ─── 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 ──────────────────────────────────────────────────────────────
|
||||
|
||||
export interface WizardStep1Props {
|
||||
|
|
|
|||
|
|
@ -13,11 +13,21 @@ import {
|
|||
FaEdit,
|
||||
FaTimes,
|
||||
FaArrowRight,
|
||||
FaChevronDown,
|
||||
FaChevronRight,
|
||||
} from 'react-icons/fa'
|
||||
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 { 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 ────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
|
@ -44,11 +54,6 @@ interface ColumnDefinition {
|
|||
description: string
|
||||
}
|
||||
|
||||
interface MenuOption {
|
||||
value: string
|
||||
label: string
|
||||
}
|
||||
|
||||
interface TableSettings {
|
||||
menuValue: string
|
||||
menuPrefix: string
|
||||
|
|
@ -234,13 +239,27 @@ function generateCreateTableSql(
|
|||
for (const rel of relationships) {
|
||||
if (!rel.fkColumnName.trim() || !rel.referencedTable.trim()) continue
|
||||
const constraintName = `FK_${tableName}_${rel.fkColumnName}`
|
||||
const cascadeDelete = rel.cascadeDelete === 'NoAction' ? 'NO ACTION' : rel.cascadeDelete.replace(/([A-Z])/g, ' $1').trim().toUpperCase()
|
||||
const cascadeUpdate = rel.cascadeUpdate === 'NoAction' ? 'NO ACTION' : rel.cascadeUpdate.replace(/([A-Z])/g, ' $1').trim().toUpperCase()
|
||||
const cascadeDelete =
|
||||
rel.cascadeDelete === 'NoAction'
|
||||
? 'NO ACTION'
|
||||
: rel.cascadeDelete
|
||||
.replace(/([A-Z])/g, ' $1')
|
||||
.trim()
|
||||
.toUpperCase()
|
||||
const cascadeUpdate =
|
||||
rel.cascadeUpdate === 'NoAction'
|
||||
? 'NO ACTION'
|
||||
: rel.cascadeUpdate
|
||||
.replace(/([A-Z])/g, ' $1')
|
||||
.trim()
|
||||
.toUpperCase()
|
||||
fkLines.push('')
|
||||
fkLines.push(`ALTER TABLE ${fullTableName}`)
|
||||
fkLines.push(` ADD CONSTRAINT [${constraintName}]`)
|
||||
fkLines.push(` FOREIGN KEY ([${rel.fkColumnName}])`)
|
||||
fkLines.push(` REFERENCES [dbo].[${rel.referencedTable}] ([${rel.referencedColumn || 'Id'}])`)
|
||||
fkLines.push(
|
||||
` REFERENCES [dbo].[${rel.referencedTable}] ([${rel.referencedColumn || 'Id'}])`,
|
||||
)
|
||||
fkLines.push(` ON DELETE ${cascadeDelete}`)
|
||||
fkLines.push(` ON UPDATE ${cascadeUpdate};`)
|
||||
}
|
||||
|
|
@ -359,7 +378,8 @@ function generateAlterTableSql(
|
|||
const orig = origById.get(col.id)
|
||||
if (!orig) return // new column, already handled above
|
||||
|
||||
const nameChanged = orig.columnName.trim().toLowerCase() !== col.columnName.trim().toLowerCase()
|
||||
const nameChanged =
|
||||
orig.columnName.trim().toLowerCase() !== col.columnName.trim().toLowerCase()
|
||||
const typeChanged = orig.dataType !== col.dataType || orig.maxLength !== col.maxLength
|
||||
const nullChanged = orig.isNullable !== col.isNullable
|
||||
|
||||
|
|
@ -390,7 +410,12 @@ function generateAlterTableSql(
|
|||
|
||||
// 🔗 FK Diff: drop removed / drop+re-add modified / add new
|
||||
const fkCascadeSql = (b: CascadeBehavior) =>
|
||||
b === 'NoAction' ? 'NO ACTION' : b.replace(/([A-Z])/g, ' $1').trim().toUpperCase()
|
||||
b === 'NoAction'
|
||||
? 'NO ACTION'
|
||||
: b
|
||||
.replace(/([A-Z])/g, ' $1')
|
||||
.trim()
|
||||
.toUpperCase()
|
||||
|
||||
const addFkSql = (rel: SqlTableRelation) => {
|
||||
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
|
||||
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 => ({
|
||||
id: crypto.randomUUID(),
|
||||
columnName: '',
|
||||
|
|
@ -497,7 +654,10 @@ const SqlTableDesignerDialog = ({
|
|||
const [originalColumns, setOriginalColumns] = useState<ColumnDefinition[]>([])
|
||||
const [colsLoading, setColsLoading] = useState(false)
|
||||
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 [relationships, setRelationships] = useState<SqlTableRelation[]>([])
|
||||
const [originalRelationships, setOriginalRelationships] = useState<SqlTableRelation[]>([])
|
||||
|
|
@ -509,21 +669,32 @@ const SqlTableDesignerDialog = ({
|
|||
const [targetTableColumns, setTargetTableColumns] = useState<string[]>([])
|
||||
const [targetColsLoading, setTargetColsLoading] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) return
|
||||
const reloadMenus = (onLoaded?: (items: MenuItem[]) => void) => {
|
||||
setMenuLoading(true)
|
||||
const svc = new MenuService()
|
||||
svc
|
||||
.getListMainMenu()
|
||||
getMenus(0, 1000)
|
||||
.then((res) => {
|
||||
const opts: MenuOption[] = (res.data || []).map((m: any) => ({
|
||||
value: m.shortName,
|
||||
label: m.displayName,
|
||||
}))
|
||||
setMenuOptions(opts)
|
||||
const items = (res.data?.items ?? []) as MenuItem[]
|
||||
const filtered = items.filter((m) => !!m.shortName?.trim())
|
||||
setRawMenuItems(filtered)
|
||||
const tree = filterNonLinkNodes(buildMenuTree(filtered))
|
||||
setMenuTree(tree)
|
||||
onLoaded?.(filtered)
|
||||
})
|
||||
.catch(() => {})
|
||||
.finally(() => setMenuLoading(false))
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) return
|
||||
reloadMenus((items) => {
|
||||
// In edit mode, auto-select the matching menu code by shortName
|
||||
if (initialTableData) {
|
||||
const parts = initialTableData.tableName.split('_')
|
||||
const derivedShortName = parts[0] ?? ''
|
||||
const match = items.find((m) => m.shortName === derivedShortName)
|
||||
if (match?.code) setSelectedMenuCode(match.code)
|
||||
}
|
||||
})
|
||||
|
||||
if (dataSource) {
|
||||
sqlObjectManagerService
|
||||
|
|
@ -618,7 +789,15 @@ const SqlTableDesignerDialog = ({
|
|||
originalRelationships,
|
||||
)
|
||||
: generateCreateTableSql(columns, settings, relationships),
|
||||
[isEditMode, originalColumns, columns, settings, initialTableData, relationships, originalRelationships],
|
||||
[
|
||||
isEditMode,
|
||||
originalColumns,
|
||||
columns,
|
||||
settings,
|
||||
initialTableData,
|
||||
relationships,
|
||||
originalRelationships,
|
||||
],
|
||||
)
|
||||
|
||||
// ── Column operations ──────────────────────────────────────────────────────
|
||||
|
|
@ -629,9 +808,7 @@ const SqlTableDesignerDialog = ({
|
|||
|
||||
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()),
|
||||
)
|
||||
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()]
|
||||
|
|
@ -674,12 +851,14 @@ const SqlTableDesignerDialog = ({
|
|||
const buildTableName = (prefix: string, entity: string) =>
|
||||
prefix && entity ? `${prefix}_D_${entity}` : ''
|
||||
|
||||
const onMenuChange = (value: string) => {
|
||||
const opt = menuOptions.find((o) => o.value === value)
|
||||
const prefix = opt?.value ?? ''
|
||||
const onMenuCodeSelect = (code: string) => {
|
||||
if (isEditMode) return
|
||||
const item = rawMenuItems.find((m) => m.code === code)
|
||||
const prefix = item?.shortName ?? ''
|
||||
setSelectedMenuCode(code)
|
||||
setSettings((s) => ({
|
||||
...s,
|
||||
menuValue: value,
|
||||
menuValue: prefix,
|
||||
menuPrefix: prefix,
|
||||
tableName: buildTableName(prefix, s.entityName),
|
||||
}))
|
||||
|
|
@ -813,7 +992,8 @@ const SqlTableDesignerDialog = ({
|
|||
} catch (error: any) {
|
||||
toast.push(
|
||||
<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>,
|
||||
{ placement: 'top-center' },
|
||||
)
|
||||
|
|
@ -831,6 +1011,8 @@ const SqlTableDesignerDialog = ({
|
|||
setOriginalRelationships([])
|
||||
setDbTables([])
|
||||
setTargetTableColumns([])
|
||||
setSelectedMenuCode('')
|
||||
setMenuAddDialogOpen(false)
|
||||
onClose()
|
||||
}
|
||||
|
||||
|
|
@ -878,7 +1060,8 @@ const SqlTableDesignerDialog = ({
|
|||
<div className="flex flex-col">
|
||||
{colsLoading && (
|
||||
<div className="flex items-center justify-center py-12 text-gray-500">
|
||||
<span className="animate-spin mr-2">◠</span> {translate('::App.SqlQueryManager.LoadingColumns')}
|
||||
<span className="animate-spin mr-2">◠</span>{' '}
|
||||
{translate('::App.SqlQueryManager.LoadingColumns')}
|
||||
</div>
|
||||
)}
|
||||
{!colsLoading && (
|
||||
|
|
@ -893,10 +1076,22 @@ const SqlTableDesignerDialog = ({
|
|||
</Button>
|
||||
</div>
|
||||
<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')}
|
||||
</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')}
|
||||
</Button>
|
||||
</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.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')}</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-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>
|
||||
|
||||
{/* Editable column rows */}
|
||||
|
|
@ -930,9 +1129,7 @@ const SqlTableDesignerDialog = ({
|
|||
const isDuplicate =
|
||||
col.columnName.trim() !== '' &&
|
||||
duplicateColumnNames.has(col.columnName.trim().toLowerCase())
|
||||
const isNewRow =
|
||||
isEditMode &&
|
||||
!originalColumns.some((o) => o.id === col.id)
|
||||
const isNewRow = isEditMode && !originalColumns.some((o) => o.id === col.id)
|
||||
|
||||
const origRow = isEditMode ? originalColumns.find((o) => o.id === col.id) : undefined
|
||||
const isRenamedRow =
|
||||
|
|
@ -1047,7 +1244,8 @@ const SqlTableDesignerDialog = ({
|
|||
<button
|
||||
onClick={() => removeColumn(col.id)}
|
||||
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" />
|
||||
</button>
|
||||
</div>
|
||||
|
|
@ -1057,8 +1255,7 @@ const SqlTableDesignerDialog = ({
|
|||
</div>
|
||||
|
||||
{/* Id warning */}
|
||||
{!isEditMode &&
|
||||
!columns.some((c) => c.columnName.trim().toLowerCase() === 'id') && (
|
||||
{!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">
|
||||
{translate('::App.SqlQueryManager.NoIdColumnWarning')}
|
||||
</div>
|
||||
|
|
@ -1075,23 +1272,57 @@ const SqlTableDesignerDialog = ({
|
|||
<div className="grid grid-cols-2 gap-2">
|
||||
{/* Menu Name */}
|
||||
<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>
|
||||
</label>
|
||||
<select
|
||||
className="w-full px-3 py-2 text-sm border rounded dark:bg-gray-700 dark:border-gray-600 dark:text-white"
|
||||
value={settings.menuValue}
|
||||
disabled={menuLoading || isEditMode} // Menu cannot be changed in edit mode (as it determines the table name)
|
||||
onChange={(e) => onMenuChange(e.target.value)}
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
disabled={isEditMode}
|
||||
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>
|
||||
{menuOptions.map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>
|
||||
{opt.label} ({opt.value})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{menuLoading && <p className="text-xs text-gray-400 mt-1">{translate('::App.SqlQueryManager.Loading')}</p>}
|
||||
<FaPlus className="text-xs" />{' '}
|
||||
{translate('::ListForms.Wizard.Step1.AddNewMenu') || 'Add Menu'}
|
||||
</button>
|
||||
{settings.menuValue && !isEditMode && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
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>
|
||||
|
||||
{/* Entity Name */}
|
||||
|
|
@ -1111,7 +1342,9 @@ const SqlTableDesignerDialog = ({
|
|||
|
||||
{/* Table Name (readonly, auto-generated) */}
|
||||
<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
|
||||
type="text"
|
||||
readOnly
|
||||
|
|
@ -1122,10 +1355,8 @@ const SqlTableDesignerDialog = ({
|
|||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
{/* Warning: no Id column */}
|
||||
{!isEditMode &&
|
||||
!columns.some((c) => c.columnName.trim().toLowerCase() === 'id') && (
|
||||
{!isEditMode && !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">
|
||||
{translate('::App.SqlQueryManager.NoIdColumnError')}
|
||||
</div>
|
||||
|
|
@ -1140,10 +1371,12 @@ const SqlTableDesignerDialog = ({
|
|||
{/* Loading indicator for edit mode */}
|
||||
{fksLoading && (
|
||||
<div className="flex items-center justify-center py-10 text-gray-500 text-sm gap-2">
|
||||
<span className="animate-spin">◠</span> {translate('::App.SqlQueryManager.LoadingFkConstraints')}
|
||||
<span className="animate-spin">◠</span>{' '}
|
||||
{translate('::App.SqlQueryManager.LoadingFkConstraints')}
|
||||
</div>
|
||||
)}
|
||||
{!fksLoading && <>
|
||||
{!fksLoading && (
|
||||
<>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs text-gray-500">
|
||||
|
|
@ -1155,7 +1388,8 @@ const SqlTableDesignerDialog = ({
|
|||
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"
|
||||
>
|
||||
<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>
|
||||
</div>
|
||||
|
||||
|
|
@ -1163,8 +1397,12 @@ const SqlTableDesignerDialog = ({
|
|||
{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>
|
||||
<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>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
|
@ -1192,10 +1430,16 @@ const SqlTableDesignerDialog = ({
|
|||
</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>
|
||||
<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">{translate('::App.SqlQueryManager.Required')}</span>
|
||||
<span className="text-orange-600 dark:text-orange-400">
|
||||
{translate('::App.SqlQueryManager.Required')}
|
||||
</span>
|
||||
)}
|
||||
{rel.description && (
|
||||
<span className="text-gray-400 italic">{rel.description}</span>
|
||||
|
|
@ -1221,16 +1465,20 @@ const SqlTableDesignerDialog = ({
|
|||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 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="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')}
|
||||
{editingFkId
|
||||
? translate('::App.SqlQueryManager.EditRelationship')
|
||||
: translate('::App.SqlQueryManager.AddNewRelationship')}
|
||||
</h2>
|
||||
<button
|
||||
onClick={() => setFkModalOpen(false)}
|
||||
|
|
@ -1277,7 +1525,9 @@ const SqlTableDesignerDialog = ({
|
|||
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"
|
||||
>
|
||||
<option value="">{translate('::App.SqlQueryManager.SelectPlaceholder')}</option>
|
||||
<option value="">
|
||||
{translate('::App.SqlQueryManager.SelectPlaceholder')}
|
||||
</option>
|
||||
{columns
|
||||
.filter((c) => c.columnName.trim())
|
||||
.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"
|
||||
>
|
||||
<option value="">{translate('::App.SqlQueryManager.SelectPlaceholder')}</option>
|
||||
<option value="">
|
||||
{translate('::App.SqlQueryManager.SelectPlaceholder')}
|
||||
</option>
|
||||
{dbTables.map((t) => (
|
||||
<option key={`${t.schemaName}.${t.tableName}`} value={t.tableName}>
|
||||
{t.tableName}
|
||||
|
|
@ -1312,7 +1564,8 @@ const SqlTableDesignerDialog = ({
|
|||
|
||||
<div>
|
||||
<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>
|
||||
<select
|
||||
value={fkForm.referencedColumn}
|
||||
|
|
@ -1320,7 +1573,9 @@ const SqlTableDesignerDialog = ({
|
|||
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"
|
||||
>
|
||||
<option value="">{translate('::App.SqlQueryManager.SelectTargetTableFirst')}</option>
|
||||
<option value="">
|
||||
{translate('::App.SqlQueryManager.SelectTargetTableFirst')}
|
||||
</option>
|
||||
{targetTableColumns.map((col) => (
|
||||
<option key={col} value={col}>
|
||||
{col}
|
||||
|
|
@ -1338,12 +1593,17 @@ const SqlTableDesignerDialog = ({
|
|||
<select
|
||||
value={fkForm.cascadeUpdate}
|
||||
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"
|
||||
>
|
||||
{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>
|
||||
</div>
|
||||
|
|
@ -1354,12 +1614,17 @@ const SqlTableDesignerDialog = ({
|
|||
<select
|
||||
value={fkForm.cascadeDelete}
|
||||
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"
|
||||
>
|
||||
{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>
|
||||
</div>
|
||||
|
|
@ -1374,7 +1639,9 @@ const SqlTableDesignerDialog = ({
|
|||
onChange={(e) => setFkForm((f) => ({ ...f, isRequired: e.target.checked }))}
|
||||
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>
|
||||
</div>
|
||||
|
||||
|
|
@ -1411,7 +1678,7 @@ const SqlTableDesignerDialog = ({
|
|||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
document.body,
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
|
@ -1492,7 +1759,11 @@ const SqlTableDesignerDialog = ({
|
|||
icon={<FaCloudUploadAlt />}
|
||||
onClick={handleDeploy}
|
||||
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')}
|
||||
</Button>
|
||||
|
|
|
|||
355
ui/src/views/shared/MenuAddDialog.tsx
Normal file
355
ui/src/views/shared/MenuAddDialog.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
Loading…
Reference in a new issue