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",
|
||||
"key": "App.Listforms.Wizard",
|
||||
"en": "Wizard",
|
||||
"tr": "Sihirbaz"
|
||||
"en": "Listform Wizard",
|
||||
"tr": "Listform Sihirbazı"
|
||||
},
|
||||
{
|
||||
"resourceName": "Platform",
|
||||
|
|
|
|||
|
|
@ -1050,10 +1050,10 @@
|
|||
"IsDisabled": false
|
||||
},
|
||||
{
|
||||
"ParentCode": "App.Administration",
|
||||
"ParentCode": "App.DeveloperKit",
|
||||
"Code": "App.Listforms.Wizard",
|
||||
"DisplayName": "App.Listforms.Wizard",
|
||||
"Order": 10,
|
||||
"Order": 9,
|
||||
"Url": "/admin/listform/wizard",
|
||||
"Icon": "FcFlashAuto",
|
||||
"RequiredPermissionName": "App.Listforms.Wizard",
|
||||
|
|
|
|||
|
|
@ -24,6 +24,8 @@ import WizardStep1, {
|
|||
findRootCode,
|
||||
} from './WizardStep1'
|
||||
import WizardStep2, { sqlDataTypeToDbType } from './WizardStep2'
|
||||
import WizardStep3, { WizardGroup } from './WizardStep3'
|
||||
import WizardStep4 from './WizardStep4'
|
||||
import { Container } from '@/components/shared'
|
||||
|
||||
// ─── Formik initial values & validation ──────────────────────────────────────
|
||||
|
|
@ -114,10 +116,14 @@ const Wizard = () => {
|
|||
const [isLoadingColumns, setIsLoadingColumns] = useState(false)
|
||||
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) => {
|
||||
if (!dsCode || !name) {
|
||||
setSelectCommandColumns([])
|
||||
setSelectedColumns(new Set())
|
||||
setEditingGroups([])
|
||||
return
|
||||
}
|
||||
setIsLoadingColumns(true)
|
||||
|
|
@ -126,11 +132,15 @@ const Wizard = () => {
|
|||
const cols = res.data ?? []
|
||||
setSelectCommandColumns(cols)
|
||||
setSelectedColumns(new Set(cols.map((c) => c.columnName)))
|
||||
setEditingGroups([])
|
||||
// Auto-select first column as key field
|
||||
if (cols.length > 0) {
|
||||
const first = cols[0]
|
||||
formikRef.current?.setFieldValue('keyFieldName', first.columnName)
|
||||
formikRef.current?.setFieldValue('keyFieldDbSourceType', sqlDataTypeToDbType(first.dataType))
|
||||
formikRef.current?.setFieldValue(
|
||||
'keyFieldDbSourceType',
|
||||
sqlDataTypeToDbType(first.dataType),
|
||||
)
|
||||
}
|
||||
} catch {
|
||||
setSelectCommandColumns([])
|
||||
|
|
@ -283,6 +293,26 @@ const Wizard = () => {
|
|||
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 (
|
||||
<Container>
|
||||
<Helmet
|
||||
|
|
@ -336,7 +366,7 @@ const Wizard = () => {
|
|||
>
|
||||
{({ touched, errors, isSubmitting, values }) => (
|
||||
<Form>
|
||||
<FormContainer size="sm">
|
||||
<FormContainer size={currentStep === 2 ? undefined : currentStep === 3 ? undefined : 'sm'}>
|
||||
{/* ─── Step 1: Basic Info ─────────────────────────────── */}
|
||||
{currentStep === 0 && (
|
||||
<WizardStep1
|
||||
|
|
@ -389,26 +419,29 @@ const Wizard = () => {
|
|||
|
||||
{/* ─── Step 3: List Form Fields ───────────────────────────── */}
|
||||
{currentStep === 2 && (
|
||||
<div className="flex gap-2 mt-4">
|
||||
<Button block variant="default" type="button" onClick={() => setCurrentStep(1)}>
|
||||
{translate('::Back') || 'Back'}
|
||||
</Button>
|
||||
<Button block variant="solid" type="button" onClick={() => setCurrentStep(3)}>
|
||||
{translate('::Next') || 'Next'}
|
||||
</Button>
|
||||
</div>
|
||||
<WizardStep3
|
||||
selectedColumns={selectedColumns}
|
||||
selectCommandColumns={selectCommandColumns}
|
||||
groups={editingGroups}
|
||||
onGroupsChange={setEditingGroups}
|
||||
translate={translate}
|
||||
onBack={() => setCurrentStep(1)}
|
||||
onNext={() => setCurrentStep(3)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* ─── Step 4: Deploy ───────────────────────────── */}
|
||||
{currentStep === 3 && (
|
||||
<div className="flex gap-2 mt-4">
|
||||
<Button block variant="default" type="button" onClick={() => setCurrentStep(2)}>
|
||||
{translate('::Back') || 'Back'}
|
||||
</Button>
|
||||
<Button block variant="solid" loading={isSubmitting} type="submit">
|
||||
{isSubmitting ? translate('::SavingWithThreeDot') : translate('::Save')}
|
||||
</Button>
|
||||
</div>
|
||||
<WizardStep4
|
||||
values={values}
|
||||
wizardName={wizardName}
|
||||
selectedColumns={selectedColumns}
|
||||
selectCommandColumns={selectCommandColumns}
|
||||
groups={editingGroups}
|
||||
translate={translate}
|
||||
onBack={() => setCurrentStep(2)}
|
||||
onSubmit={handleDeploy}
|
||||
/>
|
||||
)}
|
||||
</FormContainer>
|
||||
</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