Wizard Step1 güncellemesi

This commit is contained in:
Sedat ÖZTÜRK 2026-02-27 16:43:03 +03:00
parent 877d0e7397
commit aac3f4aa80
5 changed files with 818 additions and 219 deletions

View file

@ -6,6 +6,7 @@ namespace Sozsoft.Platform.ListForms;
public class WizardCreateInputDto public class WizardCreateInputDto
{ {
public string ListFormCode { get; set; } public string ListFormCode { get; set; }
public string MenuCode { get; set; }
public string LanguageTextMenuEn { get; set; } public string LanguageTextMenuEn { get; set; }
public string LanguageTextMenuTr { get; set; } public string LanguageTextMenuTr { get; set; }

View file

@ -1404,6 +1404,18 @@
"en": "Wizard", "en": "Wizard",
"tr": "Sihirbaz" "tr": "Sihirbaz"
}, },
{
"resourceName": "Platform",
"key": "ListForms.Wizard.MenuInfo",
"en": "Menu Information",
"tr": "Menü Bilgileri"
},
{
"resourceName": "Platform",
"key": "ListForms.Wizard.ListFormSettings",
"en": "List Form Settings",
"tr": "Liste Formu Ayarları"
},
{ {
"resourceName": "Platform", "resourceName": "Platform",
"key": "App.Listforms.DataSource", "key": "App.Listforms.DataSource",

View file

@ -17,6 +17,7 @@ import {
export interface ListFormWizardDto { export interface ListFormWizardDto {
listFormCode: string listFormCode: string
menuCode: string
languageTextMenuEn: string languageTextMenuEn: string
languageTextMenuTr: string languageTextMenuTr: string
languageTextTitleEn: string languageTextTitleEn: string

View file

@ -1,4 +1,4 @@
import Container from '@/components/shared/Container' import Container from '@/components/shared/Container'
import { import {
Button, Button,
FormContainer, FormContainer,
@ -26,40 +26,49 @@ import { DbTypeEnum, SelectCommandTypeEnum } from '@/proxy/form/models'
import { postListFormWizard } from '@/services/admin/list-form.service' import { postListFormWizard } from '@/services/admin/list-form.service'
import { getDataSources } from '@/services/data-source.service' import { getDataSources } from '@/services/data-source.service'
import { APP_NAME } from '@/constants/app.constant' import { APP_NAME } from '@/constants/app.constant'
import { MenuItem } from '@/proxy/menus/menu'
import WizardStep1, {
MenuTreeNode,
buildMenuTree,
filterNonLinkNodes,
findRootCode,
} from './WizardStep1'
// ─── Formik initial values & validation ──────────────────────────────────────
const initialValues: ListFormWizardDto = { const initialValues: ListFormWizardDto = {
listFormCode: '', listFormCode: '',
menuCode: '',
languageTextMenuEn: '', languageTextMenuEn: '',
languageTextMenuTr: '', languageTextMenuTr: '',
languageTextTitleEn: '', languageTextTitleEn: '',
languageTextTitleTr: '', languageTextTitleTr: '',
languageTextDescEn: '', languageTextDescEn: '',
languageTextDescTr: '', languageTextDescTr: '',
languageTextMenuParentEn: '', // Cascade: menuParentCode languageTextMenuParentEn: '',
languageTextMenuParentTr: '', // Cascade: menuParentCode languageTextMenuParentTr: '',
permissionGroupName: '', // Creatable permissionGroupName: '',
menuParentCode: '', // Creatable menuParentCode: '',
menuIcon: '', // Select menuIcon: '',
dataSourceCode: '', // Select dataSourceCode: '',
dataSourceConnectionString: '', // Cascade: dataSourceCode dataSourceConnectionString: '',
selectCommandType: SelectCommandTypeEnum.Table, // Select: Enum SelectCommandTypeEnum selectCommandType: SelectCommandTypeEnum.Table,
selectCommand: '', selectCommand: '',
keyFieldName: '', keyFieldName: '',
keyFieldDbSourceType: DbTypeEnum.Int32, // Select: Enum dbSourceTypeOptions keyFieldDbSourceType: DbTypeEnum.Int32,
} }
const step1ValidationSchema = Yup.object().shape({ const step1ValidationSchema = Yup.object().shape({
listFormCode: Yup.string().required(), menuCode: Yup.string().required(),
permissionGroupName: Yup.string().required(), permissionGroupName: Yup.string().required(),
menuParentCode: Yup.string().required(), menuParentCode: Yup.string().required(),
menuIcon: Yup.string(), languageTextMenuEn: Yup.string().required(),
languageTextMenuEn: Yup.string(), languageTextMenuTr: Yup.string().required(),
languageTextMenuTr: Yup.string(),
languageTextMenuParentEn: Yup.string(),
languageTextMenuParentTr: Yup.string(),
}) })
const step2ValidationSchema = Yup.object().shape({ const step2ValidationSchema = Yup.object().shape({
listFormCode: Yup.string().required(),
languageTextTitleEn: Yup.string(), languageTextTitleEn: Yup.string(),
languageTextTitleTr: Yup.string(), languageTextTitleTr: Yup.string(),
languageTextDescEn: Yup.string(), languageTextDescEn: Yup.string(),
@ -74,10 +83,13 @@ const step2ValidationSchema = Yup.object().shape({
const listFormValidationSchema = step1ValidationSchema.concat(step2ValidationSchema) const listFormValidationSchema = step1ValidationSchema.concat(step2ValidationSchema)
const Wizard = () => { // ─── Wizard ───────────────────────────────────────────────────────────────────
const { translate } = useLocalization()
const [currentStep, setCurrentStep] = useState(0)
const Wizard = () => {
const [currentStep, setCurrentStep] = useState(0)
const [wizardName, setWizardName] = useState('')
// ── Data Source ──
const [isLoadingDataSource, setIsLoadingDataSource] = useState(false) const [isLoadingDataSource, setIsLoadingDataSource] = useState(false)
const [dataSourceList, setDataSourceList] = useState<SelectBoxOption[]>([]) const [dataSourceList, setDataSourceList] = useState<SelectBoxOption[]>([])
const [isDataSourceNew, setIsDataSourceNew] = useState(false) const [isDataSourceNew, setIsDataSourceNew] = useState(false)
@ -86,32 +98,30 @@ const Wizard = () => {
const response = await getDataSources() const response = await getDataSources()
if (response.data?.items) { if (response.data?.items) {
setDataSourceList( setDataSourceList(
response.data.items.map((item: any) => ({ response.data.items.map((item: any) => ({ value: item.code, label: item.code })),
value: item.code,
label: item.code,
})),
) )
} }
setIsLoadingDataSource(false) setIsLoadingDataSource(false)
} }
// ── Menu ──
const [isLoadingMenu, setIsLoadingMenu] = useState(false) const [isLoadingMenu, setIsLoadingMenu] = useState(false)
const [menuList, setMenuList] = useState<SelectBoxOption[]>([]) const [menuTree, setMenuTree] = useState<MenuTreeNode[]>([])
const [isMenuNew, setIsMenuNew] = useState(false) const [rawMenuItems, setRawMenuItems] = useState<(MenuItem & { id?: string })[]>([])
const { translate } = useLocalization()
const getMenuList = async () => { const getMenuList = async () => {
setIsLoadingMenu(true) setIsLoadingMenu(true)
const response = await getMenus() const response = await getMenus()
if (response.data?.items) { if (response.data?.items) {
setMenuList( const items = response.data.items as (MenuItem & { id?: string })[]
response.data.items.map((item: any) => ({ setRawMenuItems(items)
value: item.code, const fullTree = buildMenuTree(items)
label: item.displayName, setMenuTree(filterNonLinkNodes(fullTree))
})),
)
} }
setIsLoadingMenu(false) setIsLoadingMenu(false)
} }
// ── Permission Groups ──
const [isLoadingPermissionGroup, setIsLoadingPermissionGroup] = useState(false) const [isLoadingPermissionGroup, setIsLoadingPermissionGroup] = useState(false)
const [permissionGroupList, setPermissionGroupList] = useState<SelectBoxOption[]>([]) const [permissionGroupList, setPermissionGroupList] = useState<SelectBoxOption[]>([])
const getPermissionGroupList = async () => { const getPermissionGroupList = async () => {
@ -119,10 +129,7 @@ const Wizard = () => {
const response = await getPermissions('R', '') const response = await getPermissions('R', '')
if (response.data?.groups) { if (response.data?.groups) {
setPermissionGroupList( setPermissionGroupList(
response.data.groups.map((item: any) => ({ response.data.groups.map((item: any) => ({ value: item.name, label: item.displayName })),
value: item.name,
label: item.displayName,
})),
) )
} }
setIsLoadingPermissionGroup(false) setIsLoadingPermissionGroup(false)
@ -137,25 +144,73 @@ const Wizard = () => {
const navigate = useNavigate() const navigate = useNavigate()
const formikRef = useRef<FormikProps<ListFormWizardDto>>(null) const formikRef = useRef<FormikProps<ListFormWizardDto>>(null)
// Auto-derive listFormCode from wizardName
const deriveListFormCode = (name: string) => {
const sanitized = name.replace(/[^a-zA-Z0-9]/g, '')
return sanitized ? `App.${sanitized}` : ''
}
const handleWizardNameChange = (name: string) => {
setWizardName(name)
const derived = deriveListFormCode(name)
formikRef.current?.setFieldValue('listFormCode', derived)
formikRef.current?.setFieldValue('menuCode', derived)
}
const handleMenuParentChange = (code: string) => {
formikRef.current?.setFieldValue('menuParentCode', code)
if (!code) return
const rootCode = findRootCode(rawMenuItems, code)
const rootItem = rawMenuItems.find((i) => i.code === rootCode)
// 1. Use group field if set
if (rootItem?.group) {
formikRef.current?.setFieldValue('permissionGroupName', rootItem.group)
return
}
// 2. Match root code exactly against permission group names (e.g. "App.Saas" -> "App.Saas")
const exactMatch = permissionGroupList.find((g) => g.value === rootCode)
if (exactMatch) {
formikRef.current?.setFieldValue('permissionGroupName', exactMatch.value)
return
}
// 3. Partial: find a group whose name contains the last segment of the root code
const lastSegment = rootCode.split('.').pop()?.toLowerCase() ?? ''
if (lastSegment) {
const partialMatch = permissionGroupList.find((g) =>
g.value?.toLowerCase().includes(lastSegment),
)
if (partialMatch) {
formikRef.current?.setFieldValue('permissionGroupName', partialMatch.value)
return
}
}
// 4. Fallback: set rootCode as a new creatable value (SelectBox supports free input)
formikRef.current?.setFieldValue('permissionGroupName', rootCode)
}
const handleNext = async () => { const handleNext = async () => {
if (!formikRef.current) return if (!formikRef.current) return
const errors = await formikRef.current.validateForm() const errors = await formikRef.current.validateForm()
const step1Fields = Object.keys(step1ValidationSchema.fields) const step1Fields = Object.keys(step1ValidationSchema.fields)
const hasStep1Errors = step1Fields.some((f) => errors[f as keyof ListFormWizardDto]) const hasStep1Errors = step1Fields.some((f) => errors[f as keyof ListFormWizardDto])
// Touch all step 1 fields so errors appear
// Always mark step1 fields touched so validation errors become visible
const touchedStep1 = step1Fields.reduce( const touchedStep1 = step1Fields.reduce(
(acc, key) => ({ ...acc, [key]: true }), (acc, key) => ({ ...acc, [key]: true }),
{} as Record<string, boolean>, {} as Record<string, boolean>,
) )
formikRef.current.setTouched({ ...formikRef.current.touched, ...touchedStep1 }) await formikRef.current.setTouched({ ...formikRef.current.touched, ...touchedStep1 })
if (!hasStep1Errors) {
// Also require wizardName
if (!wizardName.trim() || hasStep1Errors) return
setCurrentStep(1) setCurrentStep(1)
} }
}
const handleBack = () => { const handleBack = () => setCurrentStep(0)
setCurrentStep(0)
}
return ( return (
<Container> <Container>
@ -163,12 +218,14 @@ const Wizard = () => {
titleTemplate={`%s | ${APP_NAME}`} titleTemplate={`%s | ${APP_NAME}`}
title={translate('::' + 'App.Listforms.Wizard')} title={translate('::' + 'App.Listforms.Wizard')}
defaultTitle={APP_NAME} defaultTitle={APP_NAME}
></Helmet> />
<div className="mb-8"> <div className="mb-8">
<Steps current={currentStep}> <Steps current={currentStep}>
<Steps.Item title={translate('::ListForms.Wizard.BasicInfo') || 'Basic Info'} /> <Steps.Item title={translate('::ListForms.Wizard.MenuInfo') || 'Menu Info'} />
<Steps.Item title={translate('::ListForms.Wizard.DataSettings') || 'Data Settings'} /> <Steps.Item
title={translate('::ListForms.Wizard.ListFormSettings') || 'List Form Settings'}
/>
</Steps> </Steps>
</div> </div>
@ -185,9 +242,7 @@ const Wizard = () => {
<Notification type="success" duration={2000}> <Notification type="success" duration={2000}>
{translate('::ListForms.FormBilgileriKaydedildi')} {translate('::ListForms.FormBilgileriKaydedildi')}
</Notification>, </Notification>,
{ { placement: 'top-end' },
placement: 'top-end',
},
) )
setSubmitting(false) setSubmitting(false)
setTimeout(() => { setTimeout(() => {
@ -210,181 +265,49 @@ const Wizard = () => {
<FormContainer size="sm"> <FormContainer size="sm">
{/* ─── Step 1: Basic Info ─────────────────────────────── */} {/* ─── Step 1: Basic Info ─────────────────────────────── */}
{currentStep === 0 && ( {currentStep === 0 && (
<> <WizardStep1
<FormItem values={values}
label="ListForm Code" errors={errors}
invalid={errors.listFormCode && touched.listFormCode} touched={touched}
errorMessage={errors.listFormCode} wizardName={wizardName}
asterisk={true} onWizardNameChange={handleWizardNameChange}
> rawMenuItems={rawMenuItems}
<Field menuTree={menuTree}
type="text" isLoadingMenu={isLoadingMenu}
autoComplete="off" onMenuParentChange={handleMenuParentChange}
name="listFormCode" onClearMenuParent={() => formikRef.current?.setFieldValue('menuParentCode', '')}
placeholder="ListForm Code" onReloadMenu={getMenuList}
component={Input} permissionGroupList={permissionGroupList}
isLoadingPermissionGroup={isLoadingPermissionGroup}
onNext={handleNext}
translate={translate}
/> />
</FormItem>
<FormItem
label="Permission Group Name"
invalid={errors.permissionGroupName && touched.permissionGroupName}
errorMessage={errors.permissionGroupName}
asterisk={true}
>
<Field type="text" autoComplete="off" name="permissionGroupName">
{({ field, form }: FieldProps<string>) => (
<Select
componentAs={CreatableSelect}
field={field}
form={form}
placeholder="Permission Group Name"
isClearable={true}
isLoading={isLoadingPermissionGroup}
options={permissionGroupList}
value={
values.permissionGroupName
? (permissionGroupList?.find(
(o) => o.value === values.permissionGroupName,
) ?? {
label: values.permissionGroupName,
value: values.permissionGroupName,
})
: null
}
onChange={(option) => {
form.setFieldValue(field.name, option?.value)
}}
/>
)}
</Field>
</FormItem>
<div className="grid grid-cols-2 gap-1">
<FormItem
label="Menu Parent Code"
invalid={errors.menuParentCode && touched.menuParentCode}
errorMessage={errors.menuParentCode}
asterisk={true}
>
<Field type="text" autoComplete="off" name="menuParentCode">
{({ field, form }: FieldProps<string>) => (
<Select
componentAs={CreatableSelect}
field={field}
form={form}
placeholder="Menu Parent Code"
isClearable={true}
isLoading={isLoadingMenu}
options={menuList}
value={
values.menuParentCode
? (menuList?.find((o) => o.value === values.menuParentCode) ?? {
label: values.menuParentCode,
value: values.menuParentCode,
})
: null
}
onChange={(option) => {
form.setFieldValue(field.name, option?.value)
setIsMenuNew(
!!option?.value &&
!menuList.some((a) => a.value === option?.value),
)
}}
/>
)}
</Field>
</FormItem>
<FormItem
label="Menu Icon"
invalid={errors.menuIcon && touched.menuIcon}
errorMessage={errors.menuIcon}
>
<Field
type="text"
autoComplete="off"
name="menuIcon"
placeholder="Menu Icon"
component={Input}
/>
</FormItem>
</div>
{isMenuNew && (
<div className="grid grid-cols-2 gap-1">
<FormItem
label="Parent Menu Text (En)"
invalid={
errors.languageTextMenuParentEn && touched.languageTextMenuParentEn
}
errorMessage={errors.languageTextMenuParentEn}
>
<Field
type="text"
autoComplete="off"
name="languageTextMenuParentEn"
placeholder="Parent Menu Text (En)"
component={Input}
/>
</FormItem>
<FormItem
label="Parent Menu Text (Tr)"
invalid={
errors.languageTextMenuParentTr && touched.languageTextMenuParentTr
}
errorMessage={errors.languageTextMenuParentTr}
>
<Field
type="text"
autoComplete="off"
name="languageTextMenuParentTr"
placeholder="Parent Menu Text (Tr)"
component={Input}
/>
</FormItem>
</div>
)}
<div className="grid grid-cols-2 gap-1">
<FormItem
label="Menu Text (En)"
invalid={errors.languageTextMenuEn && touched.languageTextMenuEn}
errorMessage={errors.languageTextMenuEn}
>
<Field
type="text"
autoComplete="off"
name="languageTextMenuEn"
placeholder="Menu Text (En)"
component={Input}
/>
</FormItem>
<FormItem
label="Menu Text (Tr)"
invalid={errors.languageTextMenuTr && touched.languageTextMenuTr}
errorMessage={errors.languageTextMenuTr}
>
<Field
type="text"
autoComplete="off"
name="languageTextMenuTr"
placeholder="Menu Text (Tr)"
component={Input}
/>
</FormItem>
</div>
<Button block className="mt-4" variant="solid" type="button" onClick={handleNext}>
{translate('::Next') || 'Next'}
</Button>
</>
)} )}
{/* ─── Step 2: Data Settings ───────────────────────────── */} {/* ─── Step 2: Data Settings ───────────────────────────── */}
{currentStep === 1 && ( {currentStep === 1 && (
<> <>
{/* ListForm Code */}
<FormItem
label="ListForm Code"
invalid={!!(errors.listFormCode && touched.listFormCode)}
errorMessage={errors.listFormCode}
asterisk={true}
extra={
<span className="text-xs text-gray-400">
Auto-derived from Wizard Name, editable
</span>
}
>
<Field
type="text"
autoComplete="off"
name="listFormCode"
placeholder="e.g. App.Soutes"
component={Input}
/>
</FormItem>
<div className="grid grid-cols-2 gap-1"> <div className="grid grid-cols-2 gap-1">
<FormItem <FormItem
label="Title (En)" label="Title (En)"

View file

@ -0,0 +1,662 @@
import {
Button,
Dialog,
FormItem,
Input,
Notification,
Select,
toast,
} from '@/components/ui'
import { ListFormWizardDto } from '@/proxy/admin/list-form/models'
import { MenuDto } from '@/proxy/menus/models'
import { SelectBoxOption } from '@/types/shared'
import navigationIcon from '@/proxy/menus/navigation-icon.config'
import { MenuItem } from '@/proxy/menus/menu'
import { MenuService } from '@/services/menu.service'
import { Field, FieldProps, FormikErrors, FormikTouched } from 'formik'
import { useEffect, useRef, useState } from 'react'
import CreatableSelect from 'react-select/creatable'
import {
FaChevronDown,
FaChevronRight,
FaEdit,
FaPlus,
FaTimes,
FaCheck,
FaTrash,
} from 'react-icons/fa'
import { useLocalization } from '@/utils/hooks/useLocalization'
// ─── Types (exported for Wizard.tsx) ─────────────────────────────────────────
export interface MenuTreeNode {
code: string
displayName: string
icon?: string
url?: string
children: MenuTreeNode[]
}
// ─── Helpers (exported for Wizard.tsx) ───────────────────────────────────────
export function buildMenuTree(items: MenuItem[]): MenuTreeNode[] {
const map = new Map<string, MenuTreeNode>()
const roots: MenuTreeNode[] = []
items.forEach((item) => {
map.set(item.code!, {
code: item.code!,
displayName: item.displayName ?? item.code!,
icon: item.icon ?? undefined,
url: item.url ?? undefined,
children: [],
})
})
items.forEach((item) => {
const node = map.get(item.code!)!
if (item.parentCode && map.has(item.parentCode)) {
map.get(item.parentCode)!.children.push(node)
} else {
roots.push(node)
}
})
return roots
}
export function filterNonLinkNodes(nodes: MenuTreeNode[]): MenuTreeNode[] {
return nodes
.filter((n) => !n.url)
.map((n) => ({ ...n, children: filterNonLinkNodes(n.children) }))
}
export function findRootCode(rawItems: MenuItem[], code: string): string {
let current = code
for (let i = 0; i < 50; i++) {
const item = rawItems.find((r) => r.code === current)
if (!item?.parentCode) return current
current = item.parentCode
}
return current
}
// ─── MenuService singleton ────────────────────────────────────────────────────
const menuService = new MenuService()
// ─── TreeNode ─────────────────────────────────────────────────────────────────
interface TreeNodeProps {
node: MenuTreeNode & { id?: string }
depth: number
selectedCode: string
onSelect: (code: string) => void
expanded: Set<string>
onToggle: (code: string) => void
editingCode: string | null
editingValue: string
saving: boolean
onStartEdit: (code: string, currentName: string) => void
onEditChange: (v: string) => void
onSaveEdit: (node: MenuTreeNode & { id?: string }) => void
onCancelEdit: () => void
onDelete: (node: MenuTreeNode & { id?: string }) => void
}
function TreeNode({
node, depth, selectedCode, onSelect, expanded, onToggle,
editingCode, editingValue, saving,
onStartEdit, onEditChange, onSaveEdit, onCancelEdit, onDelete,
}: TreeNodeProps) {
const hasChildren = node.children.length > 0
const isExpanded = expanded.has(node.code)
const isSelected = node.code === selectedCode
const isEditing = editingCode === node.code
const NodeIcon = node.icon ? navigationIcon[node.icon] : null
const { translate } = useLocalization()
return (
<div>
<div
className={`flex items-center gap-1 px-2 py-1.5 rounded mx-1 group ${
isSelected
? 'bg-indigo-500 text-white'
: 'hover:bg-gray-100 dark:hover:bg-gray-700 text-gray-700 dark:text-gray-200'
}`}
style={{ paddingLeft: `${8 + depth * 16}px` }}
>
<span
className="w-4 h-4 flex items-center justify-center shrink-0 text-xs cursor-pointer"
onClick={(e) => { e.stopPropagation(); if (hasChildren) onToggle(node.code) }}
>
{hasChildren ? (
isExpanded
? <FaChevronDown className="text-gray-400" />
: <FaChevronRight className="text-gray-400" />
) : null}
</span>
{NodeIcon && (
<NodeIcon className={`text-base shrink-0 ${isSelected ? 'text-indigo-200' : 'text-gray-400'}`} />
)}
{isEditing ? (
<input
autoFocus
value={editingValue}
onChange={(e) => onEditChange(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') onSaveEdit(node)
if (e.key === 'Escape') onCancelEdit()
}}
onClick={(e) => e.stopPropagation()}
className="flex-1 px-2 py-0.5 text-sm rounded border border-indigo-400 outline-none bg-white text-gray-800 dark:bg-gray-700 dark:text-gray-100"
/>
) : (
<span className="flex-1 text-sm truncate cursor-pointer" onClick={() => onSelect(node.code)}>
{node.displayName}
</span>
)}
<span
className={`text-xs shrink-0 ${isSelected ? 'text-indigo-200' : 'text-gray-400'}`}
onClick={() => !isEditing && onSelect(node.code)}
>
{translate('::' + node.code)}
</span>
{isEditing ? (
<>
<button type="button" disabled={saving}
onClick={(e) => { e.stopPropagation(); onSaveEdit(node) }}
className="p-1 text-green-500 hover:text-green-600 disabled:opacity-40">
<FaCheck className="text-xs" />
</button>
<button type="button"
onClick={(e) => { e.stopPropagation(); onCancelEdit() }}
className="p-1 text-gray-400 hover:text-gray-600">
<FaTimes className="text-xs" />
</button>
</>
) : (
<span className="shrink-0 flex items-center gap-0 opacity-0 group-hover:opacity-100 transition-opacity">
<button type="button" title="Rename"
onClick={(e) => { e.stopPropagation(); onStartEdit(node.code, node.displayName) }}
className={`p-1 ${isSelected ? 'text-indigo-200 hover:text-white' : 'text-gray-300 hover:text-indigo-500'}`}>
<FaEdit className="text-xs" />
</button>
<button type="button" title="Delete"
onClick={(e) => { e.stopPropagation(); onDelete(node) }}
className={`p-1 ${isSelected ? 'text-indigo-200 hover:text-white' : 'text-gray-300 hover:text-red-500'}`}>
<FaTrash className="text-xs" />
</button>
</span>
)}
</div>
{isExpanded && node.children.map((child) => (
<TreeNode
key={child.code}
node={child as MenuTreeNode & { id?: string }}
depth={depth + 1}
selectedCode={selectedCode}
onSelect={onSelect}
expanded={expanded}
onToggle={onToggle}
editingCode={editingCode}
editingValue={editingValue}
saving={saving}
onStartEdit={onStartEdit}
onEditChange={onEditChange}
onSaveEdit={onSaveEdit}
onCancelEdit={onCancelEdit}
onDelete={onDelete}
/>
))}
</div>
)
}
// ─── MenuTreeInline ───────────────────────────────────────────────────────────
interface MenuTreeInlineProps {
value: string
onChange: (code: string) => void
nodes: MenuTreeNode[]
rawItems: (MenuItem & { id?: string })[]
isLoading: boolean
invalid?: boolean
onReload: () => void
}
function MenuTreeInline({ value, onChange, nodes, rawItems, isLoading, invalid, onReload }: MenuTreeInlineProps) {
const [expanded, setExpanded] = useState<Set<string>>(new Set())
const [editingCode, setEditingCode] = useState<string | null>(null)
const [editingValue, setEditingValue] = useState('')
const [saving, setSaving] = useState(false)
const toggle = (code: string) =>
setExpanded((prev) => { const n = new Set(prev); n.has(code) ? n.delete(code) : n.add(code); return n })
const handleStartEdit = (code: string, currentName: string) => {
setEditingCode(code)
setEditingValue(currentName)
}
const handleSaveEdit = async (node: MenuTreeNode & { id?: string }) => {
if (!editingValue.trim() || !node.id) return
setSaving(true)
try {
const original = rawItems.find((i) => i.code === node.code)
if (!original) return
await menuService.update(node.id, { ...original, displayName: editingValue.trim() } as MenuDto)
onReload()
setEditingCode(null)
} catch (e: any) {
toast.push(<Notification title={e.message} type="danger" />, { placement: 'top-end' })
} finally {
setSaving(false)
}
}
const handleDelete = async (node: MenuTreeNode & { id?: string }) => {
if (!node.id) return
if (!window.confirm(`"${node.displayName}" menüsünü silmek istediğinize emin misiniz?`)) return
setSaving(true)
try {
await menuService.delete(node.id)
onReload()
} catch (e: any) {
toast.push(<Notification title={e.message} type="danger" />, { placement: 'top-end' })
} finally {
setSaving(false)
}
}
function enrichNode(node: MenuTreeNode): MenuTreeNode & { id?: string } {
const raw = rawItems.find((i) => i.code === node.code)
return { ...node, id: raw?.id, children: node.children.map(enrichNode) }
}
const enrichedNodes = nodes.map(enrichNode)
const sharedNodeProps = {
expanded, onToggle: toggle,
editingCode, editingValue, saving,
onStartEdit: handleStartEdit, onEditChange: setEditingValue,
onSaveEdit: handleSaveEdit, onCancelEdit: () => setEditingCode(null),
onDelete: handleDelete,
}
return (
<div className={`rounded-lg border ${invalid ? 'border-red-500' : 'border-gray-300 dark:border-gray-600'} bg-white dark:bg-gray-800 overflow-hidden`}>
<div className="h-48 overflow-y-auto py-1">
{isLoading ? (
<div className="px-4 py-3 text-sm text-gray-400">Loading</div>
) : enrichedNodes.length === 0 ? (
<div className="px-4 py-3 text-sm text-gray-400">No menus available</div>
) : (
enrichedNodes.map((node) => (
<TreeNode key={node.code} node={node} depth={0} selectedCode={value} onSelect={onChange} {...sharedNodeProps} />
))
)}
</div>
</div>
)
}
// ─── IconPickerField ──────────────────────────────────────────────────────────
interface IconPickerFieldProps {
value: string
onChange: (iconKey: string) => void
invalid?: boolean
}
const ALL_ICON_ENTRIES = Object.entries(navigationIcon)
const ICON_PAGE_SIZE = 100
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
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">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… (e.g. 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 ────────────────────────────────────────────────────────────
interface MenuAddDialogProps {
isOpen: boolean
onClose: () => void
initialParentCode: string
initialOrder: number
rawItems: (MenuItem & { id?: string })[]
onSaved: () => void
}
function MenuAddDialog({ isOpen, onClose, initialParentCode, initialOrder, rawItems, onSaved }: MenuAddDialogProps) {
const [form, setForm] = useState({ code: '', displayName: '', parentCode: initialParentCode, icon: '', shortName: '', order: initialOrder })
const [saving, setSaving] = useState(false)
useEffect(() => {
if (isOpen) setForm({ code: '', displayName: '', parentCode: initialParentCode, icon: '', shortName: '', order: initialOrder })
}, [isOpen, initialParentCode, initialOrder])
const handleSave = async () => {
if (!form.code.trim() || !form.displayName.trim()) return
setSaving(true)
try {
await menuService.create({
code: form.code.trim(),
displayName: form.displayName.trim(),
parentCode: form.parentCode.trim() || undefined,
icon: form.icon || undefined,
shortName: form.shortName.trim() || undefined,
order: form.order,
isDisabled: false,
} 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
return (
<Dialog isOpen={isOpen} onClose={onClose} onRequestClose={onClose} width={520}>
<div className="flex flex-col gap-4 p-4">
<h5 className="text-base font-semibold text-gray-800 dark:text-gray-100">Yeni Menü Ekle</h5>
<div className="flex flex-col gap-3">
<div className="flex flex-col gap-1">
<label className="text-sm font-medium text-gray-600 dark:text-gray-300">Code <span className="text-red-500">*</span></label>
<input value={form.code} onChange={(e) => setForm((p) => ({ ...p, code: e.target.value }))} placeholder="App.MyMenu"
className="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" />
</div>
<div className="flex flex-col gap-1">
<label className="text-sm font-medium text-gray-600 dark:text-gray-300">Display Name <span className="text-red-500">*</span></label>
<input value={form.displayName} onChange={(e) => setForm((p) => ({ ...p, displayName: e.target.value }))} placeholder="My Menu"
className="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" />
</div>
<div className="flex flex-col gap-1">
<label className="text-sm font-medium text-gray-600 dark:text-gray-300">Icon</label>
<IconPickerField value={form.icon} onChange={(key) => setForm((p) => ({ ...p, icon: key }))} />
</div>
<div className="flex flex-col gap-1">
<label className="text-sm font-medium text-gray-600 dark:text-gray-300">Menu Parent</label>
<input disabled value={form.parentCode || '(Ana Menü)'}
className="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" />
</div>
<div className="flex flex-col gap-1">
<label className="text-sm font-medium text-gray-600 dark:text-gray-300">Sıra (Order)</label>
<input type="number" value={form.order} onChange={(e) => setForm((p) => ({ ...p, order: Number(e.target.value) }))}
className="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" />
</div>
<div className="flex flex-col gap-1">
<label className="text-sm font-medium text-gray-600 dark:text-gray-300">Short Name</label>
<input value={form.shortName} onChange={(e) => setForm((p) => ({ ...p, shortName: e.target.value }))} placeholder="My Menu (short)"
className="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" />
</div>
</div>
<div className="flex justify-end gap-2 pt-2">
<Button size="sm" variant="plain" onClick={onClose}>İptal</Button>
<Button size="sm" variant="solid" loading={saving} disabled={!form.code.trim() || !form.displayName.trim()} onClick={handleSave}>Kaydet</Button>
</div>
</div>
</Dialog>
)
}
// ─── WizardStep1 ──────────────────────────────────────────────────────────────
export interface WizardStep1Props {
values: ListFormWizardDto
errors: FormikErrors<ListFormWizardDto>
touched: FormikTouched<ListFormWizardDto>
wizardName: string
onWizardNameChange: (name: string) => void
rawMenuItems: (MenuItem & { id?: string })[]
menuTree: MenuTreeNode[]
isLoadingMenu: boolean
onMenuParentChange: (code: string) => void
onClearMenuParent: () => void
onReloadMenu: () => void
permissionGroupList: SelectBoxOption[]
isLoadingPermissionGroup: boolean
onNext: () => void
translate: (key: string) => string
}
const WizardStep1 = ({
values, errors, touched,
wizardName, onWizardNameChange,
rawMenuItems, menuTree, isLoadingMenu,
onMenuParentChange, onClearMenuParent, onReloadMenu,
permissionGroupList, isLoadingPermissionGroup,
onNext, translate,
}: WizardStep1Props) => {
const [menuDialogOpen, setMenuDialogOpen] = useState(false)
const [menuDialogParentCode, setMenuDialogParentCode] = useState('')
const [menuDialogInitialOrder, setMenuDialogInitialOrder] = useState(999)
return (
<>
{/* Wizard Name */}
<FormItem
label="Wizard Name"
asterisk={true}
extra={<span className="text-xs ml-2 text-gray-400">Used to generate ListForm Code and Menu Code</span>}
>
<Input
type="text"
autoComplete="off"
placeholder="e.g. Soutes"
value={wizardName}
onChange={(e) => onWizardNameChange(e.target.value)}
/>
</FormItem>
{/* Menu Parent */}
<FormItem
label="Menu Parent"
invalid={errors.menuParentCode && touched.menuParentCode}
errorMessage={errors.menuParentCode}
asterisk={true}
extra={
<div className="flex items-center gap-2 ml-3">
<button
type="button"
onClick={() => {
setMenuDialogParentCode(values.menuParentCode ? findRootCode(rawMenuItems, values.menuParentCode) : '')
const selectedItem = rawMenuItems.find((i) => i.code === values.menuParentCode)
setMenuDialogInitialOrder(selectedItem?.order ?? 999)
setMenuDialogOpen(true)
}}
className="flex items-center gap-1 px-2 py-0.5 text-xs rounded bg-green-500 text-white hover:bg-green-600"
>
<FaPlus className="text-xs" /> Ekle
</button>
{values.menuParentCode && (
<button
type="button"
onClick={(e) => { e.stopPropagation(); e.preventDefault(); onClearMenuParent() }}
className="flex items-center gap-1 px-2 py-0.5 text-xs rounded border border-gray-300 dark:border-gray-600 text-gray-500 hover:text-red-500 hover:border-red-400"
>
<FaTimes className="text-xs" /> Seçimi Kaldır
</button>
)}
</div>
}
>
<Field name="menuParentCode">
{() => (
<MenuTreeInline
value={values.menuParentCode}
onChange={onMenuParentChange}
nodes={menuTree}
rawItems={rawMenuItems}
isLoading={isLoadingMenu}
invalid={!!(errors.menuParentCode && touched.menuParentCode)}
onReload={onReloadMenu}
/>
)}
</Field>
</FormItem>
<MenuAddDialog
isOpen={menuDialogOpen}
onClose={() => setMenuDialogOpen(false)}
initialParentCode={menuDialogParentCode}
initialOrder={menuDialogInitialOrder}
rawItems={rawMenuItems}
onSaved={onReloadMenu}
/>
{/* Menu Code */}
<FormItem
label="Menu Code"
invalid={!!(errors.menuCode && touched.menuCode)}
errorMessage={errors.menuCode}
asterisk={true}
extra={<span className="text-xs ml-2 text-gray-400">Auto-derived, editable</span>}
>
<Field type="text" autoComplete="off" name="menuCode" placeholder="e.g. App.Soutes" component={Input} />
</FormItem>
<FormItem
label="Menu Text (En)"
invalid={errors.languageTextMenuEn && touched.languageTextMenuEn}
errorMessage={errors.languageTextMenuEn}
>
<Field type="text" autoComplete="off" name="languageTextMenuEn" placeholder="Menu Text (En)" component={Input} />
</FormItem>
<FormItem
label="Menu Text (Tr)"
invalid={errors.languageTextMenuTr && touched.languageTextMenuTr}
errorMessage={errors.languageTextMenuTr}
>
<Field type="text" autoComplete="off" name="languageTextMenuTr" placeholder="Menu Text (Tr)" component={Input} />
</FormItem>
{/* Permission Group Name */}
<FormItem
label="Permission Group Name"
invalid={errors.permissionGroupName && touched.permissionGroupName}
errorMessage={errors.permissionGroupName}
asterisk={true}
>
<Field type="text" autoComplete="off" name="permissionGroupName">
{({ field, form }: FieldProps<string>) => (
<Select
componentAs={CreatableSelect}
field={field}
form={form}
placeholder="Permission Group Name"
isClearable={true}
isLoading={isLoadingPermissionGroup}
options={permissionGroupList}
value={
values.permissionGroupName
? (permissionGroupList?.find((o) => o.value === values.permissionGroupName) ?? {
label: values.permissionGroupName,
value: values.permissionGroupName,
})
: null
}
onChange={(option) => form.setFieldValue(field.name, option?.value)}
/>
)}
</Field>
</FormItem>
<Button block className="mt-4" variant="solid" type="button" onClick={onNext}>
{translate('::Next') || 'Next'}
</Button>
</>
)
}
export default WizardStep1