Wizard Step3 ve Wizard Step4 güncellemesi

This commit is contained in:
Sedat Öztürk 2026-02-27 23:57:11 +03:00
parent 4c5cfe06f8
commit f2652dbb44
5 changed files with 1203 additions and 22 deletions

View file

@ -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",

View file

@ -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",

View file

@ -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>

View 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

View 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