sozsoft-platform/ui/src/views/shared/MenuAddDialog.tsx
2026-08-16 18:30:07 +03:00

412 lines
15 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, 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 })[]
onSaved: (menu: {
code: string
parentCode?: string
menuTextEn: string
menuTextTr: string
icon?: string
shortName?: string
}) => void | Promise<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()
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(),
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
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)
}
}
// 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'
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">
<FaPlus className="text-green-500 text-sm" />
<h5 className="text-base font-semibold text-gray-800 dark:text-gray-100">
{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>
<input
autoFocus
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={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>
<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>
{/* 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>
)
}