sozsoft-platform/ui/src/views/shared/MenuAddDialog.tsx
2026-05-02 10:35:51 +03:00

367 lines
14 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 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()
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'
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-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.Listform.ListformField.Name') || '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') || '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('::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('::App.Listform.ListformField.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>
)
}