Wizard AutoFill Required Fields

This commit is contained in:
Sedat ÖZTÜRK 2026-09-04 09:28:42 +03:00
parent 47d9a20998
commit e237cacc2a
8 changed files with 816 additions and 121 deletions

View file

@ -16248,6 +16248,24 @@
"en": "Required",
"tr": "Gerekli"
},
{
"resourceName": "Platform",
"key": "App.ListFormWizard.RequiredFieldsMissing",
"en": "Fill in the required column mappings before continuing.",
"tr": "Devam etmeden önce zorunlu kolon eşleşmelerini doldurun."
},
{
"resourceName": "Platform",
"key": "App.ListFormWizard.AutoFillRequired",
"en": "Auto Fill Required Fields",
"tr": "Zorunlu Alanları Otomatik Doldur"
},
{
"resourceName": "Platform",
"key": "App.ListFormWizard.AutoFillUnmatched",
"en": "Some required fields could not be matched from the select command columns; please select them manually.",
"tr": "Bazı zorunlu alanlar select command kolonlarından eşleştirilemedi; elle seçmelisiniz."
},
{
"resourceName": "Platform",
"key": "App.ListFormWizard.ListFormFields",

View file

@ -937,7 +937,7 @@ const FileManager = () => {
</div>
<Select
size="xs"
size="sm"
options={sortOptions}
value={selectedSortOption}
onChange={(option) => {
@ -976,7 +976,7 @@ const FileManager = () => {
<div className="flex items-center gap-2">
{isHostContext ? (
<Select
size="xs"
size="sm"
isLoading={tenantsLoading}
options={tenantOptions}
value={selectedTenantOption}

View file

@ -3,8 +3,8 @@ import type { ListFormWizardDto } from '@/proxy/admin/wizard/models'
import type { DatabaseColumnDto } from '@/proxy/sql-query-manager/models'
import type { SelectBoxOption } from '@/types/shared'
import { Field, type FieldProps, useFormikContext } from 'formik'
import { useMemo } from 'react'
import { FaArrowLeft, FaArrowRight } from 'react-icons/fa'
import { useMemo, useState } from 'react'
import { FaArrowLeft, FaArrowRight, FaMagic } from 'react-icons/fa'
import { gantScaleOptions } from '../edit/options'
interface WizardStepGanttLayoutProps {
@ -14,6 +14,99 @@ interface WizardStepGanttLayoutProps {
onNext: () => void
}
type RequiredField =
'keyExpr' | 'parentIdExpr' | 'titleExpr' | 'startExpr' | 'endExpr' | 'progressExpr'
type ColumnKind = 'any' | 'date' | 'number' | 'text'
/**
* Gantt görünümünün çalışması için şart olan eşleşmeler. Liste `views/list/List.tsx`
* içindeki `isLayoutValid` kontrolüyle aynıdır: biri boşsa ekran grid'e düşer.
* `hints`, otomatik doldurmada öncelik sırasına göre denenen kolon adı parçalarıdır.
*/
const REQUIRED_FIELDS: Array<{
name: RequiredField
labelKey: string
kind?: ColumnKind
hints: string[]
}> = [
{
name: 'keyExpr',
labelKey: '::App.Listform.ListformField.KeyFieldName',
hints: ['id', 'key', 'kod', 'code', 'recordid'],
},
{
name: 'parentIdExpr',
labelKey: '::App.ListFormEdit.ParentIdExpr',
hints: ['parentid', 'ustid', 'parentkey', 'parentcode', 'ustkayitid', 'parent', 'ust'],
},
{
name: 'titleExpr',
labelKey: '::App.ListFormEdit.TitleExpr',
kind: 'text',
hints: ['title', 'baslik', 'gorevadi', 'taskname', 'konu', 'subject', 'name', 'adi', 'ad'],
},
{
name: 'startExpr',
labelKey: '::App.ListFormEdit.StartExpr',
kind: 'date',
hints: ['startdate', 'baslangictarihi', 'baslangic', 'begindate', 'start', 'begin'],
},
{
name: 'endExpr',
labelKey: '::App.ListFormEdit.EndExpr',
kind: 'date',
hints: ['enddate', 'bitistarihi', 'bitis', 'finishdate', 'duedate', 'end', 'finish'],
},
{
name: 'progressExpr',
labelKey: '::App.ListFormEdit.ProgressExpr',
kind: 'number',
hints: ['progress', 'ilerleme', 'tamamlanma', 'completion', 'percent', 'yuzde', 'oran'],
},
]
const normalize = (value: string) =>
value
.toLowerCase()
.replace(/ı/g, 'i')
.replace(/ş/g, 's')
.replace(/ğ/g, 'g')
.replace(/ü/g, 'u')
.replace(/ö/g, 'o')
.replace(/ç/g, 'c')
.replace(/[^a-z0-9]/g, '')
const kindOf = (dataType: string): ColumnKind => {
const type = (dataType || '').toLowerCase()
if (/date|time|timestamp/.test(type)) return 'date'
if (/int|decimal|numeric|float|real|money|double|number/.test(type)) return 'number'
if (/char|text|string|clob/.test(type)) return 'text'
return 'any'
}
/** Eşleşme yoksa -1; aksi halde kolonun alana ne kadar uyduğunu gösteren skor. */
const scoreColumn = (column: DatabaseColumnDto, hints: string[], kind?: ColumnKind) => {
const name = normalize(column.columnName)
let best = -1
hints.forEach((hint, index) => {
const needle = normalize(hint)
let score = -1
if (name === needle) score = 100
else if (name.endsWith(needle)) score = 70
else if (name.startsWith(needle)) score = 60
else if (name.includes(needle)) score = 40
if (score >= 0 && score - index > best) best = score - index
})
if (best < 0 || !kind || kind === 'any') return best
const columnKind = kindOf(column.dataType)
if (columnKind === kind) return best + 20
// Tarih/sayı bekleyen alana metin kolonu atamak görünümü çalışmaz hale getirir.
if (columnKind !== 'any' && kind !== 'text') return -1
return best
}
/**
* Gantt layout adımı: görev başlığı, başlangıç/bitiş tarihi, ilerleme ve hiyerarşi
* (anahtar / üst anahtar) kolonlarının eşleştirilmesi ile ölçek ve genişletme davranışı.
@ -25,7 +118,9 @@ const WizardStepGanttLayout = ({
onBack,
onNext,
}: WizardStepGanttLayoutProps) => {
const { values } = useFormikContext<ListFormWizardDto>()
const { values, setFieldValue } = useFormikContext<ListFormWizardDto>()
const [errorFields, setErrorFields] = useState<RequiredField[]>([])
const [autoFillMessage, setAutoFillMessage] = useState('')
const fieldOptions = useMemo<SelectBoxOption[]>(
() =>
selectCommandColumns.map((column) => ({
@ -35,14 +130,6 @@ const WizardStepGanttLayout = ({
[selectCommandColumns],
)
const option = values.ganttOptionDto
const selectFields = [
['keyExpr', translate('::App.Listform.ListformField.KeyFieldName')],
['parentIdExpr', translate('::App.ListFormEdit.ParentIdExpr')],
['titleExpr', translate('::App.ListFormEdit.TitleExpr')],
['startExpr', translate('::App.ListFormEdit.StartExpr')],
['endExpr', translate('::App.ListFormEdit.EndExpr')],
['progressExpr', translate('::App.ListFormEdit.ProgressExpr')],
] as const
const permissionFields = [
['editingEnabled', '::App.ListFormEdit.EditingEnabled'],
['allowTaskAdding', '::App.ListFormEdit.AllowTaskAdding'],
@ -56,27 +143,93 @@ const WizardStepGanttLayout = ({
['allowTaskResourceUpdating', '::App.ListFormEdit.AllowTaskResourceUpdating'],
] as const
const autoFillRequired = () => {
const used = new Set<string>()
let unmatched = false
REQUIRED_FIELDS.forEach((field) => {
const existing = option[field.name]?.trim()
if (existing) {
used.add(existing)
return
}
// Anahtar alan Settings adımında zaten seçildi; tahmin etmeye gerek yok.
const preferred = field.name === 'keyExpr' ? values.keyFieldName?.trim() : ''
if (
preferred &&
!used.has(preferred) &&
selectCommandColumns.some((column) => column.columnName === preferred)
) {
used.add(preferred)
setFieldValue(`ganttOptionDto.${field.name}`, preferred)
return
}
let bestColumn = ''
let bestScore = -1
selectCommandColumns.forEach((column) => {
if (used.has(column.columnName)) return
const score = scoreColumn(column, field.hints, field.kind)
if (score > bestScore) {
bestScore = score
bestColumn = column.columnName
}
})
if (bestScore < 0) {
unmatched = true
return
}
used.add(bestColumn)
setFieldValue(`ganttOptionDto.${field.name}`, bestColumn)
})
setErrorFields([])
setAutoFillMessage(unmatched ? translate('::App.ListFormWizard.AutoFillUnmatched') : '')
}
const continueToNext = () => {
const missing = REQUIRED_FIELDS.filter((field) => !option[field.name]?.trim()).map(
(field) => field.name,
)
setErrorFields(missing)
if (missing.length === 0) onNext()
}
return (
<div className="pb-20">
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
<Card header={translate('::App.ListFormSchedulerOptions.BasicSettings')}>
<div className="grid grid-cols-1 gap-x-5 md:grid-cols-2">
{selectFields.map(([name, label]) => (
<FormItem key={name} label={label}>
<Field name={`ganttOptionDto.${name}`}>
{({ field, form }: FieldProps) => (
{REQUIRED_FIELDS.map((field) => {
const invalid = errorFields.includes(field.name)
return (
<FormItem
key={field.name}
label={`${translate(field.labelKey)} *`}
invalid={invalid}
errorMessage={invalid ? translate('::App.ListFormWizard.Required') : undefined}
>
<Field name={`ganttOptionDto.${field.name}`}>
{({ form, field: formikField }: FieldProps) => (
<Select
field={field}
field={formikField}
form={form}
isClearable
options={fieldOptions}
value={fieldOptions.find((fieldOption) => fieldOption.value === option[name])}
onChange={(selected) => form.setFieldValue(field.name, selected?.value ?? '')}
isClearable
value={fieldOptions.find(
(fieldOption) => fieldOption.value === option[field.name],
)}
onChange={(selected) => {
form.setFieldValue(formikField.name, selected?.value ?? '')
setErrorFields((current) => current.filter((name) => name !== field.name))
}}
/>
)}
</Field>
</FormItem>
))}
)
})}
<FormItem label={translate('::App.ListFormEdit.HasItemsExpr')}>
<Field name="ganttOptionDto.hasItemsExpr" component={Input} />
</FormItem>
@ -121,17 +274,41 @@ const WizardStepGanttLayout = ({
</Card>
</div>
{autoFillMessage && <p className="mt-4 text-sm text-amber-600">{autoFillMessage}</p>}
{errorFields.length > 0 && (
<p className="mt-2 text-sm text-red-500">
{translate('::App.ListFormWizard.RequiredFieldsMissing')}
</p>
)}
<div className="fixed bottom-0 left-0 right-0 z-10 flex h-16 items-center border-t border-gray-200 bg-white px-6 dark:border-gray-700 dark:bg-gray-900">
<div className="flex w-full items-center justify-between gap-3">
<Button type="button" size="sm" icon={<FaArrowLeft />} onClick={onBack}>
{translate('::App.Platform.Back')}
</Button>
<Button type="button" size="sm" variant="solid" icon={<FaArrowRight />} onClick={onNext}>
<div className="flex items-center gap-3">
<Button
type="button"
size="sm"
icon={<FaMagic />}
disabled={selectCommandColumns.length === 0}
onClick={autoFillRequired}
>
{translate('::App.ListFormWizard.AutoFillRequired')}
</Button>
<Button
type="button"
size="sm"
variant="solid"
icon={<FaArrowRight />}
onClick={continueToNext}
>
{translate('::App.Platform.Next')}
</Button>
</div>
</div>
</div>
</div>
)
}

View file

@ -15,7 +15,6 @@ import { MenuItem } from '@/proxy/menus/menu'
import { MenuService } from '@/services/menu.service'
import { Field, FieldProps, FormikErrors, FormikTouched } from 'formik'
import { useEffect, useMemo, useState } from 'react'
import CreatableSelect from 'react-select/creatable'
import {
FaArrowDown,
FaArrowLeft,

View file

@ -3,8 +3,8 @@ import type { ListFormWizardDto } from '@/proxy/admin/wizard/models'
import type { DatabaseColumnDto } from '@/proxy/sql-query-manager/models'
import type { SelectBoxOption } from '@/types/shared'
import { Field, type FieldProps, useFormikContext } from 'formik'
import { useMemo } from 'react'
import { FaArrowLeft, FaArrowRight } from 'react-icons/fa'
import { useMemo, useState } from 'react'
import { FaArrowLeft, FaArrowRight, FaMagic } from 'react-icons/fa'
import { firstDayOfWeekOptions, schedulerViewOptions } from '../edit/options'
interface WizardStepSchedulerLayoutProps {
@ -14,6 +14,88 @@ interface WizardStepSchedulerLayoutProps {
onNext: () => void
}
type RequiredField = 'textExpr' | 'startDateExpr' | 'endDateExpr'
type ColumnKind = 'any' | 'date' | 'number' | 'text'
/**
* Takvim görünümünün çalışması için şart olan eşleşmeler. Liste `views/list/List.tsx`
* içindeki `isLayoutValid` kontrolüyle aynıdır: biri boşsa ekran grid'e düşer.
* `hints`, otomatik doldurmada öncelik sırasına göre denenen kolon adı parçalarıdır.
*/
const REQUIRED_FIELDS: Array<{
name: RequiredField
labelKey: string
kind?: ColumnKind
hints: string[]
}> = [
{
name: 'textExpr',
labelKey: '::App.ListFormSchedulerOptions.TextExpr',
kind: 'text',
hints: ['text', 'title', 'baslik', 'konu', 'subject', 'name', 'adi', 'ad'],
},
{
name: 'startDateExpr',
labelKey: '::App.ListFormSchedulerOptions.StartDateExpr',
kind: 'date',
hints: ['startdate', 'baslangictarihi', 'baslangic', 'begindate', 'starttime', 'start'],
},
{
name: 'endDateExpr',
labelKey: '::App.ListFormSchedulerOptions.EndDateExpr',
kind: 'date',
hints: ['enddate', 'bitistarihi', 'bitis', 'finishdate', 'endtime', 'end'],
},
]
/** Tekrar eden randevu alanları; zorunlu değildir. */
const RECURRENCE_FIELDS = [
['recurrenceRuleExpr', '::App.ListFormSchedulerOptions.RecurrenceRuleExpr'],
['recurrenceExceptionExpr', '::App.ListFormSchedulerOptions.RecurrenceExceptionExpr'],
] as const
const normalize = (value: string) =>
value
.toLowerCase()
.replace(/ı/g, 'i')
.replace(/ş/g, 's')
.replace(/ğ/g, 'g')
.replace(/ü/g, 'u')
.replace(/ö/g, 'o')
.replace(/ç/g, 'c')
.replace(/[^a-z0-9]/g, '')
const kindOf = (dataType: string): ColumnKind => {
const type = (dataType || '').toLowerCase()
if (/date|time|timestamp/.test(type)) return 'date'
if (/int|decimal|numeric|float|real|money|double|number/.test(type)) return 'number'
if (/char|text|string|clob/.test(type)) return 'text'
return 'any'
}
/** Eşleşme yoksa -1; aksi halde kolonun alana ne kadar uyduğunu gösteren skor. */
const scoreColumn = (column: DatabaseColumnDto, hints: string[], kind?: ColumnKind) => {
const name = normalize(column.columnName)
let best = -1
hints.forEach((hint, index) => {
const needle = normalize(hint)
let score = -1
if (name === needle) score = 100
else if (name.endsWith(needle)) score = 70
else if (name.startsWith(needle)) score = 60
else if (name.includes(needle)) score = 40
if (score >= 0 && score - index > best) best = score - index
})
if (best < 0 || !kind || kind === 'any') return best
const columnKind = kindOf(column.dataType)
if (columnKind === kind) return best + 20
// Tarih bekleyen alana metin kolonu atamak takvimi çalışmaz hale getirir.
if (columnKind !== 'any' && kind !== 'text') return -1
return best
}
/**
* Takvim layout adımı: randevu başlığı, başlangıç/bitiş ve tüm gün gibi Scheduler
* görünümünün kolon eşleşmeleri. Yalnızca Scheduler layout'u seçiliyken gösterilir.
@ -24,7 +106,9 @@ const WizardStepSchedulerLayout = ({
onBack,
onNext,
}: WizardStepSchedulerLayoutProps) => {
const { values } = useFormikContext<ListFormWizardDto>()
const { values, setFieldValue } = useFormikContext<ListFormWizardDto>()
const [errorFields, setErrorFields] = useState<RequiredField[]>([])
const [autoFillMessage, setAutoFillMessage] = useState('')
const fieldOptions = useMemo<SelectBoxOption[]>(
() =>
selectCommandColumns.map((column) => ({
@ -34,14 +118,6 @@ const WizardStepSchedulerLayout = ({
[selectCommandColumns],
)
const option = values.schedulerOptionDto
const fieldMappings = [
['textExpr', '::App.ListFormSchedulerOptions.TextExpr'],
['startDateExpr', '::App.ListFormSchedulerOptions.StartDateExpr'],
['endDateExpr', '::App.ListFormSchedulerOptions.EndDateExpr'],
['allDayExpr', '::App.ListFormSchedulerOptions.AllDayExpr'],
['recurrenceRuleExpr', '::App.ListFormSchedulerOptions.RecurrenceRuleExpr'],
['recurrenceExceptionExpr', '::App.ListFormSchedulerOptions.RecurrenceExceptionExpr'],
] as const
const interactionFields = [
['showAllDayPanel', '::App.ListFormSchedulerOptions.ShowAllDayPanel'],
['crossScrollingEnabled', '::App.ListFormSchedulerOptions.CrossScrollingEnabled'],
@ -52,26 +128,97 @@ const WizardStepSchedulerLayout = ({
['allowDeleting', '::App.ListFormSchedulerOptions.AllowDeleting'],
] as const
const autoFillRequired = () => {
const used = new Set<string>()
let unmatched = false
REQUIRED_FIELDS.forEach((field) => {
const existing = option[field.name]?.trim()
if (existing) {
used.add(existing)
return
}
let bestColumn = ''
let bestScore = -1
selectCommandColumns.forEach((column) => {
if (used.has(column.columnName)) return
const score = scoreColumn(column, field.hints, field.kind)
if (score > bestScore) {
bestScore = score
bestColumn = column.columnName
}
})
if (bestScore < 0) {
unmatched = true
return
}
used.add(bestColumn)
setFieldValue(`schedulerOptionDto.${field.name}`, bestColumn)
})
setErrorFields([])
setAutoFillMessage(unmatched ? translate('::App.ListFormWizard.AutoFillUnmatched') : '')
}
const continueToNext = () => {
const missing = REQUIRED_FIELDS.filter((field) => !option[field.name]?.trim()).map(
(field) => field.name,
)
setErrorFields(missing)
if (missing.length === 0) onNext()
}
return (
<div className="pb-20">
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-4">
<Card header={translate('::App.ListFormSchedulerOptions.BasicSettings')}>
{fieldMappings.slice(0, 4).map(([name, translationKey]) => (
<FormItem key={name} label={translate(translationKey)}>
<Field name={`schedulerOptionDto.${name}`}>
{REQUIRED_FIELDS.map((field) => {
const invalid = errorFields.includes(field.name)
return (
<FormItem
key={field.name}
label={`${translate(field.labelKey)} *`}
invalid={invalid}
errorMessage={invalid ? translate('::App.ListFormWizard.Required') : undefined}
>
<Field name={`schedulerOptionDto.${field.name}`}>
{({ form, field: formikField }: FieldProps) => (
<Select
field={formikField}
form={form}
options={fieldOptions}
isClearable={true}
value={fieldOptions.find(
(fieldOption) => fieldOption.value === option[field.name],
)}
onChange={(selected) => {
form.setFieldValue(formikField.name, selected?.value ?? '')
setErrorFields((current) => current.filter((name) => name !== field.name))
}}
/>
)}
</Field>
</FormItem>
)
})}
<FormItem label={translate('::App.ListFormSchedulerOptions.AllDayExpr')}>
<Field name="schedulerOptionDto.allDayExpr">
{({ field, form }: FieldProps) => (
<Select
field={field}
form={form}
isClearable
isClearable={true}
options={fieldOptions}
value={fieldOptions.find((fieldOption) => fieldOption.value === option[name])}
value={fieldOptions.find(
(fieldOption) => fieldOption.value === option.allDayExpr,
)}
onChange={(selected) => form.setFieldValue(field.name, selected?.value ?? '')}
/>
)}
</Field>
</FormItem>
))}
</Card>
<Card header={translate('::App.ListFormSchedulerOptions.ViewSettings')}>
@ -81,6 +228,7 @@ const WizardStepSchedulerLayout = ({
<Select
field={field}
form={form}
isClearable={true}
options={schedulerViewOptions}
value={schedulerViewOptions.find(
(viewOption) => viewOption.value === option.defaultView,
@ -96,6 +244,7 @@ const WizardStepSchedulerLayout = ({
<Select
field={field}
form={form}
isClearable={true}
options={firstDayOfWeekOptions}
value={firstDayOfWeekOptions.find(
(dayOption) => dayOption.value === option.firstDayOfWeek,
@ -128,14 +277,14 @@ const WizardStepSchedulerLayout = ({
</Card>
<Card header={translate('::App.ListFormSchedulerOptions.RecurrenceSettings')}>
{fieldMappings.slice(4).map(([name, translationKey]) => (
{RECURRENCE_FIELDS.map(([name, translationKey]) => (
<FormItem key={name} label={translate(translationKey)}>
<Field name={`schedulerOptionDto.${name}`}>
{({ field, form }: FieldProps) => (
<Select
field={field}
form={form}
isClearable
isClearable={true}
options={fieldOptions}
value={fieldOptions.find((fieldOption) => fieldOption.value === option[name])}
onChange={(selected) => form.setFieldValue(field.name, selected?.value ?? '')}
@ -155,17 +304,41 @@ const WizardStepSchedulerLayout = ({
</Card>
</div>
{autoFillMessage && <p className="mt-4 text-sm text-amber-600">{autoFillMessage}</p>}
{errorFields.length > 0 && (
<p className="mt-2 text-sm text-red-500">
{translate('::App.ListFormWizard.RequiredFieldsMissing')}
</p>
)}
<div className="fixed bottom-0 left-0 right-0 z-10 flex h-16 items-center border-t border-gray-200 bg-white px-6 dark:border-gray-700 dark:bg-gray-900">
<div className="flex w-full items-center justify-between gap-3">
<Button type="button" size="sm" icon={<FaArrowLeft />} onClick={onBack}>
{translate('::App.Platform.Back')}
</Button>
<Button type="button" size="sm" variant="solid" icon={<FaArrowRight />} onClick={onNext}>
<div className="flex items-center gap-3">
<Button
type="button"
size="sm"
icon={<FaMagic />}
disabled={selectCommandColumns.length === 0}
onClick={autoFillRequired}
>
{translate('::App.ListFormWizard.AutoFillRequired')}
</Button>
<Button
type="button"
size="sm"
variant="solid"
icon={<FaArrowRight />}
onClick={continueToNext}
>
{translate('::App.Platform.Next')}
</Button>
</div>
</div>
</div>
</div>
)
}

View file

@ -5,7 +5,7 @@ import type { DatabaseColumnDto } from '@/proxy/sql-query-manager/models'
import type { SelectBoxOption } from '@/types/shared'
import { Field, type FieldProps, useFormikContext } from 'formik'
import { useMemo, useState } from 'react'
import { FaArrowLeft, FaArrowRight } from 'react-icons/fa'
import { FaArrowLeft, FaArrowRight, FaMagic } from 'react-icons/fa'
interface WizardStepTodoLayoutProps {
selectedColumns: Set<string>
@ -15,6 +15,27 @@ interface WizardStepTodoLayoutProps {
onNext: () => void
}
type RequiredField = 'titleExpr' | 'statusExpr'
type ColumnKind = 'any' | 'date' | 'number' | 'text'
/**
* Todo görünümünün çalışması için şart olan eşleşmeler. Liste `views/list/List.tsx`
* içindeki `isLayoutValid` kontrolüyle aynıdır: biri boşsa ekran grid'e düşer.
* `hints`, otomatik doldurmada öncelik sırasına göre denenen kolon adı parçalarıdır.
*/
const REQUIRED_FIELDS: Array<{ name: RequiredField; kind?: ColumnKind; hints: string[] }> = [
{
name: 'titleExpr',
kind: 'text',
hints: ['title', 'baslik', 'gorevadi', 'taskname', 'konu', 'subject', 'name', 'adi', 'ad'],
},
{
name: 'statusExpr',
hints: ['status', 'durum', 'statu', 'state', 'stage', 'asama', 'kolon', 'column'],
},
]
const mappings: Array<{
name: keyof TodoOptionDto
translationKey: string
@ -59,6 +80,42 @@ const mappings: Array<{
{ name: 'subTaskExpr', translationKey: '::App.ListFormFieldEdit.TodoSubTaskExpr' },
]
const normalize = (value: string) =>
value
.toLowerCase()
.replace(/ı/g, 'i')
.replace(/ş/g, 's')
.replace(/ğ/g, 'g')
.replace(/ü/g, 'u')
.replace(/ö/g, 'o')
.replace(/ç/g, 'c')
.replace(/[^a-z0-9]/g, '')
const kindOf = (dataType: string): ColumnKind => {
const type = (dataType || '').toLowerCase()
if (/date|time|timestamp/.test(type)) return 'date'
if (/int|decimal|numeric|float|real|money|double|number/.test(type)) return 'number'
if (/char|text|string|clob/.test(type)) return 'text'
return 'any'
}
/** Eşleşme yoksa -1; aksi halde kolonun alana ne kadar uyduğunu gösteren skor. */
const scoreColumn = (column: DatabaseColumnDto, hints: string[], kind?: ColumnKind) => {
const name = normalize(column.columnName)
let best = -1
hints.forEach((hint, index) => {
const needle = normalize(hint)
let score = -1
if (name === needle) score = 100
else if (name.endsWith(needle)) score = 70
else if (name.startsWith(needle)) score = 60
else if (name.includes(needle)) score = 40
if (score >= 0 && score - index > best) best = score - index
})
if (best < 0 || !kind || kind === 'any') return best
return kindOf(column.dataType) === kind ? best + 20 : best
}
/**
* Todo / Kanban layout adımı: kart başlığı, durum kolonu, sıra alanı gibi Kanban görünümünün
* ihtiyaç duyduğu kolon eşleşmeleri. Yalnızca Todo layout'u seçiliyken gösterilir.
@ -72,19 +129,54 @@ const WizardStepTodoLayout = ({
}: WizardStepTodoLayoutProps) => {
const { values, setFieldValue } = useFormikContext<ListFormWizardDto>()
const [validationError, setValidationError] = useState('')
const fieldOptions = useMemo<SelectBoxOption[]>(
() =>
selectCommandColumns
.filter((column) => selectedColumns.has(column.columnName))
.map((column) => ({ value: column.columnName, label: column.columnName })),
const [autoFillMessage, setAutoFillMessage] = useState('')
// Todo kartları yalnızca ekrana alınan kolonları gösterebilir; eşleşmeler de onlardan seçilir.
const availableColumns = useMemo(
() => selectCommandColumns.filter((column) => selectedColumns.has(column.columnName)),
[selectCommandColumns, selectedColumns],
)
const fieldOptions = useMemo<SelectBoxOption[]>(
() =>
availableColumns.map((column) => ({ value: column.columnName, label: column.columnName })),
[availableColumns],
)
const autoFillRequired = () => {
const used = new Set<string>()
let unmatched = false
REQUIRED_FIELDS.forEach((field) => {
const existing = values.todoOptionDto[field.name]?.trim()
if (existing) {
used.add(existing)
return
}
let bestColumn = ''
let bestScore = -1
availableColumns.forEach((column) => {
if (used.has(column.columnName)) return
const score = scoreColumn(column, field.hints, field.kind)
if (score > bestScore) {
bestScore = score
bestColumn = column.columnName
}
})
if (bestScore < 0) {
unmatched = true
return
}
used.add(bestColumn)
setFieldValue(`todoOptionDto.${field.name}`, bestColumn)
})
setValidationError('')
setAutoFillMessage(unmatched ? translate('::App.ListFormWizard.AutoFillUnmatched') : '')
}
const continueToDeploy = () => {
if (
values.todo &&
(!values.todoOptionDto.titleExpr?.trim() || !values.todoOptionDto.statusExpr?.trim())
) {
const missing = REQUIRED_FIELDS.some((field) => !values.todoOptionDto[field.name]?.trim())
if (values.todo && missing) {
setValidationError(translate('::App.ListFormFieldEdit.TodoRequiredFields'))
return
}
@ -147,6 +239,7 @@ const WizardStepTodoLayout = ({
</FormItem>
</div>
{autoFillMessage && <p className="mt-4 text-sm text-amber-600">{autoFillMessage}</p>}
{validationError && <p className="mt-4 text-sm text-red-500">{validationError}</p>}
<div className="fixed bottom-0 left-0 right-0 z-10 flex h-16 items-center border-t border-gray-200 bg-white px-6 dark:border-gray-700 dark:bg-gray-900">
@ -154,6 +247,16 @@ const WizardStepTodoLayout = ({
<Button size="sm" variant="default" type="button" icon={<FaArrowLeft />} onClick={onBack}>
{translate('::App.Platform.Back')}
</Button>
<div className="flex items-center gap-3">
<Button
size="sm"
type="button"
icon={<FaMagic />}
disabled={availableColumns.length === 0}
onClick={autoFillRequired}
>
{translate('::App.ListFormWizard.AutoFillRequired')}
</Button>
<Button
size="sm"
variant="solid"
@ -166,6 +269,7 @@ const WizardStepTodoLayout = ({
</div>
</div>
</div>
</div>
)
}

View file

@ -3,8 +3,8 @@ import type { ListFormWizardDto } from '@/proxy/admin/wizard/models'
import type { DatabaseColumnDto } from '@/proxy/sql-query-manager/models'
import type { SelectBoxOption } from '@/types/shared'
import { Field, type FieldProps, useFormikContext } from 'formik'
import { useMemo } from 'react'
import { FaArrowLeft, FaArrowRight } from 'react-icons/fa'
import { useMemo, useState } from 'react'
import { FaArrowLeft, FaArrowRight, FaMagic } from 'react-icons/fa'
interface WizardStepTreeLayoutProps {
selectCommandColumns: DatabaseColumnDto[]
@ -13,6 +13,53 @@ interface WizardStepTreeLayoutProps {
onNext: () => void
}
type RequiredField = 'keyExpr' | 'parentIdExpr'
/**
* Ağaç görünümünün çalışması için şart olan eşleşmeler. Liste `views/list/List.tsx`
* içindeki `isLayoutValid` kontrolüyle aynıdır: biri boşsa ekran grid'e düşer.
* `hints`, otomatik doldurmada öncelik sırasına göre denenen kolon adı parçalarıdır.
*/
const REQUIRED_FIELDS: Array<{ name: RequiredField; labelKey: string; hints: string[] }> = [
{
name: 'keyExpr',
labelKey: '::App.Listform.ListformField.KeyFieldName',
hints: ['id', 'key', 'kod', 'code', 'recordid'],
},
{
name: 'parentIdExpr',
labelKey: '::App.ListFormEdit.ParentIdExpr',
hints: ['parentid', 'ustid', 'parentkey', 'parentcode', 'ustkayitid', 'parent', 'ust'],
},
]
const normalize = (value: string) =>
value
.toLowerCase()
.replace(/ı/g, 'i')
.replace(/ş/g, 's')
.replace(/ğ/g, 'g')
.replace(/ü/g, 'u')
.replace(/ö/g, 'o')
.replace(/ç/g, 'c')
.replace(/[^a-z0-9]/g, '')
/** Eşleşme yoksa -1; aksi halde kolonun ipuçlarına ne kadar uyduğunu gösteren skor. */
const scoreColumn = (columnName: string, hints: string[]) => {
const name = normalize(columnName)
let best = -1
hints.forEach((hint, index) => {
const needle = normalize(hint)
let score = -1
if (name === needle) score = 100
else if (name.endsWith(needle)) score = 70
else if (name.startsWith(needle)) score = 60
else if (name.includes(needle)) score = 40
if (score >= 0 && score - index > best) best = score - index
})
return best
}
/**
* Ağaç layout adımı: kayıtları hiyerarşik gösterebilmek için anahtar ve üst anahtar
* kolonlarının eşleştirilmesi. Yalnızca Tree layout'u seçiliyken gösterilir.
@ -23,7 +70,9 @@ const WizardStepTreeLayout = ({
onBack,
onNext,
}: WizardStepTreeLayoutProps) => {
const { values } = useFormikContext<ListFormWizardDto>()
const { values, setFieldValue } = useFormikContext<ListFormWizardDto>()
const [errorFields, setErrorFields] = useState<RequiredField[]>([])
const [autoFillMessage, setAutoFillMessage] = useState('')
const fieldOptions = useMemo<SelectBoxOption[]>(
() =>
selectCommandColumns.map((column) => ({
@ -33,38 +82,92 @@ const WizardStepTreeLayout = ({
[selectCommandColumns],
)
const selectFields = [
{
name: 'treeOptionDto.keyExpr',
value: values.treeOptionDto.keyExpr,
label: translate('::App.Listform.ListformField.KeyFieldName'),
},
{
name: 'treeOptionDto.parentIdExpr',
value: values.treeOptionDto.parentIdExpr,
label: translate('::App.ListFormEdit.ParentIdExpr'),
},
]
const autoFillRequired = () => {
const option = values.treeOptionDto
const used = new Set<string>()
let unmatched = false
REQUIRED_FIELDS.forEach((field) => {
const existing = option[field.name]?.trim()
if (existing) {
used.add(existing)
return
}
// Anahtar alan Settings adımında zaten seçildi; tahmin etmeye gerek yok.
const preferred = field.name === 'keyExpr' ? values.keyFieldName?.trim() : ''
if (
preferred &&
!used.has(preferred) &&
selectCommandColumns.some((column) => column.columnName === preferred)
) {
used.add(preferred)
setFieldValue(`treeOptionDto.${field.name}`, preferred)
return
}
let bestColumn = ''
let bestScore = -1
selectCommandColumns.forEach((column) => {
if (used.has(column.columnName)) return
const score = scoreColumn(column.columnName, field.hints)
if (score > bestScore) {
bestScore = score
bestColumn = column.columnName
}
})
if (bestScore < 0) {
unmatched = true
return
}
used.add(bestColumn)
setFieldValue(`treeOptionDto.${field.name}`, bestColumn)
})
setErrorFields([])
setAutoFillMessage(unmatched ? translate('::App.ListFormWizard.AutoFillUnmatched') : '')
}
const continueToNext = () => {
const missing = REQUIRED_FIELDS.filter(
(field) => !values.treeOptionDto[field.name]?.trim(),
).map((field) => field.name)
setErrorFields(missing)
if (missing.length === 0) onNext()
}
return (
<div className="pb-20">
<div className="grid grid-cols-1 gap-x-6 md:grid-cols-2">
{selectFields.map((item) => (
<FormItem key={item.name} label={item.label}>
<Field name={item.name}>
{REQUIRED_FIELDS.map((item) => {
const invalid = errorFields.includes(item.name)
return (
<FormItem
key={item.name}
label={`${translate(item.labelKey)} *`}
invalid={invalid}
errorMessage={invalid ? translate('::App.ListFormWizard.Required') : undefined}
>
<Field name={`treeOptionDto.${item.name}`}>
{({ field, form }: FieldProps) => (
<Select
field={field}
form={form}
isClearable
isClearable={true}
options={fieldOptions}
value={fieldOptions.find((option) => option.value === item.value)}
onChange={(option) => form.setFieldValue(field.name, option?.value ?? '')}
value={fieldOptions.find(
(option) => option.value === values.treeOptionDto[item.name],
)}
onChange={(option) => {
form.setFieldValue(field.name, option?.value ?? '')
setErrorFields((current) => current.filter((name) => name !== item.name))
}}
/>
)}
</Field>
</FormItem>
))}
)
})}
<FormItem label={translate('::App.ListFormEdit.HasItemsExpr')}>
<Field name="treeOptionDto.hasItemsExpr" component={Input} />
@ -80,17 +183,41 @@ const WizardStepTreeLayout = ({
</FormItem>
</div>
{autoFillMessage && <p className="mt-4 text-sm text-amber-600">{autoFillMessage}</p>}
{errorFields.length > 0 && (
<p className="mt-2 text-sm text-red-500">
{translate('::App.ListFormWizard.RequiredFieldsMissing')}
</p>
)}
<div className="fixed bottom-0 left-0 right-0 z-10 flex h-16 items-center border-t border-gray-200 bg-white px-6 dark:border-gray-700 dark:bg-gray-900">
<div className="flex w-full items-center justify-between gap-3">
<Button type="button" size="sm" icon={<FaArrowLeft />} onClick={onBack}>
{translate('::App.Platform.Back')}
</Button>
<Button type="button" size="sm" variant="solid" icon={<FaArrowRight />} onClick={onNext}>
<div className="flex items-center gap-3">
<Button
type="button"
size="sm"
icon={<FaMagic />}
disabled={selectCommandColumns.length === 0}
onClick={autoFillRequired}
>
{translate('::App.ListFormWizard.AutoFillRequired')}
</Button>
<Button
type="button"
size="sm"
variant="solid"
icon={<FaArrowRight />}
onClick={continueToNext}
>
{translate('::App.Platform.Next')}
</Button>
</div>
</div>
</div>
</div>
)
}

View file

@ -16,7 +16,7 @@ import {
import { Field, FieldProps, Form, Formik } from 'formik'
import { useEffect, useMemo, useRef, useState } from 'react'
import type { FormEvent } from 'react'
import { FaArrowLeft, FaArrowRight } from 'react-icons/fa'
import { FaArrowLeft, FaArrowRight, FaMagic } from 'react-icons/fa'
import { WorkflowDesigner } from '../workflow/WorkflowDesigner'
import { IdentityUserDto } from '@/proxy/admin/models'
@ -39,6 +39,57 @@ type PendingLink = {
type RequiredWorkflowField = 'approvalUserFieldName' | 'approvalStatusFieldName'
/**
* Onay akışı kurulduğunda şart olan alanlar ve otomatik doldurmada öncelik sırasına göre
* denenen kolon adı parçaları. Bu ikisi boşsa akış çalışma zamanında kaydı kimseye düşüremez.
*/
const REQUIRED_FIELDS: Array<{ name: RequiredWorkflowField; hints: string[] }> = [
{
name: 'approvalUserFieldName',
hints: [
'approvaluser',
'onaykullanicisi',
'onaylayan',
'approvedby',
'approver',
'onaykullanici',
'kullanici',
'username',
],
},
{
name: 'approvalStatusFieldName',
hints: ['approvalstatus', 'onaydurumu', 'onaydurum', 'approvalstate', 'status', 'durum'],
},
]
const normalize = (value: string) =>
value
.toLowerCase()
.replace(/ı/g, 'i')
.replace(/ş/g, 's')
.replace(/ğ/g, 'g')
.replace(/ü/g, 'u')
.replace(/ö/g, 'o')
.replace(/ç/g, 'c')
.replace(/[^a-z0-9]/g, '')
/** Eşleşme yoksa -1; aksi halde kolonun alana ne kadar uyduğunu gösteren skor. */
const scoreColumn = (columnName: string, hints: string[]) => {
const name = normalize(columnName)
let best = -1
hints.forEach((hint, index) => {
const needle = normalize(hint)
let score = -1
if (name === needle) score = 100
else if (name.endsWith(needle)) score = 70
else if (name.startsWith(needle)) score = 60
else if (name.includes(needle)) score = 40
if (score >= 0 && score - index > best) best = score - index
})
return best
}
const toDesignerCriteria = (items: ListFormWorkflowCriteriaDto[]): WorkflowCriteriaDto[] =>
items.map((item) => ({
...item,
@ -76,6 +127,7 @@ function WizardStepWorkflow({
const [workflowErrors, setWorkflowErrors] = useState<
Partial<Record<RequiredWorkflowField, string>>
>({})
const [autoFillMessage, setAutoFillMessage] = useState('')
const canvasRef = useRef<HTMLDivElement | null>(null)
const currentCriteria = useMemo(() => toDesignerCriteria(criteria), [criteria])
@ -272,6 +324,41 @@ function WizardStepWorkflow({
])
}
const autoFillRequired = () => {
const used = new Set<string>()
const filled: Partial<Record<RequiredWorkflowField, string>> = {}
let unmatched = false
REQUIRED_FIELDS.forEach((field) => {
const existing = workflow[field.name]?.trim()
if (existing) {
used.add(existing)
return
}
let bestColumn = ''
let bestScore = -1
selectCommandColumns.forEach((column) => {
if (used.has(column.columnName)) return
const score = scoreColumn(column.columnName, field.hints)
if (score > bestScore) {
bestScore = score
bestColumn = column.columnName
}
})
if (bestScore < 0) {
unmatched = true
return
}
used.add(bestColumn)
filled[field.name] = bestColumn
})
onWorkflowChange({ ...workflow, ...filled, criteria })
setWorkflowErrors({})
setAutoFillMessage(unmatched ? translate('::App.ListFormWizard.AutoFillUnmatched') : '')
}
const continueToTodo = () => {
if (criteria.length === 0) {
setWorkflowErrors({})
@ -302,7 +389,7 @@ function WizardStepWorkflow({
>
{({ values }) => (
<Form>
<Card className="mb-4" header={translate('::App.ListFormEdit.TabWorkflow')}>
<Card className="mb-4">
<FormContainer>
<div className="grid grid-cols-1 md:grid-cols-6 gap-4">
{[
@ -334,7 +421,7 @@ function WizardStepWorkflow({
<Select
field={field}
form={form}
isClearable
isClearable={true}
options={columnOptions}
value={columnOptions.find(
(option) => option.value === (values as any)[name],
@ -468,7 +555,17 @@ function WizardStepWorkflow({
<Button size="sm" variant="default" type="button" icon={<FaArrowLeft />} onClick={onBack}>
{translate('::App.Platform.Back')}
</Button>
{autoFillMessage && <span className="text-sm text-amber-600">{autoFillMessage}</span>}
<div className="flex-1 flex items-center justify-end gap-3">
<Button
size="sm"
type="button"
icon={<FaMagic />}
disabled={selectCommandColumns.length === 0}
onClick={autoFillRequired}
>
{translate('::App.ListFormWizard.AutoFillRequired')}
</Button>
<Button
size="sm"
variant="solid"