diff --git a/api/src/Sozsoft.Platform.DbMigrator/Migrations/LanguagesData.json b/api/src/Sozsoft.Platform.DbMigrator/Migrations/LanguagesData.json index 296db7bc..c174d6b1 100644 --- a/api/src/Sozsoft.Platform.DbMigrator/Migrations/LanguagesData.json +++ b/api/src/Sozsoft.Platform.DbMigrator/Migrations/LanguagesData.json @@ -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", diff --git a/ui/src/views/admin/files/FileManager.tsx b/ui/src/views/admin/files/FileManager.tsx index aaf1ddb9..0147d662 100644 --- a/ui/src/views/admin/files/FileManager.tsx +++ b/ui/src/views/admin/files/FileManager.tsx @@ -937,7 +937,7 @@ const FileManager = () => { 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() + const { values, setFieldValue } = useFormikContext() + const [errorFields, setErrorFields] = useState([]) + const [autoFillMessage, setAutoFillMessage] = useState('') const fieldOptions = useMemo( () => 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() + 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 (
- {selectFields.map(([name, label]) => ( - - - {({ field, form }: FieldProps) => ( - fieldOption.value === option[field.name], + )} + onChange={(selected) => { + form.setFieldValue(formikField.name, selected?.value ?? '') + setErrorFields((current) => current.filter((name) => name !== field.name)) + }} + /> + )} + + + ) + })} @@ -121,14 +274,38 @@ const WizardStepGanttLayout = ({
+ {autoFillMessage &&

{autoFillMessage}

} + {errorFields.length > 0 && ( +

+ {translate('::App.ListFormWizard.RequiredFieldsMissing')} +

+ )} +
- +
+ + +
diff --git a/ui/src/views/admin/listForm/wizard/WizardStepMenu.tsx b/ui/src/views/admin/listForm/wizard/WizardStepMenu.tsx index 99e36627..f3de9f7b 100644 --- a/ui/src/views/admin/listForm/wizard/WizardStepMenu.tsx +++ b/ui/src/views/admin/listForm/wizard/WizardStepMenu.tsx @@ -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, diff --git a/ui/src/views/admin/listForm/wizard/WizardStepSchedulerLayout.tsx b/ui/src/views/admin/listForm/wizard/WizardStepSchedulerLayout.tsx index a4207290..2a35d94a 100644 --- a/ui/src/views/admin/listForm/wizard/WizardStepSchedulerLayout.tsx +++ b/ui/src/views/admin/listForm/wizard/WizardStepSchedulerLayout.tsx @@ -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() + const { values, setFieldValue } = useFormikContext() + const [errorFields, setErrorFields] = useState([]) + const [autoFillMessage, setAutoFillMessage] = useState('') const fieldOptions = useMemo( () => 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() + 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 (
- {fieldMappings.slice(0, 4).map(([name, translationKey]) => ( - - - {({ field, form }: FieldProps) => ( - fieldOption.value === option[field.name], + )} + onChange={(selected) => { + form.setFieldValue(formikField.name, selected?.value ?? '') + setErrorFields((current) => current.filter((name) => name !== field.name)) + }} + /> + )} + + + ) + })} + + + {({ field, form }: FieldProps) => ( + viewOption.value === option.defaultView, @@ -96,6 +244,7 @@ const WizardStepSchedulerLayout = ({ fieldOption.value === option[name])} onChange={(selected) => form.setFieldValue(field.name, selected?.value ?? '')} @@ -155,14 +304,38 @@ const WizardStepSchedulerLayout = ({
+ {autoFillMessage &&

{autoFillMessage}

} + {errorFields.length > 0 && ( +

+ {translate('::App.ListFormWizard.RequiredFieldsMissing')} +

+ )} +
- +
+ + +
diff --git a/ui/src/views/admin/listForm/wizard/WizardStepTodoLayout.tsx b/ui/src/views/admin/listForm/wizard/WizardStepTodoLayout.tsx index f9b9d503..36e7a93c 100644 --- a/ui/src/views/admin/listForm/wizard/WizardStepTodoLayout.tsx +++ b/ui/src/views/admin/listForm/wizard/WizardStepTodoLayout.tsx @@ -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 @@ -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() const [validationError, setValidationError] = useState('') - const fieldOptions = useMemo( - () => - 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( + () => + availableColumns.map((column) => ({ value: column.columnName, label: column.columnName })), + [availableColumns], + ) + + const autoFillRequired = () => { + const used = new Set() + 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 = ({
+ {autoFillMessage &&

{autoFillMessage}

} {validationError &&

{validationError}

}
@@ -154,15 +247,26 @@ const WizardStepTodoLayout = ({ - +
+ + +
diff --git a/ui/src/views/admin/listForm/wizard/WizardStepTreeLayout.tsx b/ui/src/views/admin/listForm/wizard/WizardStepTreeLayout.tsx index c8045bdb..6e20cd01 100644 --- a/ui/src/views/admin/listForm/wizard/WizardStepTreeLayout.tsx +++ b/ui/src/views/admin/listForm/wizard/WizardStepTreeLayout.tsx @@ -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() + const { values, setFieldValue } = useFormikContext() + const [errorFields, setErrorFields] = useState([]) + const [autoFillMessage, setAutoFillMessage] = useState('') const fieldOptions = useMemo( () => 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() + 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 (
- {selectFields.map((item) => ( - - - {({ field, form }: FieldProps) => ( - option.value === values.treeOptionDto[item.name], + )} + onChange={(option) => { + form.setFieldValue(field.name, option?.value ?? '') + setErrorFields((current) => current.filter((name) => name !== item.name)) + }} + /> + )} + + + ) + })} @@ -80,14 +183,38 @@ const WizardStepTreeLayout = ({
+ {autoFillMessage &&

{autoFillMessage}

} + {errorFields.length > 0 && ( +

+ {translate('::App.ListFormWizard.RequiredFieldsMissing')} +

+ )} +
- +
+ + +
diff --git a/ui/src/views/admin/listForm/wizard/WizardStepWorkflow.tsx b/ui/src/views/admin/listForm/wizard/WizardStepWorkflow.tsx index d71a7e95..ad8dd02e 100644 --- a/ui/src/views/admin/listForm/wizard/WizardStepWorkflow.tsx +++ b/ui/src/views/admin/listForm/wizard/WizardStepWorkflow.tsx @@ -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> >({}) + const [autoFillMessage, setAutoFillMessage] = useState('') const canvasRef = useRef(null) const currentCriteria = useMemo(() => toDesignerCriteria(criteria), [criteria]) @@ -272,6 +324,41 @@ function WizardStepWorkflow({ ]) } + const autoFillRequired = () => { + const used = new Set() + const filled: Partial> = {} + 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 }) => (
- +
{[ @@ -334,7 +421,7 @@ function WizardStepWorkflow({