Wizard Step3 ve Wizard Step4 güncellemesi
This commit is contained in:
parent
4c5cfe06f8
commit
f2652dbb44
5 changed files with 1203 additions and 22 deletions
|
|
@ -1401,8 +1401,8 @@
|
||||||
{
|
{
|
||||||
"resourceName": "Platform",
|
"resourceName": "Platform",
|
||||||
"key": "App.Listforms.Wizard",
|
"key": "App.Listforms.Wizard",
|
||||||
"en": "Wizard",
|
"en": "Listform Wizard",
|
||||||
"tr": "Sihirbaz"
|
"tr": "Listform Sihirbazı"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"resourceName": "Platform",
|
"resourceName": "Platform",
|
||||||
|
|
|
||||||
|
|
@ -1050,10 +1050,10 @@
|
||||||
"IsDisabled": false
|
"IsDisabled": false
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"ParentCode": "App.Administration",
|
"ParentCode": "App.DeveloperKit",
|
||||||
"Code": "App.Listforms.Wizard",
|
"Code": "App.Listforms.Wizard",
|
||||||
"DisplayName": "App.Listforms.Wizard",
|
"DisplayName": "App.Listforms.Wizard",
|
||||||
"Order": 10,
|
"Order": 9,
|
||||||
"Url": "/admin/listform/wizard",
|
"Url": "/admin/listform/wizard",
|
||||||
"Icon": "FcFlashAuto",
|
"Icon": "FcFlashAuto",
|
||||||
"RequiredPermissionName": "App.Listforms.Wizard",
|
"RequiredPermissionName": "App.Listforms.Wizard",
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,8 @@ import WizardStep1, {
|
||||||
findRootCode,
|
findRootCode,
|
||||||
} from './WizardStep1'
|
} from './WizardStep1'
|
||||||
import WizardStep2, { sqlDataTypeToDbType } from './WizardStep2'
|
import WizardStep2, { sqlDataTypeToDbType } from './WizardStep2'
|
||||||
|
import WizardStep3, { WizardGroup } from './WizardStep3'
|
||||||
|
import WizardStep4 from './WizardStep4'
|
||||||
import { Container } from '@/components/shared'
|
import { Container } from '@/components/shared'
|
||||||
|
|
||||||
// ─── Formik initial values & validation ──────────────────────────────────────
|
// ─── Formik initial values & validation ──────────────────────────────────────
|
||||||
|
|
@ -114,10 +116,14 @@ const Wizard = () => {
|
||||||
const [isLoadingColumns, setIsLoadingColumns] = useState(false)
|
const [isLoadingColumns, setIsLoadingColumns] = useState(false)
|
||||||
const [selectedColumns, setSelectedColumns] = useState<Set<string>>(new Set())
|
const [selectedColumns, setSelectedColumns] = useState<Set<string>>(new Set())
|
||||||
|
|
||||||
|
// ── Editing Form Groups (Step 3) ──
|
||||||
|
const [editingGroups, setEditingGroups] = useState<WizardGroup[]>([])
|
||||||
|
|
||||||
const loadColumns = async (dsCode: string, schema: string, name: string) => {
|
const loadColumns = async (dsCode: string, schema: string, name: string) => {
|
||||||
if (!dsCode || !name) {
|
if (!dsCode || !name) {
|
||||||
setSelectCommandColumns([])
|
setSelectCommandColumns([])
|
||||||
setSelectedColumns(new Set())
|
setSelectedColumns(new Set())
|
||||||
|
setEditingGroups([])
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
setIsLoadingColumns(true)
|
setIsLoadingColumns(true)
|
||||||
|
|
@ -126,11 +132,15 @@ const Wizard = () => {
|
||||||
const cols = res.data ?? []
|
const cols = res.data ?? []
|
||||||
setSelectCommandColumns(cols)
|
setSelectCommandColumns(cols)
|
||||||
setSelectedColumns(new Set(cols.map((c) => c.columnName)))
|
setSelectedColumns(new Set(cols.map((c) => c.columnName)))
|
||||||
|
setEditingGroups([])
|
||||||
// Auto-select first column as key field
|
// Auto-select first column as key field
|
||||||
if (cols.length > 0) {
|
if (cols.length > 0) {
|
||||||
const first = cols[0]
|
const first = cols[0]
|
||||||
formikRef.current?.setFieldValue('keyFieldName', first.columnName)
|
formikRef.current?.setFieldValue('keyFieldName', first.columnName)
|
||||||
formikRef.current?.setFieldValue('keyFieldDbSourceType', sqlDataTypeToDbType(first.dataType))
|
formikRef.current?.setFieldValue(
|
||||||
|
'keyFieldDbSourceType',
|
||||||
|
sqlDataTypeToDbType(first.dataType),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
setSelectCommandColumns([])
|
setSelectCommandColumns([])
|
||||||
|
|
@ -283,6 +293,26 @@ const Wizard = () => {
|
||||||
setCurrentStep(2)
|
setCurrentStep(2)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleDeploy = async () => {
|
||||||
|
if (!formikRef.current) throw new Error('Form bulunamadı')
|
||||||
|
const values = formikRef.current.values
|
||||||
|
await postListFormWizard({ ...values })
|
||||||
|
toast.push(
|
||||||
|
<Notification type="success" duration={2000}>
|
||||||
|
{translate('::ListForms.FormBilgileriKaydedildi')}
|
||||||
|
</Notification>,
|
||||||
|
{ placement: 'top-end' },
|
||||||
|
)
|
||||||
|
setTimeout(() => {
|
||||||
|
navigate(
|
||||||
|
ROUTES_ENUM.protected.saas.listFormManagement.edit.replace(
|
||||||
|
':listFormCode',
|
||||||
|
values.listFormCode,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}, 1500)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Container>
|
<Container>
|
||||||
<Helmet
|
<Helmet
|
||||||
|
|
@ -336,7 +366,7 @@ const Wizard = () => {
|
||||||
>
|
>
|
||||||
{({ touched, errors, isSubmitting, values }) => (
|
{({ touched, errors, isSubmitting, values }) => (
|
||||||
<Form>
|
<Form>
|
||||||
<FormContainer size="sm">
|
<FormContainer size={currentStep === 2 ? undefined : currentStep === 3 ? undefined : 'sm'}>
|
||||||
{/* ─── Step 1: Basic Info ─────────────────────────────── */}
|
{/* ─── Step 1: Basic Info ─────────────────────────────── */}
|
||||||
{currentStep === 0 && (
|
{currentStep === 0 && (
|
||||||
<WizardStep1
|
<WizardStep1
|
||||||
|
|
@ -389,26 +419,29 @@ const Wizard = () => {
|
||||||
|
|
||||||
{/* ─── Step 3: List Form Fields ───────────────────────────── */}
|
{/* ─── Step 3: List Form Fields ───────────────────────────── */}
|
||||||
{currentStep === 2 && (
|
{currentStep === 2 && (
|
||||||
<div className="flex gap-2 mt-4">
|
<WizardStep3
|
||||||
<Button block variant="default" type="button" onClick={() => setCurrentStep(1)}>
|
selectedColumns={selectedColumns}
|
||||||
{translate('::Back') || 'Back'}
|
selectCommandColumns={selectCommandColumns}
|
||||||
</Button>
|
groups={editingGroups}
|
||||||
<Button block variant="solid" type="button" onClick={() => setCurrentStep(3)}>
|
onGroupsChange={setEditingGroups}
|
||||||
{translate('::Next') || 'Next'}
|
translate={translate}
|
||||||
</Button>
|
onBack={() => setCurrentStep(1)}
|
||||||
</div>
|
onNext={() => setCurrentStep(3)}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* ─── Step 4: Deploy ───────────────────────────── */}
|
{/* ─── Step 4: Deploy ───────────────────────────── */}
|
||||||
{currentStep === 3 && (
|
{currentStep === 3 && (
|
||||||
<div className="flex gap-2 mt-4">
|
<WizardStep4
|
||||||
<Button block variant="default" type="button" onClick={() => setCurrentStep(2)}>
|
values={values}
|
||||||
{translate('::Back') || 'Back'}
|
wizardName={wizardName}
|
||||||
</Button>
|
selectedColumns={selectedColumns}
|
||||||
<Button block variant="solid" loading={isSubmitting} type="submit">
|
selectCommandColumns={selectCommandColumns}
|
||||||
{isSubmitting ? translate('::SavingWithThreeDot') : translate('::Save')}
|
groups={editingGroups}
|
||||||
</Button>
|
translate={translate}
|
||||||
</div>
|
onBack={() => setCurrentStep(2)}
|
||||||
|
onSubmit={handleDeploy}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
</FormContainer>
|
</FormContainer>
|
||||||
</Form>
|
</Form>
|
||||||
|
|
|
||||||
766
ui/src/views/admin/listForm/WizardStep3.tsx
Normal file
766
ui/src/views/admin/listForm/WizardStep3.tsx
Normal file
|
|
@ -0,0 +1,766 @@
|
||||||
|
import { Button, Dialog } from '@/components/ui'
|
||||||
|
import { columnEditorTypeListOptions } from '@/views/admin/listForm/edit/options'
|
||||||
|
import type { DatabaseColumnDto } from '@/proxy/sql-query-manager/models'
|
||||||
|
import {
|
||||||
|
DndContext,
|
||||||
|
DragEndEvent,
|
||||||
|
DragOverlay,
|
||||||
|
DragStartEvent,
|
||||||
|
PointerSensor,
|
||||||
|
useDraggable,
|
||||||
|
useDroppable,
|
||||||
|
useSensor,
|
||||||
|
useSensors,
|
||||||
|
} from '@dnd-kit/core'
|
||||||
|
import { SortableContext, arrayMove, useSortable, rectSortingStrategy } from '@dnd-kit/sortable'
|
||||||
|
import { CSS } from '@dnd-kit/utilities'
|
||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { FaGripVertical, FaPlus, FaTimes, FaTrash, FaArrowRight, FaCode } from 'react-icons/fa'
|
||||||
|
|
||||||
|
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||||
|
export interface WizardGroupItem {
|
||||||
|
id: string
|
||||||
|
dataField: string
|
||||||
|
editorType: string
|
||||||
|
editorOptions: string
|
||||||
|
editorScript: string
|
||||||
|
colSpan: number
|
||||||
|
isRequired: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WizardGroup {
|
||||||
|
id: string
|
||||||
|
caption: string
|
||||||
|
colCount: number
|
||||||
|
items: WizardGroupItem[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WizardStep3Props {
|
||||||
|
selectedColumns: Set<string>
|
||||||
|
selectCommandColumns: DatabaseColumnDto[]
|
||||||
|
groups: WizardGroup[]
|
||||||
|
onGroupsChange: (groups: WizardGroup[]) => void
|
||||||
|
translate: (key: string) => string
|
||||||
|
onBack: () => void
|
||||||
|
onNext: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
||||||
|
function inferEditorType(sqlType: string): string {
|
||||||
|
const t = sqlType?.toLowerCase() ?? ''
|
||||||
|
if (t === 'bit') return 'dxCheckBox'
|
||||||
|
if (
|
||||||
|
[
|
||||||
|
'int',
|
||||||
|
'bigint',
|
||||||
|
'smallint',
|
||||||
|
'tinyint',
|
||||||
|
'decimal',
|
||||||
|
'float',
|
||||||
|
'real',
|
||||||
|
'numeric',
|
||||||
|
'money',
|
||||||
|
'smallmoney',
|
||||||
|
].includes(t)
|
||||||
|
)
|
||||||
|
return 'dxNumberBox'
|
||||||
|
if (['date', 'datetime', 'datetime2', 'smalldatetime', 'datetimeoffset'].includes(t))
|
||||||
|
return 'dxDateBox'
|
||||||
|
return 'dxTextBox'
|
||||||
|
}
|
||||||
|
|
||||||
|
function newGroupItem(colName: string, sqlType = ''): WizardGroupItem {
|
||||||
|
return {
|
||||||
|
id: `${colName}_${Date.now()}`,
|
||||||
|
dataField: colName,
|
||||||
|
editorType: inferEditorType(sqlType),
|
||||||
|
editorOptions: '',
|
||||||
|
editorScript: '',
|
||||||
|
colSpan: 1,
|
||||||
|
isRequired: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function newGroup(order: number): WizardGroup {
|
||||||
|
return {
|
||||||
|
id: `grp_${Date.now()}_${Math.random().toString(36).slice(2, 7)}`,
|
||||||
|
caption: `Group ${order}`,
|
||||||
|
colCount: 2,
|
||||||
|
items: [],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── DnD helper IDs ───────────────────────────────────────────────────────────
|
||||||
|
const AV_PREFIX = 'av::'
|
||||||
|
const GRP_PREFIX = 'grp::'
|
||||||
|
const ITM_PREFIX = 'itm::'
|
||||||
|
|
||||||
|
// ─── Sub-components ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function AvailableColumnChip({ colName }: { colName: string }) {
|
||||||
|
const { attributes, listeners, setNodeRef, isDragging } = useDraggable({
|
||||||
|
id: `${AV_PREFIX}${colName}`,
|
||||||
|
})
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={setNodeRef}
|
||||||
|
{...listeners}
|
||||||
|
{...attributes}
|
||||||
|
className={`flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg border cursor-grab select-none text-sm transition-all
|
||||||
|
${
|
||||||
|
isDragging
|
||||||
|
? 'opacity-40 border-indigo-300 bg-indigo-50 dark:bg-indigo-900/20'
|
||||||
|
: 'border-gray-200 dark:border-gray-600 bg-white dark:bg-gray-800 hover:border-indigo-400 hover:bg-indigo-50 dark:hover:bg-indigo-900/20 text-gray-700 dark:text-gray-200'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<FaGripVertical className="text-gray-300 text-xs shrink-0" />
|
||||||
|
<span className="truncate font-medium">{colName}</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SortableItemProps {
|
||||||
|
item: WizardGroupItem
|
||||||
|
groupColCount: number
|
||||||
|
onEditorTypeChange: (val: string) => void
|
||||||
|
onEditorOptionsChange: (val: string) => void
|
||||||
|
onEditorScriptChange: (val: string) => void
|
||||||
|
onColSpanChange: (val: number) => void
|
||||||
|
onRequiredChange: (val: boolean) => void
|
||||||
|
onRemove: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
function SortableItem({
|
||||||
|
item,
|
||||||
|
groupColCount,
|
||||||
|
onEditorTypeChange,
|
||||||
|
onEditorOptionsChange,
|
||||||
|
onEditorScriptChange,
|
||||||
|
onColSpanChange,
|
||||||
|
onRequiredChange,
|
||||||
|
onRemove,
|
||||||
|
}: SortableItemProps) {
|
||||||
|
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
|
||||||
|
id: `${ITM_PREFIX}${item.id}`,
|
||||||
|
})
|
||||||
|
const style = {
|
||||||
|
transform: CSS.Transform.toString(transform),
|
||||||
|
transition,
|
||||||
|
gridColumn: `span ${Math.min(item.colSpan, groupColCount)}`,
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={setNodeRef}
|
||||||
|
style={style}
|
||||||
|
className={`flex flex-col gap-1 p-2 rounded-lg border group/item transition-all
|
||||||
|
${
|
||||||
|
isDragging
|
||||||
|
? 'opacity-40 border-indigo-300 bg-indigo-50 dark:bg-indigo-900/20 z-50'
|
||||||
|
: 'border-gray-200 dark:border-gray-600 bg-white dark:bg-gray-800 hover:border-indigo-300 dark:hover:border-indigo-600'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{/* Top row: drag handle + field name + remove */}
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
{...attributes}
|
||||||
|
{...listeners}
|
||||||
|
className="cursor-grab text-gray-300 hover:text-gray-500 shrink-0"
|
||||||
|
tabIndex={-1}
|
||||||
|
>
|
||||||
|
<FaGripVertical className="text-sm" />
|
||||||
|
</button>
|
||||||
|
<span className="flex-1 text-xs font-semibold text-indigo-600 dark:text-indigo-400 truncate">
|
||||||
|
{item.dataField}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onRemove}
|
||||||
|
className="opacity-0 group-hover/item:opacity-100 p-0.5 text-gray-300 hover:text-red-500 shrink-0 transition-opacity"
|
||||||
|
title="Remove"
|
||||||
|
>
|
||||||
|
<FaTimes className="text-[10px]" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Editor type select */}
|
||||||
|
<select
|
||||||
|
value={item.editorType}
|
||||||
|
onChange={(e) => onEditorTypeChange(e.target.value)}
|
||||||
|
className="w-full text-xs h-7 px-1.5 rounded border border-gray-200 dark:border-gray-600 bg-gray-50 dark:bg-gray-700 text-gray-700 dark:text-gray-200 focus:outline-none focus:border-indigo-400"
|
||||||
|
>
|
||||||
|
{columnEditorTypeListOptions.map((et) => (
|
||||||
|
<option key={et.value} value={et.value}>
|
||||||
|
{et.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
|
||||||
|
{/* Editor Options */}
|
||||||
|
<div className="flex flex-col gap-0.5">
|
||||||
|
<span className="text-[10px] text-gray-400 font-medium">Editor Options</span>
|
||||||
|
<textarea
|
||||||
|
value={item.editorOptions}
|
||||||
|
onChange={(e) => onEditorOptionsChange(e.target.value)}
|
||||||
|
placeholder='{"readOnly": false}'
|
||||||
|
rows={2}
|
||||||
|
className="w-full text-xs px-1.5 py-1 rounded border border-gray-200 dark:border-gray-600 bg-gray-50 dark:bg-gray-700 text-gray-700 dark:text-gray-200 focus:outline-none focus:border-indigo-400 resize-none font-mono"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Editor Script */}
|
||||||
|
<div className="flex flex-col gap-0.5">
|
||||||
|
<span className="text-[10px] text-gray-400 font-medium">Editor Script</span>
|
||||||
|
<input
|
||||||
|
value={item.editorScript}
|
||||||
|
onChange={(e) => onEditorScriptChange(e.target.value)}
|
||||||
|
placeholder="(e) => { /* e.component */ }"
|
||||||
|
className="w-full text-xs px-1.5 py-1 rounded border border-gray-200 dark:border-gray-600 bg-gray-50 dark:bg-gray-700 text-gray-700 dark:text-gray-200 focus:outline-none focus:border-indigo-400 resize-none font-mono"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Bottom row: ColSpan + Required */}
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<span className="text-[10px] text-gray-400">Span</span>
|
||||||
|
<select
|
||||||
|
value={item.colSpan}
|
||||||
|
onChange={(e) => onColSpanChange(Number(e.target.value))}
|
||||||
|
className="text-xs h-5 w-9 px-0.5 rounded border border-gray-200 dark:border-gray-600 bg-gray-50 dark:bg-gray-700 text-gray-700 dark:text-gray-200 focus:outline-none focus:border-indigo-400"
|
||||||
|
>
|
||||||
|
{Array.from({ length: groupColCount }, (_, i) => i + 1).map((n) => (
|
||||||
|
<option key={n} value={n}>
|
||||||
|
{n}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<label className="flex items-center gap-1 cursor-pointer ml-auto" title="Required">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={item.isRequired}
|
||||||
|
onChange={(e) => onRequiredChange(e.target.checked)}
|
||||||
|
className="w-3 h-3 accent-red-500"
|
||||||
|
/>
|
||||||
|
<span className="text-[10px] text-gray-400">Required</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
interface GroupCardProps {
|
||||||
|
group: WizardGroup
|
||||||
|
isOver: boolean
|
||||||
|
hasAvailable: boolean
|
||||||
|
onCaptionChange: (val: string) => void
|
||||||
|
onColCountChange: (val: number) => void
|
||||||
|
onItemChange: (itemId: string, patch: Partial<WizardGroupItem>) => void
|
||||||
|
onRemoveItem: (itemId: string) => void
|
||||||
|
onDeleteGroup: () => void
|
||||||
|
onAddAll: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
function GroupCard({
|
||||||
|
group,
|
||||||
|
isOver,
|
||||||
|
hasAvailable,
|
||||||
|
onCaptionChange,
|
||||||
|
onColCountChange,
|
||||||
|
onItemChange,
|
||||||
|
onRemoveItem,
|
||||||
|
onDeleteGroup,
|
||||||
|
onAddAll,
|
||||||
|
}: GroupCardProps) {
|
||||||
|
const { setNodeRef } = useDroppable({ id: `${GRP_PREFIX}${group.id}` })
|
||||||
|
const itemIds = group.items.map((i) => `${ITM_PREFIX}${i.id}`)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={setNodeRef}
|
||||||
|
className={`rounded-xl border-2 transition-all ${
|
||||||
|
isOver
|
||||||
|
? 'border-indigo-400 bg-indigo-50/60 dark:bg-indigo-900/20'
|
||||||
|
: 'border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-850'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{/* Group Header */}
|
||||||
|
<div className="flex items-center gap-2 px-3 pt-3 pb-2">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={group.caption}
|
||||||
|
onChange={(e) => onCaptionChange(e.target.value)}
|
||||||
|
placeholder="Group caption…"
|
||||||
|
className="flex-1 text-sm font-semibold h-7 px-2 rounded border border-gray-200 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-800 dark:text-gray-100 focus:outline-none focus:border-indigo-400"
|
||||||
|
/>
|
||||||
|
{/* ColCount */}
|
||||||
|
<div className="flex items-center gap-1 shrink-0">
|
||||||
|
<span className="text-xs text-gray-400">Cols:</span>
|
||||||
|
{[1, 2, 3, 4].map((n) => (
|
||||||
|
<button
|
||||||
|
key={n}
|
||||||
|
type="button"
|
||||||
|
onClick={() => onColCountChange(n)}
|
||||||
|
className={`w-6 h-6 text-xs rounded font-medium transition-colors ${
|
||||||
|
group.colCount === n
|
||||||
|
? 'bg-indigo-500 text-white'
|
||||||
|
: 'bg-gray-200 dark:bg-gray-700 text-gray-600 dark:text-gray-300 hover:bg-indigo-100 dark:hover:bg-indigo-900/40'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{n}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{hasAvailable && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onAddAll}
|
||||||
|
className="flex items-center gap-1 h-6 px-2 text-[11px] font-medium rounded border border-indigo-200 dark:border-indigo-700 bg-indigo-50 dark:bg-indigo-900/20 text-indigo-600 dark:text-indigo-400 hover:bg-indigo-100 dark:hover:bg-indigo-900/40 transition-colors shrink-0"
|
||||||
|
title="Tüm mevcut sütunları bu gruba ekle"
|
||||||
|
>
|
||||||
|
<FaArrowRight className="text-[9px]" />
|
||||||
|
Tümünü Ekle
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onDeleteGroup}
|
||||||
|
className="p-1.5 text-gray-300 hover:text-red-500 rounded transition-colors"
|
||||||
|
title="Delete group"
|
||||||
|
>
|
||||||
|
<FaTrash className="text-xs" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Items drop zone */}
|
||||||
|
<div
|
||||||
|
className={`mx-1 mb-3 min-h-[80px] rounded-lg p-2 transition-all ${
|
||||||
|
isOver
|
||||||
|
? 'bg-indigo-100/60 dark:bg-indigo-900/30 border border-dashed border-indigo-400'
|
||||||
|
: group.items.length === 0
|
||||||
|
? 'border border-dashed border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800'
|
||||||
|
: ''
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{group.items.length === 0 && !isOver && (
|
||||||
|
<div className="flex items-center justify-center h-12 text-xs text-gray-300 dark:text-gray-600 select-none">
|
||||||
|
Sütunları buraya sürükleyin
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<SortableContext items={itemIds} strategy={rectSortingStrategy}>
|
||||||
|
<div
|
||||||
|
style={{ gridTemplateColumns: `repeat(${group.colCount}, 1fr)` }}
|
||||||
|
className="grid gap-3"
|
||||||
|
>
|
||||||
|
{group.items.map((item) => (
|
||||||
|
<SortableItem
|
||||||
|
key={item.id}
|
||||||
|
item={item}
|
||||||
|
groupColCount={group.colCount}
|
||||||
|
onEditorTypeChange={(val) => onItemChange(item.id, { editorType: val })}
|
||||||
|
onEditorOptionsChange={(val) => onItemChange(item.id, { editorOptions: val })}
|
||||||
|
onEditorScriptChange={(val) => onItemChange(item.id, { editorScript: val })}
|
||||||
|
onColSpanChange={(val) => onItemChange(item.id, { colSpan: val })}
|
||||||
|
onRequiredChange={(val) => onItemChange(item.id, { isRequired: val })}
|
||||||
|
onRemove={() => onRemoveItem(item.id)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</SortableContext>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── WizardStep3 ──────────────────────────────────────────────────────────────
|
||||||
|
const WizardStep3 = ({
|
||||||
|
selectedColumns,
|
||||||
|
selectCommandColumns,
|
||||||
|
groups,
|
||||||
|
onGroupsChange,
|
||||||
|
translate,
|
||||||
|
onBack,
|
||||||
|
onNext,
|
||||||
|
}: WizardStep3Props) => {
|
||||||
|
const [activeId, setActiveId] = useState<string | null>(null)
|
||||||
|
const [overGroupId, setOverGroupId] = useState<string | null>(null)
|
||||||
|
const [isHelperOpen, setIsHelperOpen] = useState(false)
|
||||||
|
|
||||||
|
// ── Sync groups when selectedColumns changes ─────────────────────────────
|
||||||
|
useEffect(() => {
|
||||||
|
if (selectedColumns.size === 0) return
|
||||||
|
|
||||||
|
// 1. Remove items that are no longer in selectedColumns
|
||||||
|
const cleaned = groups.map((g) => ({
|
||||||
|
...g,
|
||||||
|
items: g.items.filter((i) => selectedColumns.has(i.dataField)),
|
||||||
|
}))
|
||||||
|
|
||||||
|
// 2. Ensure at least one empty group exists for the user to drag into
|
||||||
|
const finalGroups =
|
||||||
|
cleaned.length === 0
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
id: `grp_default_${Date.now()}`,
|
||||||
|
caption: 'Group 1',
|
||||||
|
colCount: 2,
|
||||||
|
items: [] as WizardGroupItem[],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: cleaned
|
||||||
|
|
||||||
|
// 3. Only update if something actually changed
|
||||||
|
const itemsChanged = finalGroups.some(
|
||||||
|
(g, i) => g.items.length !== (groups[i]?.items.length ?? -1),
|
||||||
|
)
|
||||||
|
const countChanged = finalGroups.length !== groups.length
|
||||||
|
if (itemsChanged || countChanged) onGroupsChange(finalGroups)
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [selectedColumns])
|
||||||
|
|
||||||
|
// ── Available columns = selected but NOT yet placed ───────────────────────
|
||||||
|
const placedColumns = new Set(groups.flatMap((g) => g.items.map((i) => i.dataField)))
|
||||||
|
const availableColumns = [...selectedColumns].filter((c) => !placedColumns.has(c))
|
||||||
|
|
||||||
|
// ── Helpers ───────────────────────────────────────────────────────────────
|
||||||
|
const colMeta = (name: string) =>
|
||||||
|
selectCommandColumns.find((c) => c.columnName === name)?.dataType ?? ''
|
||||||
|
|
||||||
|
const addColumnToGroup = (colName: string, targetGroupId: string) => {
|
||||||
|
onGroupsChange(
|
||||||
|
groups.map((g) =>
|
||||||
|
g.id === targetGroupId
|
||||||
|
? { ...g, items: [...g.items, newGroupItem(colName, colMeta(colName))] }
|
||||||
|
: g,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const removeItemFromGroup = (groupId: string, itemId: string) => {
|
||||||
|
onGroupsChange(
|
||||||
|
groups.map((g) =>
|
||||||
|
g.id === groupId ? { ...g, items: g.items.filter((i) => i.id !== itemId) } : g,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateItem = (groupId: string, itemId: string, patch: Partial<WizardGroupItem>) => {
|
||||||
|
onGroupsChange(
|
||||||
|
groups.map((g) =>
|
||||||
|
g.id === groupId
|
||||||
|
? { ...g, items: g.items.map((i) => (i.id === itemId ? { ...i, ...patch } : i)) }
|
||||||
|
: g,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateGroup = (groupId: string, patch: Partial<WizardGroup>) => {
|
||||||
|
onGroupsChange(groups.map((g) => (g.id === groupId ? { ...g, ...patch } : g)))
|
||||||
|
}
|
||||||
|
|
||||||
|
const deleteGroup = (groupId: string) => {
|
||||||
|
onGroupsChange(groups.filter((g) => g.id !== groupId))
|
||||||
|
}
|
||||||
|
|
||||||
|
const addAllToGroup = (groupId: string) => {
|
||||||
|
const placed = new Set(groups.flatMap((g) => g.items.map((i) => i.dataField)))
|
||||||
|
const toAdd = [...selectedColumns].filter((c) => !placed.has(c))
|
||||||
|
if (toAdd.length === 0) return
|
||||||
|
onGroupsChange(
|
||||||
|
groups.map((g) =>
|
||||||
|
g.id === groupId
|
||||||
|
? { ...g, items: [...g.items, ...toAdd.map((col) => newGroupItem(col, colMeta(col)))] }
|
||||||
|
: g,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const addGroup = () => {
|
||||||
|
onGroupsChange([...groups, newGroup(groups.length + 1)])
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── DnD sensors & handlers ────────────────────────────────────────────────
|
||||||
|
const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 5 } }))
|
||||||
|
|
||||||
|
const findGroupOfItem = (itmId: string): WizardGroup | undefined =>
|
||||||
|
groups.find((g) => g.items.some((i) => i.id === itmId))
|
||||||
|
|
||||||
|
const onDragStart = ({ active }: DragStartEvent) => {
|
||||||
|
setActiveId(String(active.id))
|
||||||
|
}
|
||||||
|
|
||||||
|
const onDragOver = ({ over }: { over: any }) => {
|
||||||
|
if (!over) {
|
||||||
|
setOverGroupId(null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const overId = String(over.id)
|
||||||
|
if (overId.startsWith(GRP_PREFIX)) {
|
||||||
|
setOverGroupId(overId.slice(GRP_PREFIX.length))
|
||||||
|
} else if (overId.startsWith(ITM_PREFIX)) {
|
||||||
|
const itmId = overId.slice(ITM_PREFIX.length)
|
||||||
|
setOverGroupId(findGroupOfItem(itmId)?.id ?? null)
|
||||||
|
} else {
|
||||||
|
setOverGroupId(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const onDragEnd = ({ active, over }: DragEndEvent) => {
|
||||||
|
setActiveId(null)
|
||||||
|
setOverGroupId(null)
|
||||||
|
if (!over) return
|
||||||
|
|
||||||
|
const activeId = String(active.id)
|
||||||
|
const overId = String(over.id)
|
||||||
|
|
||||||
|
// ── available column dropped onto group or item ───────────────────────
|
||||||
|
if (activeId.startsWith(AV_PREFIX)) {
|
||||||
|
const colName = activeId.slice(AV_PREFIX.length)
|
||||||
|
let targetGroupId: string | null = null
|
||||||
|
if (overId.startsWith(GRP_PREFIX)) {
|
||||||
|
targetGroupId = overId.slice(GRP_PREFIX.length)
|
||||||
|
} else if (overId.startsWith(ITM_PREFIX)) {
|
||||||
|
const itmId = overId.slice(ITM_PREFIX.length)
|
||||||
|
targetGroupId = findGroupOfItem(itmId)?.id ?? null
|
||||||
|
}
|
||||||
|
if (targetGroupId) addColumnToGroup(colName, targetGroupId)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── group item being sorted / moved ───────────────────────────────────
|
||||||
|
if (activeId.startsWith(ITM_PREFIX)) {
|
||||||
|
const activeItemId = activeId.slice(ITM_PREFIX.length)
|
||||||
|
const sourceGroup = findGroupOfItem(activeItemId)
|
||||||
|
if (!sourceGroup) return
|
||||||
|
|
||||||
|
let targetGroupId: string = sourceGroup.id
|
||||||
|
let overItemId: string | null = null
|
||||||
|
|
||||||
|
if (overId.startsWith(GRP_PREFIX)) {
|
||||||
|
targetGroupId = overId.slice(GRP_PREFIX.length)
|
||||||
|
} else if (overId.startsWith(ITM_PREFIX)) {
|
||||||
|
overItemId = overId.slice(ITM_PREFIX.length)
|
||||||
|
targetGroupId = findGroupOfItem(overItemId)?.id ?? sourceGroup.id
|
||||||
|
}
|
||||||
|
|
||||||
|
if (targetGroupId === sourceGroup.id) {
|
||||||
|
// reorder within same group
|
||||||
|
const items = sourceGroup.items
|
||||||
|
const oldIndex = items.findIndex((i) => i.id === activeItemId)
|
||||||
|
const newIndex = overItemId ? items.findIndex((i) => i.id === overItemId) : items.length - 1
|
||||||
|
if (oldIndex !== newIndex && newIndex >= 0) {
|
||||||
|
onGroupsChange(
|
||||||
|
groups.map((g) =>
|
||||||
|
g.id === sourceGroup.id ? { ...g, items: arrayMove(items, oldIndex, newIndex) } : g,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// move to different group
|
||||||
|
const draggedItem = sourceGroup.items.find((i) => i.id === activeItemId)
|
||||||
|
if (!draggedItem) return
|
||||||
|
const targetGroup = groups.find((g) => g.id === targetGroupId)
|
||||||
|
if (!targetGroup) return
|
||||||
|
|
||||||
|
const clampedSpan = Math.min(draggedItem.colSpan, targetGroup.colCount)
|
||||||
|
const movedItem = { ...draggedItem, colSpan: clampedSpan }
|
||||||
|
let newItems = targetGroup.items.filter((i) => i.id !== activeItemId)
|
||||||
|
if (overItemId) {
|
||||||
|
const overIdx = newItems.findIndex((i) => i.id === overItemId)
|
||||||
|
newItems = [...newItems.slice(0, overIdx + 1), movedItem, ...newItems.slice(overIdx + 1)]
|
||||||
|
} else {
|
||||||
|
newItems = [...newItems, movedItem]
|
||||||
|
}
|
||||||
|
onGroupsChange(
|
||||||
|
groups.map((g) => {
|
||||||
|
if (g.id === sourceGroup.id)
|
||||||
|
return { ...g, items: g.items.filter((i) => i.id !== activeItemId) }
|
||||||
|
if (g.id === targetGroupId) return { ...g, items: newItems }
|
||||||
|
return g
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Drag overlay content ──────────────────────────────────────────────────
|
||||||
|
const renderOverlay = () => {
|
||||||
|
if (!activeId) return null
|
||||||
|
if (activeId.startsWith(AV_PREFIX)) {
|
||||||
|
const colName = activeId.slice(AV_PREFIX.length)
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg border border-indigo-400 bg-indigo-50 text-sm text-indigo-700 shadow-lg cursor-grabbing">
|
||||||
|
<FaGripVertical className="text-indigo-300 text-xs" />
|
||||||
|
<span className="font-medium">{colName}</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (activeId.startsWith(ITM_PREFIX)) {
|
||||||
|
const itmId = activeId.slice(ITM_PREFIX.length)
|
||||||
|
const item = groups.flatMap((g) => g.items).find((i) => i.id === itmId)
|
||||||
|
if (!item) return null
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2 px-3 py-2 rounded-lg border border-indigo-400 bg-indigo-50 text-sm shadow-lg cursor-grabbing">
|
||||||
|
<FaGripVertical className="text-indigo-300 text-xs" />
|
||||||
|
<span className="text-xs font-semibold text-indigo-600 bg-indigo-100 px-2 py-0.5 rounded">
|
||||||
|
{item.dataField}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-gray-500">{item.editorType}</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DndContext
|
||||||
|
sensors={sensors}
|
||||||
|
onDragStart={onDragStart}
|
||||||
|
onDragOver={onDragOver}
|
||||||
|
onDragEnd={onDragEnd}
|
||||||
|
>
|
||||||
|
<div className="pb-20">
|
||||||
|
<div className="flex gap-4">
|
||||||
|
{/* ── Left: Available Columns ─────────────────────────────────── */}
|
||||||
|
<div className="w-72 shrink-0">
|
||||||
|
<div className="sticky top-4">
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<span className="text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wide">
|
||||||
|
Sütunlar
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-gray-400">
|
||||||
|
{availableColumns.length}/{selectedColumns.size}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-1.5 max-h-[calc(100vh-280px)] overflow-y-auto pr-1">
|
||||||
|
{availableColumns.length === 0 ? (
|
||||||
|
<div className="text-xs text-gray-300 dark:text-gray-600 py-4 text-center select-none">
|
||||||
|
Tüm sütunlar gruplara eklendi
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
availableColumns.map((col) => <AvailableColumnChip key={col} colName={col} />)
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Right: Groups ────────────────────────────────────────────── */}
|
||||||
|
<div className="flex-1 flex flex-col gap-3">
|
||||||
|
{groups.length === 0 && (
|
||||||
|
<div className="rounded-xl border-2 border-dashed border-gray-200 dark:border-gray-700 flex items-center justify-center h-36 text-sm text-gray-300 dark:text-gray-600 select-none">
|
||||||
|
Henüz grup yok — aşağıdan grup ekleyin
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{groups.map((group) => (
|
||||||
|
<GroupCard
|
||||||
|
key={group.id}
|
||||||
|
group={group}
|
||||||
|
isOver={overGroupId === group.id}
|
||||||
|
hasAvailable={availableColumns.length > 0}
|
||||||
|
onCaptionChange={(val) => updateGroup(group.id, { caption: val })}
|
||||||
|
onColCountChange={(val) => updateGroup(group.id, { colCount: val })}
|
||||||
|
onItemChange={(itemId, patch) => updateItem(group.id, itemId, patch)}
|
||||||
|
onRemoveItem={(itemId) => removeItemFromGroup(group.id, itemId)}
|
||||||
|
onDeleteGroup={() => deleteGroup(group.id)}
|
||||||
|
onAddAll={() => addAllToGroup(group.id)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Add Group */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={addGroup}
|
||||||
|
className="w-full flex items-center justify-center gap-2 py-2.5 rounded-xl border-2 border-dashed border-gray-200 dark:border-gray-700 text-sm text-gray-400 dark:text-gray-500 hover:border-indigo-400 hover:text-indigo-500 dark:hover:border-indigo-600 dark:hover:text-indigo-400 transition-colors"
|
||||||
|
>
|
||||||
|
<FaPlus className="text-xs" />
|
||||||
|
Grup Ekle
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DragOverlay>{renderOverlay()}</DragOverlay>
|
||||||
|
|
||||||
|
{/* ── Helper Dialog ────────────────────────────────────────────────── */}
|
||||||
|
<Dialog
|
||||||
|
isOpen={isHelperOpen}
|
||||||
|
onClose={() => setIsHelperOpen(false)}
|
||||||
|
onRequestClose={() => setIsHelperOpen(false)}
|
||||||
|
preventScroll={true}
|
||||||
|
width={1000}
|
||||||
|
>
|
||||||
|
<h5 className="mb-4">Helper</h5>
|
||||||
|
|
||||||
|
<div className="space-y-4 p-4 text-sm font-mono overflow-y-auto max-h-[100vh]">
|
||||||
|
<div>
|
||||||
|
<div className="font-bold">● Editor Script</div>
|
||||||
|
<div className="ml-5 gap-2">
|
||||||
|
<pre>{'• setFormData({...formData, Path: e.value});'}</pre>
|
||||||
|
<pre>{'• UiEvalService.ApiGenerateBackgroundWorkers();'}</pre>
|
||||||
|
<pre>
|
||||||
|
{
|
||||||
|
"• setFormData({ ...formData, Path: (v => v === '1' ? '1-deneme' : v === '0' ? '0-deneme' : '')(e.value) })"
|
||||||
|
}
|
||||||
|
</pre>
|
||||||
|
<pre>
|
||||||
|
{`• (() => {const d=v=>!v?null:(v instanceof Date?v:new Date(v));const nf={...formData,[editor.dataField]:e?.value};const s=d(nf.StartDate),t=d(nf.EndDate);setFormData({...formData,TotalDays: s&&t?Math.max(0,Math.floor((Date.UTC(t.getFullYear(),t.getMonth(),t.getDate())-Date.UTC(s.getFullYear(),s.getMonth(),s.getDate()))/(24*60*60*1000))+1):null});})(); `}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div className="font-bold">● Editor Options</div>
|
||||||
|
<div className="ml-5 gap-2">
|
||||||
|
<pre>{`• {"showClearButton":true }`}</pre>
|
||||||
|
<pre>{`• {"format": "fixedPoint", "precision": 2}`}</pre>
|
||||||
|
<pre>{`• {"format": "phoneGlobal", "mask":"(000) 000-0000", "maskInvalidMessage":"Lütfen geçerli bir telefon numarası girin", "useMaskedValue":false, "maskRules": { "X": "[1-9]" }, "placeholder": "(555) 123-4567" }`}</pre>
|
||||||
|
<pre>{`• {"tooltip": { "enabled": true }}`}</pre>
|
||||||
|
<pre>{`• {"height":200}`}</pre>
|
||||||
|
<pre>{`• {"type":"date"}`}</pre>
|
||||||
|
<pre>{`• {"type":"datetime"}`}</pre>
|
||||||
|
<pre>
|
||||||
|
{`• {
|
||||||
|
"buttons": [
|
||||||
|
{
|
||||||
|
"name": "custom",
|
||||||
|
"location": "after",
|
||||||
|
"options": {
|
||||||
|
"icon": "plus",
|
||||||
|
"onClick": "function(e) { alert('Value: ' + e.formData[e.fieldName]); }"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}`}
|
||||||
|
</pre>
|
||||||
|
<pre>{`• {"toolbar": {"multiline": true, "items": [{"name": "undo"},{"name": "redo"},{"name": "separator"},{"name": "size","acceptedValues": ["8pt","10pt","12pt","14pt","18pt","24pt","36pt"],"options": {"inputAttr": {"aria-label": "Font size"}}},{"name": "font","acceptedValues": ["Arial","Courier New","Georgia","Impact","Lucida Console","Tahoma","Times New Roman","Verdana"],"options": {"inputAttr": {"aria-label": "Font family"}}},{"name": "separator"},{"name": "bold"},{"name": "italic"},{"name": "strike"},{"name": "underline"},{"name": "separator"},{"name": "alignLeft"},{"name": "alignCenter"},{"name": "alignRight"},{"name": "alignJustify"},{"name": "separator"},{"name": "orderedList"},{"name": "bulletList"},{"name": "separator"},{"name": "header","acceptedValues": [false,1,2,3,4,5],"options": {"inputAttr": {"aria-label": "Font family"}}},{"name": "separator"},{"name": "color"},{"name": "background"},{"name": "separator"},{"name": "link"},{"name": "image"},{"name": "separator"},{"name": "clear"},{"name": "codeBlock"},{"name": "blockquote"},{"name": "separator"},{"name": "insertTable"},{"name": "deleteTable"},{"name": "insertRowAbove"},{"name": "insertRowBelow"},{"name": "deleteRow"},{"name": "insertColumnLeft"},{"name": "insertColumnRight"},{"name": "deleteColumn"}]}}`}</pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
{/* ── Fixed Footer ─────────────────────────────────────────────────── */}
|
||||||
|
<div className="fixed bottom-0 left-0 right-0 z-10 bg-white dark:bg-gray-900 border-t border-gray-200 dark:border-gray-700 px-6 py-3">
|
||||||
|
<div className="max-w-sm mx-auto flex gap-3">
|
||||||
|
<Button
|
||||||
|
block
|
||||||
|
variant="default"
|
||||||
|
type="button"
|
||||||
|
onClick={() => setIsHelperOpen(true)}
|
||||||
|
title="Helper Codes"
|
||||||
|
>
|
||||||
|
{translate('::Helper Codes') || 'Helper Codes'}
|
||||||
|
</Button>
|
||||||
|
<Button block variant="default" type="button" onClick={onBack}>
|
||||||
|
{translate('::Back') || 'Back'}
|
||||||
|
</Button>
|
||||||
|
<Button block variant="solid" type="button" onClick={onNext}>
|
||||||
|
{translate('::Next') || 'Next'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</DndContext>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default WizardStep3
|
||||||
382
ui/src/views/admin/listForm/WizardStep4.tsx
Normal file
382
ui/src/views/admin/listForm/WizardStep4.tsx
Normal file
|
|
@ -0,0 +1,382 @@
|
||||||
|
import { Button } from '@/components/ui'
|
||||||
|
import type { ListFormWizardDto } from '@/proxy/admin/list-form/models'
|
||||||
|
import type { DatabaseColumnDto } from '@/proxy/sql-query-manager/models'
|
||||||
|
import type { WizardGroup } from './WizardStep3'
|
||||||
|
import { useState } from 'react'
|
||||||
|
import {
|
||||||
|
FaCheckCircle,
|
||||||
|
FaChevronDown,
|
||||||
|
FaChevronRight,
|
||||||
|
FaCircle,
|
||||||
|
FaExclamationCircle,
|
||||||
|
FaRocket,
|
||||||
|
FaSpinner,
|
||||||
|
} from 'react-icons/fa'
|
||||||
|
|
||||||
|
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface WizardStep4Props {
|
||||||
|
values: ListFormWizardDto
|
||||||
|
wizardName: string
|
||||||
|
selectedColumns: Set<string>
|
||||||
|
selectCommandColumns: DatabaseColumnDto[]
|
||||||
|
groups: WizardGroup[]
|
||||||
|
translate: (key: string) => string
|
||||||
|
onBack: () => void
|
||||||
|
onSubmit: () => Promise<void>
|
||||||
|
}
|
||||||
|
|
||||||
|
type LogStatus = 'pending' | 'running' | 'success' | 'error'
|
||||||
|
|
||||||
|
interface LogEntry {
|
||||||
|
id: number
|
||||||
|
label: string
|
||||||
|
status: LogStatus
|
||||||
|
detail?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Deploy log steps ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function buildLogSteps(values: ListFormWizardDto, groups: WizardGroup[]): Omit<LogEntry, 'status'>[] {
|
||||||
|
const totalFields = groups.reduce((acc, g) => acc + g.items.length, 0)
|
||||||
|
return [
|
||||||
|
{ id: 1, label: 'Konfigürasyon doğrulanıyor…' },
|
||||||
|
{ id: 2, label: `Menü oluşturuluyor: ${values.menuCode}`, detail: `Parent: ${values.menuParentCode}` },
|
||||||
|
{ id: 3, label: 'Dil metinleri kaydediliyor', detail: `EN: ${values.languageTextMenuEn} / TR: ${values.languageTextMenuTr}` },
|
||||||
|
{ id: 4, label: `İzin grubu yapılandırılıyor: ${values.permissionGroupName}` },
|
||||||
|
{ id: 5, label: `Veri kaynağı bağlanıyor: ${values.dataSourceCode}` },
|
||||||
|
{ id: 6, label: `ListForm oluşturuluyor: ${values.listFormCode}`, detail: `Key: ${values.keyFieldName}` },
|
||||||
|
{ id: 7, label: `Form grupları kaydediliyor (${groups.length} grup, ${totalFields} alan)` },
|
||||||
|
{ id: 8, label: 'Sunucuya deploy ediliyor…' },
|
||||||
|
{ id: 9, label: 'Tamamlandı ✓' },
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Mini-components ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function LogIcon({ status }: { status: LogStatus }) {
|
||||||
|
if (status === 'running') return <FaSpinner className="text-indigo-500 animate-spin shrink-0" />
|
||||||
|
if (status === 'success') return <FaCheckCircle className="text-emerald-500 shrink-0" />
|
||||||
|
if (status === 'error') return <FaExclamationCircle className="text-red-500 shrink-0" />
|
||||||
|
return <FaCircle className="text-gray-300 dark:text-gray-600 shrink-0 text-[8px] mt-1" />
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SectionProps {
|
||||||
|
title: string
|
||||||
|
badge?: string | number
|
||||||
|
children: React.ReactNode
|
||||||
|
defaultOpen?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
function Section({ title, badge, children, defaultOpen = true }: SectionProps) {
|
||||||
|
const [open, setOpen] = useState(defaultOpen)
|
||||||
|
return (
|
||||||
|
<div className="rounded-xl border border-gray-200 dark:border-gray-700 overflow-hidden">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setOpen((v) => !v)}
|
||||||
|
className="w-full flex items-center justify-between px-4 py-2.5 bg-gray-50 dark:bg-gray-800 hover:bg-gray-100 dark:hover:bg-gray-750 transition-colors"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{open ? (
|
||||||
|
<FaChevronDown className="text-gray-400 text-xs" />
|
||||||
|
) : (
|
||||||
|
<FaChevronRight className="text-gray-400 text-xs" />
|
||||||
|
)}
|
||||||
|
<span className="text-sm font-semibold text-gray-700 dark:text-gray-200">{title}</span>
|
||||||
|
</div>
|
||||||
|
{badge !== undefined && (
|
||||||
|
<span className="text-xs bg-indigo-100 dark:bg-indigo-900/40 text-indigo-600 dark:text-indigo-400 px-2 py-0.5 rounded-full font-medium">
|
||||||
|
{badge}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
{open && <div className="px-4 py-3 bg-white dark:bg-gray-900">{children}</div>}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Row({ label, value }: { label: string; value?: string | number }) {
|
||||||
|
if (!value && value !== 0) return null
|
||||||
|
return (
|
||||||
|
<div className="flex gap-2 py-1 border-b border-gray-100 dark:border-gray-800 last:border-0">
|
||||||
|
<span className="text-xs text-gray-400 w-40 shrink-0">{label}</span>
|
||||||
|
<span className="text-xs text-gray-700 dark:text-gray-200 font-medium break-all">{value}</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── WizardStep4 ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const WizardStep4 = ({
|
||||||
|
values,
|
||||||
|
wizardName,
|
||||||
|
selectedColumns,
|
||||||
|
selectCommandColumns,
|
||||||
|
groups,
|
||||||
|
translate,
|
||||||
|
onBack,
|
||||||
|
onSubmit,
|
||||||
|
}: WizardStep4Props) => {
|
||||||
|
const [logs, setLogs] = useState<LogEntry[]>([])
|
||||||
|
const [isDeploying, setIsDeploying] = useState(false)
|
||||||
|
const [isDone, setIsDone] = useState(false)
|
||||||
|
const [hasError, setHasError] = useState(false)
|
||||||
|
|
||||||
|
const steps = buildLogSteps(values, groups)
|
||||||
|
|
||||||
|
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms))
|
||||||
|
|
||||||
|
const runDeploy = async () => {
|
||||||
|
if (isDeploying) return
|
||||||
|
setIsDeploying(true)
|
||||||
|
setHasError(false)
|
||||||
|
setIsDone(false)
|
||||||
|
|
||||||
|
// Initialize all as pending
|
||||||
|
setLogs(steps.map((s) => ({ ...s, status: 'pending' })))
|
||||||
|
|
||||||
|
const setStatus = (id: number, status: LogStatus) =>
|
||||||
|
setLogs((prev) => prev.map((l) => (l.id === id ? { ...l, status } : l)))
|
||||||
|
|
||||||
|
// Steps 1-7: pre-deploy simulation (fast)
|
||||||
|
for (let i = 0; i < steps.length - 2; i++) {
|
||||||
|
const step = steps[i]
|
||||||
|
setStatus(step.id, 'running')
|
||||||
|
await sleep(300 + Math.random() * 200)
|
||||||
|
setStatus(step.id, 'success')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 8: actual API call
|
||||||
|
const deployStep = steps[steps.length - 2]
|
||||||
|
setStatus(deployStep.id, 'running')
|
||||||
|
try {
|
||||||
|
await onSubmit()
|
||||||
|
setStatus(deployStep.id, 'success')
|
||||||
|
await sleep(200)
|
||||||
|
// Step 9: done
|
||||||
|
const doneStep = steps[steps.length - 1]
|
||||||
|
setStatus(doneStep.id, 'running')
|
||||||
|
await sleep(300)
|
||||||
|
setStatus(doneStep.id, 'success')
|
||||||
|
setIsDone(true)
|
||||||
|
} catch (err: any) {
|
||||||
|
setStatus(deployStep.id, 'error')
|
||||||
|
setLogs((prev) => [
|
||||||
|
...prev,
|
||||||
|
{
|
||||||
|
id: 999,
|
||||||
|
label: `Hata: ${err?.message ?? 'Bilinmeyen hata'}`,
|
||||||
|
status: 'error',
|
||||||
|
},
|
||||||
|
])
|
||||||
|
setHasError(true)
|
||||||
|
} finally {
|
||||||
|
setIsDeploying(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const totalFields = groups.reduce((acc, g) => acc + g.items.length, 0)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex gap-6 pb-24">
|
||||||
|
{/* ── Left: Summary ────────────────────────────────────────────── */}
|
||||||
|
<div className="flex-1 flex flex-col gap-3 overflow-y-auto max-h-[calc(100vh-220px)]">
|
||||||
|
<h6 className="text-sm font-bold text-gray-600 dark:text-gray-300 uppercase tracking-wide mb-1">
|
||||||
|
Özet
|
||||||
|
</h6>
|
||||||
|
|
||||||
|
{/* Step 1 Summary */}
|
||||||
|
<Section title="Menü Bilgileri">
|
||||||
|
<Row label="Wizard Adı" value={wizardName} />
|
||||||
|
<Row label="Menu Code" value={values.menuCode} />
|
||||||
|
<Row label="Menu Parent" value={values.menuParentCode} />
|
||||||
|
<Row label="İzin Grubu" value={values.permissionGroupName} />
|
||||||
|
<Row label="İkon" value={values.menuIcon} />
|
||||||
|
<Row label="Menü (TR)" value={values.languageTextMenuTr} />
|
||||||
|
<Row label="Menü (EN)" value={values.languageTextMenuEn} />
|
||||||
|
<Row label="Menü Parent (TR)" value={values.languageTextMenuParentTr} />
|
||||||
|
<Row label="Menü Parent (EN)" value={values.languageTextMenuParentEn} />
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
{/* Step 2 Summary */}
|
||||||
|
<Section title="ListForm Ayarları">
|
||||||
|
<Row label="ListForm Code" value={values.listFormCode} />
|
||||||
|
<Row label="Başlık (TR)" value={values.languageTextTitleTr} />
|
||||||
|
<Row label="Başlık (EN)" value={values.languageTextTitleEn} />
|
||||||
|
<Row label="Açıklama (TR)" value={values.languageTextDescTr} />
|
||||||
|
<Row label="Açıklama (EN)" value={values.languageTextDescEn} />
|
||||||
|
<Row label="Veri Kaynağı" value={values.dataSourceCode} />
|
||||||
|
<Row label="Connection String" value={values.dataSourceConnectionString} />
|
||||||
|
<Row label="Komut Tipi" value={values.selectCommandType} />
|
||||||
|
<Row label="Select Command" value={values.selectCommand} />
|
||||||
|
<Row label="Key Field" value={values.keyFieldName} />
|
||||||
|
<Row label="Key Field Tipi" value={String(values.keyFieldDbSourceType)} />
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
{/* Columns */}
|
||||||
|
<Section title="Seçili Sütunlar" badge={selectedColumns.size}>
|
||||||
|
<div className="flex flex-wrap gap-1.5">
|
||||||
|
{[...selectedColumns].map((col) => {
|
||||||
|
const meta = selectCommandColumns.find((c) => c.columnName === col)
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
key={col}
|
||||||
|
className="inline-flex items-center gap-1 px-2 py-0.5 text-xs rounded-full bg-indigo-50 dark:bg-indigo-900/30 text-indigo-700 dark:text-indigo-300 border border-indigo-200 dark:border-indigo-700"
|
||||||
|
>
|
||||||
|
{col}
|
||||||
|
{meta?.dataType && (
|
||||||
|
<span className="text-[10px] text-indigo-400">{meta.dataType}</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
{/* Step 3 – Groups */}
|
||||||
|
<Section title="Form Grupları" badge={groups.length} defaultOpen={true}>
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
{groups.map((g) => (
|
||||||
|
<Section key={g.id} title={g.caption || '(Grup)'} badge={`${g.items.length} alan · ${g.colCount} sütun`} defaultOpen={false}>
|
||||||
|
<div className="flex flex-col gap-0.5">
|
||||||
|
{g.items.length === 0 ? (
|
||||||
|
<span className="text-xs text-gray-300 italic">Alan yok</span>
|
||||||
|
) : (
|
||||||
|
g.items.map((item) => (
|
||||||
|
<div
|
||||||
|
key={item.id}
|
||||||
|
className="flex items-center gap-2 py-1 border-b border-gray-100 dark:border-gray-800 last:border-0"
|
||||||
|
>
|
||||||
|
<span className="text-xs font-medium text-indigo-600 dark:text-indigo-400 w-36 shrink-0 truncate">
|
||||||
|
{item.dataField}
|
||||||
|
</span>
|
||||||
|
<span className="text-[10px] text-gray-400 bg-gray-100 dark:bg-gray-800 px-1.5 py-0.5 rounded">
|
||||||
|
{item.editorType}
|
||||||
|
</span>
|
||||||
|
<span className="text-[10px] text-gray-400 ml-auto">
|
||||||
|
span:{item.colSpan}
|
||||||
|
{item.isRequired && (
|
||||||
|
<span className="ml-1 text-red-400 font-semibold">*</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Section>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</Section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Right: Deploy ────────────────────────────────────────────── */}
|
||||||
|
<div className="w-96 shrink-0 flex flex-col gap-4">
|
||||||
|
{/* Stats bar */}
|
||||||
|
<div className="grid grid-cols-3 gap-2">
|
||||||
|
{[
|
||||||
|
{ label: 'Grup', value: groups.length },
|
||||||
|
{ label: 'Alan', value: totalFields },
|
||||||
|
{ label: 'Sütun', value: selectedColumns.size },
|
||||||
|
].map((s) => (
|
||||||
|
<div
|
||||||
|
key={s.label}
|
||||||
|
className="rounded-xl border border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800 p-3 text-center"
|
||||||
|
>
|
||||||
|
<div className="text-xl font-bold text-indigo-600 dark:text-indigo-400">{s.value}</div>
|
||||||
|
<div className="text-xs text-gray-400 mt-0.5">{s.label}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Log panel */}
|
||||||
|
<div className="rounded-xl border border-gray-200 dark:border-gray-700 overflow-hidden flex flex-col flex-1">
|
||||||
|
<div className="px-4 py-2.5 bg-gray-50 dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700 flex items-center justify-between">
|
||||||
|
<span className="text-sm font-semibold text-gray-700 dark:text-gray-200">Deploy Log</span>
|
||||||
|
{isDone && (
|
||||||
|
<span className="text-xs text-emerald-500 font-semibold flex items-center gap-1">
|
||||||
|
<FaCheckCircle /> Başarılı
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{hasError && (
|
||||||
|
<span className="text-xs text-red-500 font-semibold flex items-center gap-1">
|
||||||
|
<FaExclamationCircle /> Hata
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 min-h-[280px] max-h-[calc(100vh-380px)] overflow-y-auto p-3 bg-gray-950 dark:bg-black font-mono">
|
||||||
|
{logs.length === 0 ? (
|
||||||
|
<div className="text-xs text-gray-500 italic py-4 text-center select-none">
|
||||||
|
Deploy başlatmak için butona tıklayın
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
{logs.map((log) => (
|
||||||
|
<div key={log.id} className="flex items-start gap-2">
|
||||||
|
<LogIcon status={log.status} />
|
||||||
|
<div>
|
||||||
|
<span
|
||||||
|
className={`text-xs ${
|
||||||
|
log.status === 'success'
|
||||||
|
? 'text-emerald-400'
|
||||||
|
: log.status === 'error'
|
||||||
|
? 'text-red-400'
|
||||||
|
: log.status === 'running'
|
||||||
|
? 'text-indigo-300'
|
||||||
|
: 'text-gray-500'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{log.label}
|
||||||
|
</span>
|
||||||
|
{log.detail && (
|
||||||
|
<div className="text-[10px] text-gray-600 mt-0.5">{log.detail}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isDone && (
|
||||||
|
<div className="rounded-xl border border-emerald-200 dark:border-emerald-800 bg-emerald-50 dark:bg-emerald-900/20 p-4 text-sm text-emerald-700 dark:text-emerald-300 text-center font-medium">
|
||||||
|
🎉 ListForm başarıyla oluşturuldu ve deploy edildi!
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Fixed Footer ─────────────────────────────────────────────── */}
|
||||||
|
<div className="fixed bottom-0 left-0 right-0 z-10 bg-white dark:bg-gray-900 border-t border-gray-200 dark:border-gray-700 px-6 py-3">
|
||||||
|
<div className="max-w-5xl mx-auto flex gap-3">
|
||||||
|
<Button
|
||||||
|
variant="default"
|
||||||
|
type="button"
|
||||||
|
onClick={onBack}
|
||||||
|
disabled={isDeploying}
|
||||||
|
>
|
||||||
|
{translate('::Back') || 'Back'}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
block
|
||||||
|
variant="solid"
|
||||||
|
type="button"
|
||||||
|
icon={<FaRocket />}
|
||||||
|
loading={isDeploying}
|
||||||
|
disabled={isDeploying || isDone}
|
||||||
|
onClick={runDeploy}
|
||||||
|
>
|
||||||
|
{isDeploying
|
||||||
|
? 'Deploy ediliyor…'
|
||||||
|
: isDone
|
||||||
|
? 'Tamamlandı'
|
||||||
|
: translate('::Save') || 'Deploy & Save'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default WizardStep4
|
||||||
Loading…
Reference in a new issue