sozsoft-platform/ui/src/views/shared/MenuAddDialog.tsx

413 lines
15 KiB
TypeScript
Raw Normal View History

2026-03-03 07:34:25 +00:00
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'
2026-08-16 15:30:07 +00:00
import { useNavigationIcons } from '@/proxy/menus/navigation-icon.config'
import { useEffect, useMemo, useRef, useState } from 'react'
2026-03-03 07:34:25 +00:00
import { FaChevronDown, FaPlus } from 'react-icons/fa'
import { useLocalization } from '@/utils/hooks/useLocalization'
import { useConfig } from '@/components/ui/ConfigProvider'
import { useForm } from '@/components/ui/Form/context'
2026-03-03 07:34:25 +00:00
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) {
2026-08-16 15:30:07 +00:00
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])
2026-03-03 07:34:25 +00:00
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
2026-03-03 07:34:25 +00:00
const SelectedIcon = value ? navigationIcon[value] : null
const filtered = search.trim()
2026-08-16 15:30:07 +00:00
? allIconEntries.filter(([key]) => key.toLowerCase().includes(search.toLowerCase()))
: allIconEntries
2026-03-03 07:34:25 +00:00
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 ı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.
2026-08-13 11:34:43 +00:00
Aynı sebeple kenarlık/arka plan da `!` ile verilir: `plain` variant
`bg-transparent border border-transparent` bastığı için `.input` kenarlığı
eziliyor ve alan ık temada görünmez oluyordu (yalnızca hover'da beliriyordu).
*/}
<Button
2026-03-03 07:34:25 +00:00
type="button"
onClick={() => setOpen((v) => !v)}
variant="plain"
2026-08-13 11:34:43 +00:00
shape="round"
size={controlSize}
2026-08-13 11:34:43 +00:00
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' : ''}`}
2026-03-03 07:34:25 +00:00
>
{SelectedIcon ? (
<span className="flex items-center gap-2 flex-1 truncate">
<SelectedIcon className="text-xl shrink-0" />
<span className="truncate">{value}</span>
2026-03-03 07:34:25 +00:00
</span>
) : (
<span className="flex-1 !text-gray-400">
{translate('::App.WizardStep1.SelectIcon')}
2026-03-03 07:34:25 +00:00
</span>
)}
<FaChevronDown
className={`shrink-0 text-gray-400 text-xs transition-transform ${open ? 'rotate-180' : ''}`}
2026-03-03 07:34:25 +00:00
/>
</Button>
2026-03-03 07:34:25 +00:00
{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
2026-03-03 07:34:25 +00:00
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
2026-03-03 07:34:25 +00:00
${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>
2026-03-03 07:34:25 +00:00
))}
</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
2026-03-03 07:34:25 +00:00
type="button"
onClick={() => setLimit((l) => l + ICON_PAGE_SIZE)}
size="xs"
2026-06-26 08:59:13 +00:00
variant="solid"
2026-03-03 07:34:25 +00:00
>
2026-06-26 08:59:13 +00:00
{translate('::Abp.Identity.ActivityLogs.LoadMore')}
</Button>
2026-03-03 07:34:25 +00:00
</div>
)}
</div>
)}
</div>
)
}
// ─── MenuAddDialog ────────────────────────────────────────────────────────────
export interface MenuAddDialogProps {
isOpen: boolean
onClose: () => void
initialParentCode: string
initialOrder: number
rawItems: (MenuItem & { id?: string })[]
onSaved: (menu: {
code: string
parentCode?: string
menuTextEn: string
menuTextTr: string
icon?: string
shortName?: string
}) => void | Promise<void>
2026-03-03 07:34:25 +00:00
}
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()
const handleSave = async () => {
if (!form.code.trim() || !form.menuTextEn.trim()) return
if (shortNameRequired && !form.shortName.trim()) return
setSaving(true)
try {
const savedMenu = {
2026-03-03 07:34:25 +00:00
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
2026-03-03 07:34:25 +00:00
await menuService.createWithLanguageKeyText(savedMenu)
await onSaved({
code: savedMenu.code!,
parentCode: savedMenu.parentCode,
menuTextEn: savedMenu.menuTextEn!,
menuTextTr: savedMenu.menuTextTr!,
icon: savedMenu.icon,
shortName: savedMenu.shortName,
})
2026-03-03 07:34:25 +00:00
onClose()
} catch (e: any) {
2026-07-08 12:34:13 +00:00
toast.push(<Notification title={e.message} type="danger" />, {
placement: 'bottom-end',
})
2026-03-03 07:34:25 +00:00
} finally {
setSaving(false)
}
}
// suppress unused warning — rawItems kept for future use
void rawItems
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'
2026-03-03 07:34:25 +00:00
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'
2026-03-03 07:34:25 +00:00
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()
2026-03-03 07:34:25 +00:00
return (
<Dialog isOpen={isOpen} onClose={onClose} onRequestClose={onClose} width={680}>
<div className="flex flex-col gap-5 p-1">
2026-03-03 07:34:25 +00:00
{/* 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('::App.Platform.AddNewMenu')}
2026-03-03 07:34:25 +00:00
</h5>
</div>
{/* Row 1 — Name | Code */}
<div className="grid grid-cols-2 gap-4">
<div className="flex flex-col">
<label className={labelCls}>
2026-08-14 11:32:31 +00:00
{translate('::App.Listform.ListformField.Name')}{' '}
2026-03-03 07:34:25 +00:00
<span className="text-red-500">*</span>
</label>
<input
autoFocus
value={form.name}
onChange={(e) => {
const codeToken = toCodeToken(e.target.value)
const spacedLabel = toSpacedLabel(codeToken)
2026-03-03 07:34:25 +00:00
setForm((p) => ({
...p,
name: codeToken,
code: `App.Wizard.${codeToken}`,
menuTextEn: spacedLabel,
menuTextTr: spacedLabel,
shortName: codeToken.substring(0, 3),
2026-03-03 07:34:25 +00:00
}))
}}
2026-03-03 07:34:25 +00:00
placeholder="MyMenu"
className={fieldCls}
/>
</div>
<div className="flex flex-col">
<label className={labelCls}>
2026-08-14 11:32:31 +00:00
{translate('::App.Platform.Code')} <span className="text-red-500">*</span>
2026-03-03 07:34:25 +00:00
</label>
<input
value={form.code}
disabled
placeholder="App.Wizard.MyMenu"
className={disabledCls}
/>
2026-03-03 07:34:25 +00:00
</div>
</div>
{/* Row 2 — Icon (full width) */}
<div className="flex flex-col">
<label className={labelCls}>
2026-03-29 08:59:07 +00:00
{translate('::App.Listform.ListformField.Icon')} <span className="text-red-500">*</span>
2026-03-03 07:34:25 +00:00
</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')}{' '}
2026-03-03 07:34:25 +00:00
<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')}{' '}
2026-03-03 07:34:25 +00:00
<span className="text-red-500">*</span>
</label>
<input
value={form.menuTextTr}
onChange={(e) => setForm((p) => ({ ...p, menuTextTr: e.target.value }))}
2026-08-14 11:32:31 +00:00
placeholder={translate('::App.Menu.NamePlaceholder')}
2026-03-03 07:34:25 +00:00
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>
2026-03-03 07:34:25 +00:00
<input disabled value={form.parentCode} className={disabledCls} />
</div>
<div className="flex flex-col">
2026-08-14 11:32:31 +00:00
<label className={labelCls}>{translate('::App.Listform.ListformField.Order')}</label>
2026-03-03 07:34:25 +00:00
<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')}
2026-03-03 07:34:25 +00:00
{shortNameRequired ? (
<span className="text-red-500 ml-0.5">*</span>
) : (
<span className="ml-1 text-gray-400 font-normal">
({translate('::App.WizardStep1.Optional')})
2026-03-03 07:34:25 +00:00
</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')}
2026-03-03 07:34:25 +00:00
</p>
)}
</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')}
2026-03-03 07:34:25 +00:00
</Button>
<Button
type="button"
2026-03-03 07:34:25 +00:00
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')}
2026-03-03 07:34:25 +00:00
</Button>
</div>
</div>
</Dialog>
)
}