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 { useNavigationIcons } from '@/proxy/menus/navigation-icon.config' import { useEffect, useMemo, useRef, useState } from 'react' import { FaChevronDown, FaEdit, FaPlus } from 'react-icons/fa' import { useLocalization } from '@/utils/hooks/useLocalization' import { useConfig } from '@/components/ui/ConfigProvider' import { useForm } from '@/components/ui/Form/context' import Input from '@/components/ui/Input' const menuService = new MenuService() // ─── IconPickerField ────────────────────────────────────────────────────────── interface IconPickerFieldProps { value: string onChange: (iconKey: string) => void invalid?: boolean } const ICON_PAGE_SIZE = 100 export function IconPickerField({ value, onChange, invalid }: IconPickerFieldProps) { const navigationIcon = useNavigationIcons() // Sözlük asenkron dolduğu için liste yükleme bittiğinde yeniden türetilir. const allIconEntries = useMemo(() => Object.entries(navigationIcon), [navigationIcon]) const [open, setOpen] = useState(false) const [search, setSearch] = useState('') const [limit, setLimit] = useState(ICON_PAGE_SIZE) const wrapperRef = useRef(null) // Tetikleyici yüksekliği, komşu Input/Select alanlarıyla aynı olsun diye // Input bileşeniyle birebir aynı kaynaktan (form context → global config) okunur. const formSize = useForm()?.size const { controlSize: defaultControlSize } = useConfig() const controlSize = formSize ?? defaultControlSize const SelectedIcon = value ? navigationIcon[value] : null const filtered = search.trim() ? allIconEntries.filter(([key]) => key.toLowerCase().includes(search.toLowerCase())) : allIconEntries 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 (
{/* Tetikleyici, platformun `.input` sınıfını kullanır; böylece kenarlık, köşe yarıçapı, dolgu ve açık/koyu tema arka planı diğer form alanlarıyla birebir aynı olur. Metin renkleri `!` ile verilir: Button bileşeni variant'ına göre kendi `text-*` sınıfını basıyor ve aynı özgüllükte olduğu için eziyordu. Aynı sebeple kenarlık/arka plan da `!` ile verilir: `plain` variant `bg-transparent border border-transparent` bastığı için `.input` kenarlığı eziliyor ve alan açık temada görünmez oluyordu (yalnızca hover'da beliriyordu). */} {/* Panel genişliği tetikleyiciyi takip eder: sabit 360px, Visual Designer'ın dar property kolonunda yatay taşmaya ve ikonların sıkışmasına yol açıyordu. Alt sınır okunabilirliği, üst sınır geniş dialogdaki eski görünümü korur. */} {open && (
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" /> {filtered.length} icons
{displayed.map(([key, Icon]) => ( ))}
{hasMore && (
{displayed.length} / {filtered.length}
)}
)}
) } // ─── MenuAddDialog ──────────────────────────────────────────────────────────── export interface MenuAddDialogProps { isOpen: boolean onClose: () => void initialParentCode: string initialOrder: number rawItems: (MenuItem & { id?: string })[] /** * Dolu ise dialog düzenleme modunda açılır ve menünün tüm alanları değiştirilebilir. * Boş bırakılırsa yeni menü oluşturma modudur. */ editMenu?: (MenuItem & { id?: string }) | null onSaved: (menu: { code: string parentCode?: string menuTextEn: string menuTextTr: string icon?: string shortName?: string }) => void | Promise } /** Düzenleme modunda menü kendi alt ağacına taşınamaz; aksi halde döngü oluşur. */ function collectDescendantCodes( items: (MenuItem & { id?: string })[], rootCode: string, ): Set { const result = new Set([rootCode]) let grew = true while (grew) { grew = false for (const item of items) { if (!item.code || result.has(item.code)) continue if (item.parentCode && result.has(item.parentCode)) { result.add(item.code) grew = true } } } return result } const emptyForm = (parentCode: string, order: number) => ({ name: '', code: '', menuTextEn: '', menuTextTr: '', parentCode, icon: '', shortName: '', order, url: '', isDisabled: false, }) export function MenuAddDialog({ isOpen, onClose, initialParentCode, initialOrder, rawItems, editMenu, onSaved, }: MenuAddDialogProps) { const isEditMode = !!editMenu?.id const [form, setForm] = useState(() => emptyForm(initialParentCode, initialOrder)) const [saving, setSaving] = useState(false) const { translate } = useLocalization() useEffect(() => { if (!isOpen) return if (editMenu?.id) { setForm({ name: editMenu.code ?? '', code: editMenu.code ?? '', menuTextEn: editMenu.menuTextEn ?? '', menuTextTr: editMenu.menuTextTr ?? '', parentCode: editMenu.parentCode ?? '', icon: editMenu.icon ?? '', shortName: editMenu.shortName ?? '', order: editMenu.order ?? 0, url: editMenu.url ?? '', isDisabled: editMenu.isDisabled ?? false, }) return } setForm(emptyForm(initialParentCode, initialOrder)) }, [isOpen, initialParentCode, initialOrder, editMenu]) // Kök menülerde kısa ad zorunlu; alt menülerde isteğe bağlıdır. const shortNameRequired = !form.parentCode.trim() // Düzenlenen menü, kendisi veya alt ağacındaki bir menünün altına taşınamaz. const parentOptions = useMemo(() => { if (!isEditMode) return [] const blocked = collectDescendantCodes(rawItems, editMenu?.code ?? '') return rawItems .filter((item) => item.code && !blocked.has(item.code)) .map((item) => ({ code: item.code!, label: item.code! })) .sort((a, b) => a.label.localeCompare(b.label)) }, [isEditMode, rawItems, editMenu]) const handleSave = async () => { if (!form.code.trim() || !form.menuTextEn.trim()) return if (shortNameRequired && !form.shortName.trim()) return setSaving(true) try { const savedMenu = { code: form.code.trim(), // Görünen ad dil anahtarıdır; menü kodu ile aynı kalır. displayName: form.code.trim(), parentCode: form.parentCode.trim() || undefined, icon: form.icon || undefined, shortName: form.shortName.trim() || undefined, order: form.order, isDisabled: form.isDisabled, url: form.url.trim() || undefined, menuTextTr: form.menuTextTr.trim(), menuTextEn: form.menuTextEn.trim(), } as MenuDto if (isEditMode) { // Kod ve görünen ad dışındaki tüm alanlar güncellenir; sunucu kodu korur. await menuService.updateWithLanguageKeyText(editMenu!.id!, { ...(editMenu as MenuDto), ...savedMenu, }) } else { await menuService.createWithLanguageKeyText(savedMenu) } await onSaved({ code: savedMenu.code!, parentCode: savedMenu.parentCode, menuTextEn: savedMenu.menuTextEn!, menuTextTr: savedMenu.menuTextTr!, icon: savedMenu.icon, shortName: savedMenu.shortName, }) onClose() } catch (e: any) { toast.push(, { placement: 'bottom-end', }) } finally { setSaving(false) } } const fieldCls = 'h-9 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-9 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' const toCodeToken = (value: string) => value.replace(/\s+/g, '') const toSpacedLabel = (value: string) => value .replace(/([a-z0-9])([A-Z])/g, '$1 $2') .replace(/([A-Z])([A-Z][a-z])/g, '$1 $2') .trim() return (
{/* Header */}
{isEditMode ? ( ) : ( )}
{isEditMode ? translate('::App.Platform.EditMenu') : translate('::App.Platform.AddNewMenu')}
{/* Row 1 — Name | Code */}
{/* Ad, kodu ve dil anahtarını türetir; düzenlemede kod sabit olduğu için kilitlidir. */} { const codeToken = toCodeToken(e.target.value) const spacedLabel = toSpacedLabel(codeToken) setForm((p) => ({ ...p, name: codeToken, code: `App.Wizard.${codeToken}`, menuTextEn: spacedLabel, menuTextTr: spacedLabel, shortName: codeToken.substring(0, 3), })) }} placeholder="MyMenu" className={isEditMode ? disabledCls : fieldCls} />
{/* Row 2 — Icon (full width) */}
setForm((p) => ({ ...p, icon: key }))} />
{/* Row 3 — Display Name EN | Display Name TR */}
setForm((p) => ({ ...p, menuTextEn: e.target.value }))} placeholder="My Menu" className={fieldCls} />
setForm((p) => ({ ...p, menuTextTr: e.target.value }))} placeholder={translate('::App.Menu.NamePlaceholder')} className={fieldCls} />
{/* Row 4 — Menu Parent | Order */}
{isEditMode ? ( ) : ( )}
setForm((p) => ({ ...p, order: Number(e.target.value) }))} className={fieldCls} />
{/* Row 5 — Short Name (full width) */}
setForm((p) => ({ ...p, shortName: e.target.value }))} placeholder="Sas, Finance, Hr…" className={`${fieldCls}`} /> {shortNameRequired && (

{translate('::App.Platform.MenuCodeHint')}

)}
{/* Row 6 — Url | Durum (yalnızca düzenlemede) */} {isEditMode && (
setForm((p) => ({ ...p, url: e.target.value }))} placeholder="/admin/list/App.Wizard.MyMenu" className={`${fieldCls} font-mono`} />
)} {/* Footer */}
) }