sozsoft-platform/ui/src/views/shared/MenuAddDialog.tsx
2026-08-18 11:00:53 +03:00

520 lines
19 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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'
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<HTMLDivElement>(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 (
<div ref={wrapperRef} className="relative">
{/*
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).
*/}
<Button
type="button"
onClick={() => setOpen((v) => !v)}
variant="plain"
shape="round"
size={controlSize}
className={`input flex items-center gap-2 !justify-start !px-3 text-left text-sm !text-gray-700 dark:!text-gray-100
!bg-white dark:!bg-transparent !border !border-gray-300 dark:!border-gray-600 hover:!border-indigo-400
${invalid ? 'input-invalid !border-red-500' : ''}`}
>
{SelectedIcon ? (
<span className="flex items-center gap-2 flex-1 truncate">
<SelectedIcon className="text-xl shrink-0" />
<span className="truncate">{value}</span>
</span>
) : (
<span className="flex-1 !text-gray-400">{translate('::App.WizardStep1.SelectIcon')}</span>
)}
<FaChevronDown
className={`shrink-0 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('')
}}
variant="plain"
shape="none"
className={`!flex !h-10 !w-full !min-w-0 !items-center !justify-center !rounded !px-0 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 className="h-5 w-5" />
</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)}
size="xs"
variant="solid"
>
{translate('::Abp.Identity.ActivityLogs.LoadMore')}
</Button>
</div>
)}
</div>
)}
</div>
)
}
// ─── 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<void>
}
/** 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<string> {
const result = new Set<string>([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(<Notification title={e.message} type="danger" />, {
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 (
<Dialog isOpen={isOpen} onClose={onClose} onRequestClose={onClose} width={680}>
<div className="flex flex-col gap-5 p-1">
{/* Header */}
<div className="flex items-center gap-2 pb-1 border-b border-gray-100 dark:border-gray-700">
{isEditMode ? (
<FaEdit className="text-indigo-500 text-sm" />
) : (
<FaPlus className="text-green-500 text-sm" />
)}
<h5 className="text-base font-semibold text-gray-800 dark:text-gray-100">
{isEditMode
? translate('::App.Platform.EditMenu')
: translate('::App.Platform.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.Listform.ListformField.Name')}{' '}
<span className="text-red-500">*</span>
</label>
{/* Ad, kodu ve dil anahtarını türetir; düzenlemede kod sabit olduğu için kilitlidir. */}
<input
autoFocus={!isEditMode}
disabled={isEditMode}
value={form.name}
onChange={(e) => {
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}
/>
</div>
<div className="flex flex-col">
<label className={labelCls}>
{translate('::App.Platform.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('::App.Listform.ListformField.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('::App.Platform.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('::App.Platform.DisplayNameTurkish')}{' '}
<span className="text-red-500">*</span>
</label>
<input
value={form.menuTextTr}
onChange={(e) => setForm((p) => ({ ...p, menuTextTr: e.target.value }))}
placeholder={translate('::App.Menu.NamePlaceholder')}
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('::App.Platform.MenuParent')}</label>
{isEditMode ? (
<select
value={form.parentCode}
onChange={(e) => setForm((p) => ({ ...p, parentCode: e.target.value }))}
className={fieldCls}
>
<option value="">{translate('::App.WizardStep1.RootMenu')}</option>
{parentOptions.map((option) => (
<option key={option.code} value={option.code}>
{option.label}
</option>
))}
</select>
) : (
<input disabled value={form.parentCode} className={disabledCls} />
)}
</div>
<div className="flex flex-col">
<label className={labelCls}>{translate('::App.Listform.ListformField.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('::App.WizardStep1.ShortName')}
{shortNameRequired ? (
<span className="text-red-500 ml-0.5">*</span>
) : (
<span className="ml-1 text-gray-400 font-normal">
({translate('::App.WizardStep1.Optional')})
</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('::App.Platform.MenuCodeHint')}</p>
)}
</div>
{/* Row 6 — Url | Durum (yalnızca düzenlemede) */}
{isEditMode && (
<div className="grid grid-cols-2 gap-4">
<div className="flex flex-col">
<label className={labelCls}>{translate('::App.Platform.OpenUrl')}</label>
<input
value={form.url}
onChange={(e) => setForm((p) => ({ ...p, url: e.target.value }))}
placeholder="/admin/list/App.Wizard.MyMenu"
className={`${fieldCls} font-mono`}
/>
</div>
<div className="flex flex-col">
<label className={labelCls}>{translate('::App.Platform.Status')}</label>
<label className="flex h-9 cursor-pointer items-center gap-2">
<input
type="checkbox"
checked={!form.isDisabled}
onChange={(e) => setForm((p) => ({ ...p, isDisabled: !e.target.checked }))}
className="h-4 w-4 accent-indigo-500"
/>
<span className="text-sm text-gray-700 dark:text-gray-200">
{translate(
form.isDisabled
? '::App.Platform.Passive'
: '::App.Listform.ListformField.IsActive',
)}
</span>
</label>
</div>
</div>
)}
{/* Footer */}
<div className="flex justify-end gap-2 pt-1 border-t border-gray-100 dark:border-gray-700">
<Button type="button" size="sm" variant="plain" onClick={onClose}>
{translate('::App.Platform.Cancel')}
</Button>
<Button
type="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')}
</Button>
</div>
</div>
</Dialog>
)
}