LisformWorkflow çalışması

This commit is contained in:
Sedat Öztürk 2026-05-23 16:41:52 +03:00
parent 7b0f4acced
commit 73cb479e50
22 changed files with 1058 additions and 753 deletions

View file

@ -1,9 +1,8 @@
using System;
namespace Sozsoft.Platform.ListForms;
namespace Sozsoft.Platform.ListForms;
public class WorkflowDto
{
public string ApprovalFieldName { get; set; }
public DateTime ApprovalDateFieldName { get; set; }
public string ApprovalDateFieldName { get; set; }
public string ApprovalStatusFieldName { get; set; }
}

View file

@ -5,7 +5,7 @@ namespace Sozsoft.Platform.ListForms.Workflow;
public class CreateUpdateListFormWorkflowCriteriaDto
{
public Guid? Id { get; set; }
public string Id { get; set; }
public string ListFormCode { get; set; }
public string Kind { get; set; }
public string Title { get; set; }

View file

@ -8,7 +8,7 @@ public interface IListFormWorkflowAppService : IApplicationService
{
Task<ListFormWorkflowStateDto> GetStateAsync(string listFormCode = null);
Task<ListFormWorkflowCriteriaDto> SaveCriteriaAsync(CreateUpdateListFormWorkflowCriteriaDto input);
Task DeleteCriteriaAsync(Guid id);
Task DeleteCriteriaAsync(string id);
Task<ListFormWorkflowStateDto> ResetDemoAsync(string listFormCode = null);
}

View file

@ -4,7 +4,7 @@ using Volo.Abp.Application.Dtos;
namespace Sozsoft.Platform.ListForms.Workflow;
public class ListFormWorkflowCriteriaDto : AuditedEntityDto<Guid>
public class ListFormWorkflowCriteriaDto : AuditedEntityDto<string>
{
public string ListFormCode { get; set; }
public string Kind { get; set; }

View file

@ -16,10 +16,12 @@ namespace Sozsoft.Platform.ListForms.Workflow;
public class ListFormWorkflowAppService : PlatformAppService, IListFormWorkflowAppService
{
private const string DefaultListFormCode = "workflow";
private const string CriteriaIdPrefix = "N";
private const int CriteriaIdPadding = 3;
private readonly IRepository<ListFormWorkflow, Guid> criteriaRepository;
private readonly IRepository<ListFormWorkflow, string> criteriaRepository;
public ListFormWorkflowAppService(IRepository<ListFormWorkflow, Guid> criteriaRepository)
public ListFormWorkflowAppService(IRepository<ListFormWorkflow, string> criteriaRepository)
{
this.criteriaRepository = criteriaRepository;
}
@ -43,10 +45,10 @@ public class ListFormWorkflowAppService : PlatformAppService, IListFormWorkflowA
public async Task<ListFormWorkflowCriteriaDto> SaveCriteriaAsync(CreateUpdateListFormWorkflowCriteriaDto input)
{
var code = NormalizeListFormCode(input.ListFormCode);
var isNew = !input.Id.HasValue || input.Id.Value == Guid.Empty;
var isNew = input.Id.IsNullOrWhiteSpace();
var criteria = isNew
? new ListFormWorkflow(GuidGenerator.Create())
: await criteriaRepository.GetAsync(input.Id.Value);
? new ListFormWorkflow(await GenerateNextCriteriaIdAsync())
: await criteriaRepository.GetAsync(input.Id);
if (!isNew && criteria.ListFormCode != code)
{
@ -89,7 +91,7 @@ public class ListFormWorkflowAppService : PlatformAppService, IListFormWorkflowA
}
[HttpDelete("criteria/{id}")]
public async Task DeleteCriteriaAsync(Guid id)
public async Task DeleteCriteriaAsync(string id)
{
var criteria = await criteriaRepository.GetAsync(id);
await criteriaRepository.DeleteAsync(criteria, autoSave: true);
@ -97,7 +99,7 @@ public class ListFormWorkflowAppService : PlatformAppService, IListFormWorkflowA
var remaining = await criteriaRepository.GetListAsync(x => x.ListFormCode == criteria.ListFormCode);
foreach (var item in remaining)
{
var changed = ClearDeletedTarget(item, id.ToString());
var changed = ClearDeletedTarget(item, id);
if (changed)
{
await criteriaRepository.UpdateAsync(item, autoSave: true);
@ -117,30 +119,30 @@ public class ListFormWorkflowAppService : PlatformAppService, IListFormWorkflowA
var start = await CreateCriteriaAsync(code, "Start", "İş Akışı Başlat", 80, 150);
var compare = await CreateCriteriaAsync(code, "Compare", "Tutar kontrolü", 330, 130);
var approval = await CreateCriteriaAsync(code, "Approval", "Yönetici Onayı", 590, 60, "ayse.yilmaz");
var inform = await CreateCriteriaAsync(code, "Inform", "Muhasebe Bilgilendirme", 590, 230, "muhasebe");
var approval = await CreateCriteriaAsync(code, "Approval", "Yönetici Onayı", 590, 60, PlatformConsts.AbpIdentity.User.AdminEmailDefaultValue);
var inform = await CreateCriteriaAsync(code, "Inform", "Muhasebe Bilgilendirme", 590, 230, PlatformConsts.AbpIdentity.User.AdminEmailDefaultValue);
var end = await CreateCriteriaAsync(code, "End", "Akışı Bitir", 850, 150);
start.NextOnStart = compare.Id.ToString();
compare.NextOnTrue = approval.Id.ToString();
compare.NextOnFalse = inform.Id.ToString();
start.NextOnStart = compare.Id;
compare.NextOnTrue = approval.Id;
compare.NextOnFalse = inform.Id;
compare.CompareOutcomesJson = SerializeCompareOutcomes([
new CompareOutcomeDto
{
Label = "Onay gerekir",
TargetId = approval.Id.ToString(),
TargetId = approval.Id,
Conditions = [new WorkflowConditionDto { CompareColumn = "Tutar", CompareOperator = ">", CompareValue = 5000 }]
},
new CompareOutcomeDto
{
Label = "Bilgilendir",
TargetId = inform.Id.ToString(),
TargetId = inform.Id,
Conditions = [new WorkflowConditionDto { CompareColumn = "Tutar", CompareOperator = "<=", CompareValue = 5000 }]
}
]);
approval.NextOnApprove = inform.Id.ToString();
approval.NextOnReject = end.Id.ToString();
inform.NextOnStart = end.Id.ToString();
approval.NextOnApprove = inform.Id;
approval.NextOnReject = end.Id;
inform.NextOnStart = end.Id;
await criteriaRepository.UpdateAsync(start, autoSave: true);
await criteriaRepository.UpdateAsync(compare, autoSave: true);
@ -158,7 +160,7 @@ public class ListFormWorkflowAppService : PlatformAppService, IListFormWorkflowA
int positionY,
string approver = "")
{
var criteria = new ListFormWorkflow(GuidGenerator.Create())
var criteria = new ListFormWorkflow(await GenerateNextCriteriaIdAsync())
{
ListFormCode = listFormCode,
Kind = kind,
@ -181,6 +183,43 @@ public class ListFormWorkflowAppService : PlatformAppService, IListFormWorkflowA
return criteria;
}
private async Task<string> GenerateNextCriteriaIdAsync()
{
var criteria = await criteriaRepository.GetListAsync();
var maxNumber = criteria
.Select(x => TryParseCriteriaIdNumber(x.Id))
.DefaultIfEmpty(0)
.Max();
var nextNumber = maxNumber + 1;
var nextId = FormatCriteriaId(nextNumber);
var existingIds = criteria.Select(x => x.Id).ToHashSet();
while (existingIds.Contains(nextId))
{
nextNumber++;
nextId = FormatCriteriaId(nextNumber);
}
return nextId;
}
private static int TryParseCriteriaIdNumber(string id)
{
if (id.IsNullOrWhiteSpace() ||
!id.StartsWith(CriteriaIdPrefix, StringComparison.OrdinalIgnoreCase))
{
return 0;
}
return int.TryParse(id[CriteriaIdPrefix.Length..], out var number) ? number : 0;
}
private static string FormatCriteriaId(int number)
{
return $"{CriteriaIdPrefix}{number.ToString().PadLeft(CriteriaIdPadding, '0')}";
}
private static bool ClearDeletedTarget(ListFormWorkflow criteria, string deletedId)
{
var changed = false;

View file

@ -18673,6 +18673,78 @@
"key": "SuccessfullySaved",
"en": "Successfully Saved",
"tr": "Başarıyla Kaydedildi"
},
{
"resourceName": "Platform",
"key": "ListForms.ListFormEdit.Workflow.Criteria",
"en": "Criteria",
"tr": "Kriterler"
},
{
"resourceName": "Platform",
"key": "ListForms.ListFormEdit.Workflow.CriteriaStart",
"en": "Start",
"tr": "Başlat"
},
{
"resourceName": "Platform",
"key": "ListForms.ListFormEdit.Workflow.CriteriaCompare",
"en": "Compare",
"tr": "Karşılaştır"
},
{
"resourceName": "Platform",
"key": "ListForms.ListFormEdit.Workflow.CriteriaApproval",
"en": "Approval",
"tr": "Onaylanacak Kişi"
},
{
"resourceName": "Platform",
"key": "ListForms.ListFormEdit.Workflow.CriteriaInform",
"en": "Information",
"tr": "Bilgilendirilecek Kişi"
},
{
"resourceName": "Platform",
"key": "ListForms.ListFormEdit.Workflow.CriteriaEnd",
"en": "Finish",
"tr": "Akışı Bitir"
},
{
"resourceName": "Platform",
"key": "ListForms.ListFormEdit.Workflow.CriteriaTitleRule",
"en": "Title / Rule",
"tr": "Başlık / Kural"
},
{
"resourceName": "Platform",
"key": "ListForms.ListFormEdit.Workflow.CriteriaConnections",
"en": "Connections",
"tr": "Bağlantılar"
},
{
"resourceName": "Platform",
"key": "ListForms.ListFormEdit.WorkflowNoCriteria",
"en": "No criteria defined for the selected workflow.",
"tr": "Seçili iş akışı için açıklama kaydı yok."
},
{
"resourceName": "Platform",
"key": "ListForms.ListFormEdit.Workflow.ApprovalFieldName",
"en": "Approval Field Name",
"tr": "Onay Alanı Adı"
},
{
"resourceName": "Platform",
"key": "ListForms.ListFormEdit.Workflow.ApprovalDateFieldName",
"en": "Approval Date Field Name",
"tr": "Onay Tarihi Alanı Adı"
},
{
"resourceName": "Platform",
"key": "ListForms.ListFormEdit.Workflow.ApprovalStatusFieldName",
"en": "Approval Status Field Name",
"tr": "Onay Durumu Alanı Adı"
}
]
}

View file

@ -3,13 +3,13 @@ using Volo.Abp.Domain.Entities;
namespace Sozsoft.Platform.Entities;
public class ListFormWorkflow : Entity<Guid>
public class ListFormWorkflow : Entity<string>
{
protected ListFormWorkflow()
{
}
public ListFormWorkflow(Guid id) : base(id)
public ListFormWorkflow(string id) : base(id)
{
}

View file

@ -487,6 +487,7 @@ public class PlatformDbContext :
b.ToTable(TableNameResolver.GetFullTableName(nameof(TableNameEnum.ListFormWorkflow)), Prefix.DbSchema);
b.ConfigureByConvention();
b.Property(x => x.Id).HasMaxLength(50);
b.Property(x => x.ListFormCode).IsRequired().HasMaxLength(64);
b.Property(x => x.Kind).IsRequired().HasMaxLength(50);
b.Property(x => x.Title).IsRequired().HasMaxLength(250);

View file

@ -13,7 +13,7 @@ using Volo.Abp.EntityFrameworkCore;
namespace Sozsoft.Platform.Migrations
{
[DbContext(typeof(PlatformDbContext))]
[Migration("20260522200739_Initial")]
[Migration("20260523104659_Initial")]
partial class Initial
{
/// <inheritdoc />
@ -3414,8 +3414,9 @@ namespace Sozsoft.Platform.Migrations
modelBuilder.Entity("Sozsoft.Platform.Entities.ListFormWorkflow", b =>
{
b.Property<Guid>("Id")
.HasColumnType("uniqueidentifier");
b.Property<string>("Id")
.HasMaxLength(50)
.HasColumnType("nvarchar(50)");
b.Property<string>("Approver")
.IsRequired()

View file

@ -1393,7 +1393,7 @@ namespace Sozsoft.Platform.Migrations
name: "Sas_H_ListFormWorkflow",
columns: table => new
{
Id = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
Id = table.Column<string>(type: "nvarchar(50)", maxLength: 50, nullable: false),
ListFormCode = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: false),
Kind = table.Column<string>(type: "nvarchar(50)", maxLength: 50, nullable: false),
Title = table.Column<string>(type: "nvarchar(250)", maxLength: 250, nullable: false),

View file

@ -3411,8 +3411,9 @@ namespace Sozsoft.Platform.Migrations
modelBuilder.Entity("Sozsoft.Platform.Entities.ListFormWorkflow", b =>
{
b.Property<Guid>("Id")
.HasColumnType("uniqueidentifier");
b.Property<string>("Id")
.HasMaxLength(50)
.HasColumnType("nvarchar(50)");
b.Property<string>("Approver")
.IsRequired()

View file

@ -48,6 +48,7 @@ export const ListFormEditTabs = {
StateForm: 'state',
SubForm: 'subForm',
Widget: 'widget',
Workflow: 'workflow',
Fields: 'fields',
Customization: 'customization',
ExtraFilter: 'extraFilter',

View file

@ -663,6 +663,7 @@ export interface GridOptionsEditDto extends GridOptionsDto, Record<string, any>
formFieldsDefaultValueDto: FieldsDefaultValueDto[]
widgetsJson?: string
widgetsDto: WidgetEditDto[]
workflowDto: WorkflowDto
extraFilterEditDto: ExtraFilterEditDto[]
}
@ -905,6 +906,12 @@ export interface WidgetEditDto {
isActive: boolean
}
export interface WorkflowDto {
approvalFieldName: string
approvalDateFieldName: string
approvalStatusFieldName: string
}
export interface LayoutDto {
grid: boolean
pivot: boolean

View file

@ -15,11 +15,6 @@ export const operatorOptions = [">", ">=", "<", "<=", "=", "!="].map(
}),
);
export const columnOptions = ["Tutar", "Id"].map((value) => ({
value,
label: value,
}));
export const kindIcon: Record<string, any> = {
Start: FiPlay as any,
Compare: FiGitBranch as any,

View file

@ -321,7 +321,7 @@ export function emptyCriteria(kind = 'Compare', listFormCode = ''): WorkflowCrit
nextOnApprove: '',
nextOnReject: '',
compareOutcomes:
kind === 'Compare' ? [emptyCompareOutcome('Durum 1'), emptyCompareOutcome('Durum 2')] : [],
kind === 'Compare' ? [emptyCompareOutcome1('>5000'), emptyCompareOutcome2('<=5000')] : [],
positionX: 32,
positionY: 150,
}
@ -375,7 +375,7 @@ export function defaultTitle(kind: string) {
)
}
export function emptyCompareOutcome(label = 'Durum'): CompareOutcomeDto {
export function emptyCompareOutcome1(label = 'Durum'): CompareOutcomeDto {
return {
label,
targetId: '',
@ -383,6 +383,14 @@ export function emptyCompareOutcome(label = 'Durum'): CompareOutcomeDto {
}
}
export function emptyCompareOutcome2(label = 'Durum'): CompareOutcomeDto {
return {
label,
targetId: '',
conditions: [{ compareColumn: 'Tutar', compareOperator: '<=', compareValue: 5000 }],
}
}
export function toCompareOutcomeForm(
outcome: Partial<CompareOutcomeDto> &
Partial<WorkflowConditionDto> & {

View file

@ -28,8 +28,11 @@ import FormFields from './form-fields/FormFields'
import { putListForms } from '@/services/admin/list-form.service'
import { getRoles, getUsers } from '@/services/identity.service'
import { GridOptionsEditDto, ListFormCustomizationDto } from '@/proxy/form/models'
import { SelectCommandTypeEnum } from '@/proxy/form/models'
import { IdentityRoleDto, IdentityUserDto } from '@/proxy/admin/models'
import { getListFormCustomizations } from '@/services/admin/list-form-customization.service'
import { sqlObjectManagerService } from '@/services/sql-query-manager.service'
import type { DatabaseColumnDto, SqlObjectExplorerDto } from '@/proxy/sql-query-manager/models'
import { Container } from '@/components/shared'
import { ROUTES_ENUM } from '@/routes/route.constant'
import FormTabWidgets from './FormTabWidgets'
@ -71,6 +74,8 @@ const FormEdit = () => {
const [langOptions, setLangOptions] = useState<SelectBoxOption[]>([])
const [roleList, setRoleList] = useState<SelectBoxOption[]>([])
const [userList, setUserList] = useState<SelectBoxOption[]>([])
const [workflowColumns, setWorkflowColumns] = useState<DatabaseColumnDto[]>([])
const [isLoadingWorkflowColumns, setIsLoadingWorkflowColumns] = useState(false)
const languages: LanguageInfo[] | undefined = useStoreState(
(state) => state.abpConfig.config?.localization.languages,
@ -141,6 +146,51 @@ const FormEdit = () => {
refreshData()
}, [listFormCode])
useEffect(() => {
const loadWorkflowColumns = async () => {
const dataSourceCode = listFormValues?.dataSourceCode
const selectCommand = listFormValues?.selectCommand
if (!dataSourceCode || !selectCommand) {
setWorkflowColumns([])
return
}
setIsLoadingWorkflowColumns(true)
try {
const objectsResponse = await sqlObjectManagerService.getAllObjects(dataSourceCode)
const objects = objectsResponse.data
const objectInfo = findSelectCommandObject(
objects,
selectCommand,
listFormValues.selectCommandType,
)
if (!objectInfo) {
setWorkflowColumns([])
return
}
const columnsResponse = await sqlObjectManagerService.getTableColumns(
dataSourceCode,
objectInfo.schemaName,
objectInfo.objectName,
)
setWorkflowColumns(columnsResponse.data ?? [])
} catch {
setWorkflowColumns([])
} finally {
setIsLoadingWorkflowColumns(false)
}
}
loadWorkflowColumns()
}, [
listFormValues?.dataSourceCode,
listFormValues?.selectCommand,
listFormValues?.selectCommandType,
])
const onSubmit = async (
editType: string,
values: GridOptionsEditDto,
@ -189,9 +239,12 @@ const FormEdit = () => {
{/* SAĞ TARAF */}
{listFormValues.isTenant && (
<Badge className='font-semibold' content="Bu bir MULTI TENANT form'dur, veri kaybı olmaması için, sorgularda TENANTID
parametresini kullanmayı unutmayınız." innerClass="p-1 bg-red-50 text-red-500">
</Badge>
<Badge
className="font-semibold"
content="Bu bir MULTI TENANT form'dur, veri kaybı olmaması için, sorgularda TENANTID
parametresini kullanmayı unutmayınız."
innerClass="p-1 bg-red-50 text-red-500"
></Badge>
)}
</div>
<Tabs defaultValue="details" variant="underline">
@ -392,7 +445,13 @@ const FormEdit = () => {
<FormTabWidgets listFormCode={listFormCode} />
</TabContent>
<TabContent value="workflow" className="px-2">
<FormTabWorkflow listFormCode={listFormCode} />
<FormTabWorkflow
listFormCode={listFormCode}
userList={userList}
selectCommandColumns={workflowColumns}
isLoadingSelectCommandColumns={isLoadingWorkflowColumns}
onSubmit={onSubmit}
/>
</TabContent>
<TabContent value="fields" className="px-2">
<FormFields
@ -451,4 +510,48 @@ const FormEdit = () => {
)
}
function findSelectCommandObject(
objects: SqlObjectExplorerDto | undefined,
selectCommand: string,
selectCommandType?: SelectCommandTypeEnum,
) {
if (!objects || !selectCommand || selectCommandType === SelectCommandTypeEnum.Query) {
return null
}
if (selectCommandType === SelectCommandTypeEnum.Table) {
const table = objects.tables.find((item) => item.tableName === selectCommand)
return table ? { schemaName: table.schemaName, objectName: table.tableName } : null
}
if (selectCommandType === SelectCommandTypeEnum.View) {
const view = objects.views.find((item) => item.objectName === selectCommand)
return view ? { schemaName: view.schemaName, objectName: view.objectName } : null
}
if (selectCommandType === SelectCommandTypeEnum.TableValuedFunction) {
const fn = objects.functions.find((item) => item.objectName === selectCommand)
return fn ? { schemaName: fn.schemaName, objectName: fn.objectName } : null
}
if (selectCommandType === SelectCommandTypeEnum.StoredProcedure) {
const sp = objects.storedProcedures.find((item) => item.objectName === selectCommand)
return sp ? { schemaName: sp.schemaName, objectName: sp.objectName } : null
}
const table = objects.tables.find((item) => item.tableName === selectCommand)
if (table) return { schemaName: table.schemaName, objectName: table.tableName }
const view = objects.views.find((item) => item.objectName === selectCommand)
if (view) return { schemaName: view.schemaName, objectName: view.objectName }
const fn = objects.functions.find((item) => item.objectName === selectCommand)
if (fn) return { schemaName: fn.schemaName, objectName: fn.objectName }
const sp = objects.storedProcedures.find((item) => item.objectName === selectCommand)
if (sp) return { schemaName: sp.schemaName, objectName: sp.objectName }
return null
}
export default FormEdit

View file

@ -8,7 +8,16 @@ import {
type WorkflowCriteriaForm,
} from '@/utils/workflow/workflowHelpers'
import { workflowService, type WorkflowCriteriaDto } from '@/services/workflow.service'
import { DashboardShell } from '../workflow/DashboardShell'
import { WorkflowDesigner } from '../workflow/WorkflowDesigner'
import { SelectBoxOption } from '@/types/shared'
import { Field, FieldProps, Form, Formik } from 'formik'
import { Button, Card, FormContainer, FormItem, Input, Select } from '@/components/ui'
import { ListFormEditTabs } from '@/proxy/admin/list-form/options'
import { object, string } from 'yup'
import { useStoreState } from '@/store/store'
import { FormEditProps } from './FormEdit'
import { useLocalization } from '@/utils/hooks/useLocalization'
import type { DatabaseColumnDto } from '@/proxy/sql-query-manager/models'
type PendingLink = {
sourceId: string
@ -25,18 +34,33 @@ type DragEndEvent = {
delta: { x: number; y: number }
}
export function FormTabWorkflow({ listFormCode }: { listFormCode: string }) {
export function FormTabWorkflow(
props: FormEditProps & {
listFormCode: string
userList: SelectBoxOption[]
selectCommandColumns: DatabaseColumnDto[]
isLoadingSelectCommandColumns: boolean
},
) {
const columnOptions: SelectBoxOption[] = props.selectCommandColumns.length
? props.selectCommandColumns.map((column) => ({
value: column.columnName,
label: `${column.columnName} (${column.dataType})`,
}))
: []
const [criteria, setCriteria] = useState<WorkflowCriteriaDto[]>([])
const [selectedId, setSelectedId] = useState('')
const [pendingLink, setPendingLink] = useState<PendingLink>(null)
const [criteriaForm, setCriteriaForm] = useState<WorkflowCriteriaForm>(
emptyCriteria('Start', listFormCode),
emptyCriteria('Start', props.listFormCode),
)
const [dragPreview, setDragPreview] = useState<DragPreview>(null)
const [canvasZoom, setCanvasZoom] = useState(1)
const [designerTab, setDesignerTab] = useState('flow')
const [busy, setBusy] = useState(false)
const canvasRef = useRef<HTMLDivElement | null>(null)
const { translate } = useLocalization()
const currentCriteria = useMemo(() => criteria, [criteria])
@ -46,10 +70,10 @@ export function FormTabWorkflow({ listFormCode }: { listFormCode: string }) {
)
const loadState = useCallback(async () => {
const data = await workflowService.getState(listFormCode)
const data = await workflowService.getState(props.listFormCode)
setCriteria(data.criteria)
return data
}, [listFormCode])
}, [props.listFormCode])
const runAction = useCallback(
async (action: () => Promise<unknown>) => {
@ -72,9 +96,9 @@ export function FormTabWorkflow({ listFormCode }: { listFormCode: string }) {
if (selectedCriteria) {
setCriteriaForm(toCriteriaForm(selectedCriteria))
} else {
setCriteriaForm(emptyCriteria('Start', listFormCode))
setCriteriaForm(emptyCriteria('Start', props.listFormCode))
}
}, [listFormCode, selectedCriteria])
}, [props.listFormCode, selectedCriteria])
useEffect(() => {
if (!selectedId) return
@ -90,7 +114,7 @@ export function FormTabWorkflow({ listFormCode }: { listFormCode: string }) {
runAction(async () => {
await workflowService.saveCriteria({
...normalizeCriteria(criteriaForm),
listFormCode,
listFormCode: props.listFormCode,
})
setSelectedId('')
})
@ -100,8 +124,8 @@ export function FormTabWorkflow({ listFormCode }: { listFormCode: string }) {
setDesignerTab('flow')
runAction(async () => {
const saved = await workflowService.saveCriteria({
...normalizeCriteria(emptyCriteria(kind, listFormCode)),
listFormCode,
...normalizeCriteria(emptyCriteria(kind, props.listFormCode)),
listFormCode: props.listFormCode,
positionX: 80 + (currentCriteria.length % 5) * 230,
positionY: 220 + Math.floor(currentCriteria.length / 5) * 140,
})
@ -145,12 +169,15 @@ export function FormTabWorkflow({ listFormCode }: { listFormCode: string }) {
}
runAction(async () => {
await workflowService.saveCriteria({ ...normalizeCriteria(next), listFormCode })
await workflowService.saveCriteria({
...normalizeCriteria(next),
listFormCode: props.listFormCode,
})
setPendingLink(null)
setSelectedId(sourceId)
})
},
[busy, currentCriteria, listFormCode, runAction],
[busy, currentCriteria, props.listFormCode, runAction],
)
useEffect(() => {
@ -190,7 +217,10 @@ export function FormTabWorkflow({ listFormCode }: { listFormCode: string }) {
}
runAction(async () => {
await workflowService.saveCriteria({ ...normalizeCriteria(next), listFormCode })
await workflowService.saveCriteria({
...normalizeCriteria(next),
listFormCode: props.listFormCode,
})
setSelectedId(next.id)
})
}
@ -215,7 +245,10 @@ export function FormTabWorkflow({ listFormCode }: { listFormCode: string }) {
setPendingLink(null)
runAction(async () => {
await workflowService.saveCriteria({ ...normalizeCriteria(next), listFormCode })
await workflowService.saveCriteria({
...normalizeCriteria(next),
listFormCode: props.listFormCode,
})
setSelectedId('')
})
}
@ -234,7 +267,7 @@ export function FormTabWorkflow({ listFormCode }: { listFormCode: string }) {
await workflowService.saveCriteria({
...normalizeCriteria(item),
listFormCode,
listFormCode: props.listFormCode,
positionX: position.x,
positionY: position.y,
})
@ -262,38 +295,149 @@ export function FormTabWorkflow({ listFormCode }: { listFormCode: string }) {
setSelectedId(sourceId)
}
const schema = object().shape({
approvalFieldName: string(),
approvalDateFieldName: string(),
})
const initialValues = useStoreState((s) => s.admin.lists.values)
if (!initialValues) {
return null
}
return (
<DashboardShell
busy={busy}
canvasRef={canvasRef}
canvasZoom={canvasZoom}
criteriaForm={criteriaForm}
currentCriteria={currentCriteria}
designerTab={designerTab}
dragPreview={dragPreview}
pendingLink={pendingLink}
selectedId={selectedId}
onAddCriteria={addCriteria}
onBeginLink={beginLink}
onChangeCriteriaForm={setCriteriaForm}
onClearCanvasSelection={clearCanvasSelection}
onConnectNodes={connectNodes}
onDeleteSelectedCriteria={deleteSelectedCriteria}
onDisconnectLink={disconnectLink}
onDragMove={(event: DragEndEvent | null) =>
setDragPreview(event ? { id: event.active.id, delta: event.delta } : null)
}
onFitFlowLayout={fitFlowLayout}
onOpenCriteriaDetails={openCriteriaDetails}
onResetDemo={() => runAction(() => workflowService.resetDemo(listFormCode))}
onSaveCriteria={saveCriteria}
onSelectCriteria={setSelectedId}
onSetDesignerTab={setDesignerTab}
onUpdateNodePosition={updateNodePosition}
onZoomIn={() => setCanvasZoom((current) => Math.min(1.5, Number((current + 0.1).toFixed(2))))}
onZoomOut={() =>
setCanvasZoom((current) => Math.max(0.6, Number((current - 0.1).toFixed(2))))
}
/>
<div>
<Formik
initialValues={initialValues}
validationSchema={schema}
onSubmit={async (values, formikHelpers) => {
await props.onSubmit(ListFormEditTabs.Workflow, values, formikHelpers)
}}
>
{({ touched, errors, values, isSubmitting }) => (
<Form>
<FormContainer size="sm">
<Card className="my-2">
<div className="grid grid-cols-1 md:grid-cols-3 gap-3">
<FormItem
label={translate('::ListForms.ListFormEdit.Workflow.ApprovalFieldName')}
invalid={
errors.workflowDto?.approvalFieldName &&
touched.workflowDto?.approvalFieldName
}
errorMessage={errors.workflowDto?.approvalFieldName}
>
<Field type="text" name="workflowDto.approvalFieldName">
{({ field, form }: FieldProps<SelectBoxOption>) => (
<Select
field={field}
form={form}
options={columnOptions}
isClearable={true}
value={columnOptions.filter(
(option) => option.value === values.workflowDto.approvalFieldName,
)}
onChange={(option) => form.setFieldValue(field.name, option?.value)}
/>
)}
</Field>
</FormItem>
<FormItem
label={translate('::ListForms.ListFormEdit.Workflow.ApprovalDateFieldName')}
invalid={
errors.workflowDto?.approvalDateFieldName &&
touched.workflowDto?.approvalDateFieldName
}
errorMessage={errors.workflowDto?.approvalDateFieldName}
>
<Field type="text" name="workflowDto.approvalDateFieldName">
{({ field, form }: FieldProps<SelectBoxOption>) => (
<Select
field={field}
form={form}
options={columnOptions}
isClearable={true}
value={columnOptions.filter(
(option) => option.value === values.workflowDto.approvalDateFieldName,
)}
onChange={(option) => form.setFieldValue(field.name, option?.value)}
/>
)}
</Field>
</FormItem>
<FormItem
label={translate('::ListForms.ListFormEdit.Workflow.ApprovalStatusFieldName')}
invalid={
errors.workflowDto?.approvalStatusFieldName &&
touched.workflowDto?.approvalStatusFieldName
}
errorMessage={errors.workflowDto?.approvalStatusFieldName}
>
<Field type="text" name="workflowDto.approvalStatusFieldName">
{({ field, form }: FieldProps<SelectBoxOption>) => (
<Select
field={field}
form={form}
options={columnOptions}
isClearable={true}
value={columnOptions.filter(
(option) => option.value === values.workflowDto.approvalStatusFieldName,
)}
onChange={(option) => form.setFieldValue(field.name, option?.value)}
/>
)}
</Field>
</FormItem>
</div>
<Button block variant="solid" loading={isSubmitting}>
{isSubmitting ? translate('::SavingWithThreeDot') : translate('::Save')}
</Button>
</Card>
</FormContainer>
</Form>
)}
</Formik>
<WorkflowDesigner
busy={busy}
canvasRef={canvasRef}
canvasZoom={canvasZoom}
criteriaForm={criteriaForm}
currentCriteria={currentCriteria}
designerTab={designerTab}
dragPreview={dragPreview}
pendingLink={pendingLink}
selectedCriteriaId={selectedId}
userList={props.userList}
selectCommandColumns={props.selectCommandColumns}
isLoadingSelectCommandColumns={props.isLoadingSelectCommandColumns}
onAddCriteria={addCriteria}
onBeginLink={beginLink}
onChangeCriteriaForm={setCriteriaForm}
onClearSelection={clearCanvasSelection}
onConnect={connectNodes}
onDeleteCriteria={deleteSelectedCriteria}
onDeleteLink={disconnectLink}
onDragMove={(event: DragEndEvent | null) =>
setDragPreview(event ? { id: event.active.id, delta: event.delta } : null)
}
onFitLayout={fitFlowLayout}
onOpenDetails={openCriteriaDetails}
onResetDemo={() => runAction(() => workflowService.resetDemo(props.listFormCode))}
onSaveCriteria={saveCriteria}
onSelectCriteria={setSelectedId}
onSetDesignerTab={setDesignerTab}
onUpdateNodePosition={updateNodePosition}
onZoomIn={() =>
setCanvasZoom((current) => Math.min(1.5, Number((current + 0.1).toFixed(2))))
}
onZoomOut={() =>
setCanvasZoom((current) => Math.max(0.6, Number((current - 0.1).toFixed(2))))
}
/>
</div>
)
}

View file

@ -1,484 +0,0 @@
import React from 'react'
import { FiSave, FiTrash2 } from 'react-icons/fi'
import classNames from 'classnames'
import {
columnOptions,
kindIcon,
kindOptions,
operatorOptions,
} from '@/utils/workflow/workflowConstants'
import {
compareOutcomeRuleText,
criteriaSummary,
emptyCompareOutcome,
targetTitle,
} from '@/utils/workflow/workflowHelpers'
import type { CompareOutcomeDto, WorkflowCriteriaDto } from '@/services/workflow.service'
const SaveIcon = FiSave as any
const TrashIcon = FiTrash2 as any
const tableButtonClass =
'inline-flex min-h-8 items-center justify-center gap-1.5 rounded-md border px-2.5 py-1 text-[13px] font-medium leading-none transition-colors disabled:cursor-not-allowed disabled:opacity-50'
type CriteriaTableProps = {
criteria: WorkflowCriteriaDto[]
selectedId: string
form: any
busy: boolean
onSelect: (id: string) => void
onChange: (form: any) => void
onSubmit: (event: React.FormEvent<HTMLFormElement>) => void
onDelete: (id: string) => void
}
export function CriteriaTable({
criteria,
selectedId,
form,
busy,
onSelect,
onChange,
onSubmit,
onDelete,
}: CriteriaTableProps) {
const setField = (name: string, value: unknown) => onChange({ ...form, [name]: value })
const targetOptions = [
{ value: '', label: 'Bağlantı yok' },
...criteria
.filter((item) => item.id !== form.id)
.map((item) => ({ value: item.id, label: `${item.id} - ${item.title}` })),
]
const updateCompareOutcome = (index: number, patch: Partial<CompareOutcomeDto>) => {
const next = [...(form.compareOutcomes || [])]
next[index] = { ...next[index], ...patch }
setField('compareOutcomes', next)
}
const updateCompareCondition = (
outcomeIndex: number,
conditionIndex: number,
patch: Record<string, unknown>,
) => {
const next = [...(form.compareOutcomes || [])]
const conditions = [...(next[outcomeIndex]?.conditions || [])]
conditions[conditionIndex] = { ...conditions[conditionIndex], ...patch }
next[outcomeIndex] = { ...next[outcomeIndex], conditions }
setField('compareOutcomes', next)
}
const addCompareCondition = (outcomeIndex: number) => {
const next = [...(form.compareOutcomes || [])]
next[outcomeIndex] = {
...next[outcomeIndex],
conditions: [
...(next[outcomeIndex]?.conditions || []),
{ compareColumn: 'Tutar', compareOperator: '>', compareValue: 0 },
],
}
setField('compareOutcomes', next)
}
const removeCompareCondition = (outcomeIndex: number, conditionIndex: number) => {
const next = [...(form.compareOutcomes || [])]
const conditions = (next[outcomeIndex]?.conditions || []).filter(
(_: unknown, index: number) => index !== conditionIndex,
)
next[outcomeIndex] = { ...next[outcomeIndex], conditions }
setField('compareOutcomes', next)
}
const removeCompareOutcome = (index: number) => {
setField(
'compareOutcomes',
(form.compareOutcomes || []).filter((_: unknown, itemIndex: number) => itemIndex !== index),
)
}
const targetSelect = (
value: string | null | undefined,
onSelectTarget: (value: string) => void,
required = false,
) => (
<select
required={required}
value={value || ''}
onChange={(event) => onSelectTarget(event.target.value)}
>
{targetOptions.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
)
const toggleRow = (id: string) => onSelect(id === selectedId ? '' : id)
return (
<section className="min-w-0 rounded-lg">
<form className="block" onSubmit={onSubmit}>
<div className="overflow-auto rounded-md border border-gray-200 text-sm">
<div className="min-w-[920px]">
<div className="grid grid-cols-[minmax(110px,0.8fr)_120px_minmax(220px,1.4fr)_minmax(220px,1.2fr)_110px] bg-slate-50 font-semibold text-slate-700">
<div className="px-4 py-3">Id</div>
<div className="px-4 py-3">Tip</div>
<div className="px-4 py-3">Başlık / Kural</div>
<div className="px-4 py-3">Bağlantılar</div>
<div className="px-4 py-3">İşlem</div>
</div>
{criteria.length === 0 && (
<div className="border-t border-gray-100 px-4 py-6 text-center text-slate-500">
Seçili akışı için ıklama kaydı yok.
</div>
)}
{criteria.map((item) => {
const isSelected = item.id === selectedId
const connectionSummary = criteriaConnectionSummary(item, criteria)
return (
<React.Fragment key={item.id}>
<div
className={classNames(
'grid cursor-pointer grid-cols-[minmax(110px,0.8fr)_120px_minmax(220px,1.4fr)_minmax(220px,1.2fr)_110px] border-t border-gray-100',
{
'bg-blue-50': isSelected,
},
)}
onClick={() => toggleRow(item.id)}
>
<div className="min-w-0 px-4 py-3">
<strong className="break-words">{item.id}</strong>
</div>
<div className="px-4 py-3">
{kindOptions.find((option) => option.value === item.kind)?.label}
</div>
<div className="min-w-0 break-words px-4 py-3">
{criteriaSummaryContent(item)}
</div>
<div className="min-w-0 break-words px-4 py-3">{connectionSummary}</div>
<div className="px-4 py-3">
<button
type="button"
className={classNames(
tableButtonClass,
'ml-1.5 border-gray-300 bg-white text-slate-700',
)}
onClick={(event) => {
event.stopPropagation()
toggleRow(item.id)
}}
>
{isSelected ? 'Kapat' : 'Düzenle'}
</button>
</div>
</div>
{isSelected && (
<div className="grid gap-3.5 border-t border-gray-100 bg-slate-50 p-3.5">
<div className="grid grid-cols-3 gap-2.5 max-[720px]:grid-cols-1">
<Field label="Tip" required>
<select
value={form.kind}
onChange={(event) => setField('kind', event.target.value)}
>
{kindOptions.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</Field>
<Field label="Başlık" required>
<input
required
value={form.title}
onChange={(event) => setField('title', event.target.value)}
/>
</Field>
<Field
label="Onaylayacak Kişi"
required={form.kind === 'Approval' || form.kind === 'Inform'}
>
<input
required={form.kind === 'Approval' || form.kind === 'Inform'}
value={form.approver}
onChange={(event) => setField('approver', event.target.value)}
/>
</Field>
{(form.kind === 'Start' || form.kind === 'Inform') && (
<Field label="Sonraki adım" required>
{targetSelect(
form.nextOnStart,
(value) => setField('nextOnStart', value),
true,
)}
</Field>
)}
{form.kind === 'Approval' && (
<>
<Field label="Onay adımı" required>
{targetSelect(
form.nextOnApprove,
(value) => setField('nextOnApprove', value),
true,
)}
</Field>
<Field label="Red adımı" required>
{targetSelect(
form.nextOnReject,
(value) => setField('nextOnReject', value),
true,
)}
</Field>
</>
)}
</div>
{form.kind === 'Compare' && (
<div className="grid gap-2.5 rounded-lg border border-gray-200 bg-slate-50 p-2.5">
<div className="flex items-center justify-between gap-2 text-[13px] font-bold text-slate-700">
<span>Karşılaştırma durumları</span>
<button
type="button"
className={classNames(
tableButtonClass,
'ml-1.5 border-gray-300 bg-white text-slate-700',
)}
disabled={(form.compareOutcomes || []).length >= 4}
onClick={() =>
setField('compareOutcomes', [
...(form.compareOutcomes || []),
emptyCompareOutcome(
`Durum ${(form.compareOutcomes || []).length + 1}`,
),
])
}
>
Ekle
</button>
</div>
{(form.compareOutcomes || []).map(
(outcome: CompareOutcomeDto, index: number) => (
<div
key={index}
className="grid gap-2 border-t border-gray-200 pt-2 first:border-t-0 first:pt-0"
>
<div className="grid grid-cols-[minmax(130px,0.8fr)_minmax(200px,1.4fr)_auto] items-center gap-2 max-[720px]:grid-cols-1">
<input
required
value={outcome.label}
aria-label="Durum adı zorunlu"
onChange={(event) =>
updateCompareOutcome(index, {
label: event.target.value,
})
}
/>
{targetSelect(
outcome.targetId,
(targetId) =>
updateCompareOutcome(index, {
targetId,
}),
true,
)}
<button
type="button"
className={classNames(
tableButtonClass,
'ml-1.5 border-gray-300 bg-white text-slate-700',
)}
disabled={(form.compareOutcomes || []).length <= 2}
onClick={() => removeCompareOutcome(index)}
>
Sil
</button>
</div>
<div className="grid gap-2.5">
{(outcome.conditions || []).map((condition, conditionIndex) => (
<div
key={conditionIndex}
className="grid grid-cols-[minmax(100px,0.7fr)_82px_minmax(110px,0.8fr)_auto] items-center gap-1.5 max-[720px]:grid-cols-1"
>
<select
value={condition.compareColumn}
onChange={(event) =>
updateCompareCondition(index, conditionIndex, {
column: event.target.value,
})
}
>
{columnOptions.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
<select
value={condition.compareOperator}
onChange={(event) =>
updateCompareCondition(index, conditionIndex, {
operator: event.target.value,
})
}
>
{operatorOptions.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
<input
required
type="number"
step="0.01"
value={condition.compareValue}
onChange={(event) =>
updateCompareCondition(index, conditionIndex, {
compareValue: event.target.value,
})
}
/>
<button
type="button"
className={classNames(
tableButtonClass,
'ml-1.5 border-gray-300 bg-white text-slate-700',
)}
disabled={(outcome.conditions || []).length <= 1}
onClick={() =>
removeCompareCondition(index, conditionIndex)
}
>
Koşulu sil
</button>
</div>
))}
<button
type="button"
className={classNames(
tableButtonClass,
'ml-1.5 border-gray-300 bg-white text-slate-700',
)}
onClick={() => addCompareCondition(index)}
>
Koşul ekle
</button>
</div>
</div>
),
)}
</div>
)}
<div className="flex flex-wrap gap-2">
<button
type="submit"
className={classNames(
tableButtonClass,
'border-blue-600 bg-blue-600 text-white',
)}
disabled={busy}
>
<SaveIcon />
Kaydet
</button>
<button
type="button"
className={classNames(
tableButtonClass,
'border-red-600 bg-red-600 text-white',
)}
disabled={busy || !form.id}
onClick={() => onDelete(form.id)}
>
<TrashIcon />
Sil
</button>
</div>
</div>
)}
</React.Fragment>
)
})}
</div>
</div>
</form>
</section>
)
}
function Field({
label,
children,
required = false,
}: {
label: string
children: React.ReactNode
required?: boolean
}) {
return (
<label className="grid gap-1.5 text-[12px] text-slate-700">
<span>
{label}
{required && <span className="font-bold text-red-600"> *</span>}
</span>
{children}
</label>
)
}
function criteriaSummaryContent(item: WorkflowCriteriaDto) {
if (item.kind === 'Compare') {
const outcomes = item.compareOutcomes || []
if (!outcomes.length) return '-'
return (
<ul className="m-0 grid gap-1">
{outcomes.map((outcome, index: number) => (
<li key={`${outcome.label || 'outcome'}-${index}`}>
<strong>{outcome.label || `Durum ${index + 1}`}:</strong>{' '}
{compareOutcomeRuleText(outcome)}
</li>
))}
</ul>
)
}
return criteriaSummary(item)
}
function criteriaConnectionSummary(item: WorkflowCriteriaDto, criteria: WorkflowCriteriaDto[]) {
if (item.kind === 'Compare') {
const outcomes = item.compareOutcomes || []
if (!outcomes.length) return '-'
return (
<ul className="m-0 grid gap-1">
{outcomes.map((outcome, index: number) => (
<li key={`${outcome.label || 'target'}-${index}`}>
<strong>{outcome.label || `Durum ${index + 1}`}:</strong>{' '}
{targetTitle(criteria, outcome.targetId)}
</li>
))}
</ul>
)
}
if (item.kind === 'Approval') {
return (
<ul className="m-0 grid gap-1">
<li>
<strong>Onay:</strong> {targetTitle(criteria, item.nextOnApprove)}
</li>
<li>
<strong>Red:</strong> {targetTitle(criteria, item.nextOnReject)}
</li>
</ul>
)
}
if (item.kind === 'Start' || item.kind === 'Inform') {
return targetTitle(criteria, item.nextOnStart)
}
return '-'
}

View file

@ -1,117 +0,0 @@
import { WorkflowDesigner } from './WorkflowDesigner'
import { useEffect, useState, type FormEvent, type RefObject } from 'react'
import type { WorkflowCriteriaDto } from '@/services/workflow.service'
import { DatabaseColumnDto } from '@/proxy/sql-query-manager/models'
import { sqlObjectManagerService } from '@/services/sql-query-manager.service'
type DashboardShellProps = {
busy: boolean
canvasRef: RefObject<HTMLDivElement>
canvasZoom: number
criteriaForm: any
currentCriteria: WorkflowCriteriaDto[]
designerTab: string
dragPreview: any
pendingLink: any
selectedId: string
onAddCriteria: (kind: string) => void
onBeginLink: (sourceId: string, outcome: string) => void
onChangeCriteriaForm: (form: any) => void
onClearCanvasSelection: () => void
onConnectNodes: (sourceId: string, outcome: string, targetId: string) => void
onDeleteSelectedCriteria: (criteriaId?: string) => void
onDisconnectLink: (sourceId: string, outcome: string) => void
onDragMove: (event: any) => void
onFitFlowLayout: () => void
onOpenCriteriaDetails: (id: string) => void
onResetDemo: () => void
onSaveCriteria: (event: FormEvent<HTMLFormElement>) => void
onSelectCriteria: (id: string) => void
onSetDesignerTab: (tab: string) => void
onUpdateNodePosition: (event: any) => void
onZoomIn: () => void
onZoomOut: () => void
}
export function DashboardShell({
busy,
canvasRef,
canvasZoom,
criteriaForm,
currentCriteria,
designerTab,
dragPreview,
pendingLink,
selectedId,
onAddCriteria,
onBeginLink,
onChangeCriteriaForm,
onClearCanvasSelection,
onConnectNodes,
onDeleteSelectedCriteria,
onDisconnectLink,
onDragMove,
onFitFlowLayout,
onOpenCriteriaDetails,
onResetDemo,
onSaveCriteria,
onSelectCriteria,
onSetDesignerTab,
onUpdateNodePosition,
onZoomIn,
onZoomOut,
}: DashboardShellProps) {
const [selectCommandColumns, setSelectCommandColumns] = useState<DatabaseColumnDto[]>([])
const [isLoadingColumns, setIsLoadingColumns] = useState(false)
const loadColumns = async (dsCode: string, schema: string, name: string) => {
if (!dsCode || !name) {
setSelectCommandColumns([])
return
}
setIsLoadingColumns(true)
try {
const res = await sqlObjectManagerService.getTableColumns(dsCode, schema, name)
setSelectCommandColumns(res.data ?? [])
} catch {
setSelectCommandColumns([])
} finally {
setIsLoadingColumns(false)
}
}
return (
<div className="min-h-screen">
<main className="grid">
<WorkflowDesigner
busy={busy}
canvasRef={canvasRef}
canvasZoom={canvasZoom}
criteriaForm={criteriaForm}
currentCriteria={currentCriteria}
designerTab={designerTab}
dragPreview={dragPreview}
pendingLink={pendingLink}
selectedCriteriaId={selectedId}
onAddCriteria={onAddCriteria}
onBeginLink={onBeginLink}
onChangeCriteriaForm={onChangeCriteriaForm}
onClearSelection={onClearCanvasSelection}
onConnect={onConnectNodes}
onDeleteCriteria={onDeleteSelectedCriteria}
onDeleteLink={onDisconnectLink}
onDragMove={onDragMove}
onFitLayout={onFitFlowLayout}
onOpenDetails={onOpenCriteriaDetails}
onResetDemo={onResetDemo}
onSaveCriteria={onSaveCriteria}
onSelectCriteria={onSelectCriteria}
onSetDesignerTab={onSetDesignerTab}
onUpdateNodePosition={onUpdateNodePosition}
onZoomIn={onZoomIn}
onZoomOut={onZoomOut}
/>
</main>
</div>
)
}

View file

@ -12,6 +12,7 @@ import {
} from '@/utils/workflow/workflowHelpers'
import type { KeyboardEvent, MouseEvent, RefObject } from 'react'
import type { WorkflowCriteriaDto } from '@/services/workflow.service'
import { useLocalization } from '@/utils/hooks/useLocalization'
type PendingLink = {
sourceId: string
@ -36,7 +37,7 @@ type FlowNodeProps = {
onBeginLink: (sourceId: string, outcome: string) => void
}
export function FlowCanvas({
export function WorkflowCanvas({
currentCriteria,
dragPreview,
zoom,
@ -126,7 +127,6 @@ export function FlowCanvas({
)}
{currentCriteria.length === 0 && (
<div className="sticky left-[18px] top-[18px] z-30 inline-grid max-w-[360px] gap-1 rounded-lg border border-[#cfd6e2] bg-white/95 p-3.5 text-[#475467] shadow-lg">
<strong className="text-slate-700">Boş canvas</strong>
<span>
Üstteki butonlardan adım ekleyin, sonra çıkış etiketleriyle bağlantıları kurun.
</span>
@ -286,6 +286,7 @@ function FlowNode({
top: item.positionY,
transform: CSS.Translate.toString(transform),
}
const { translate } = useLocalization()
return (
<button
@ -334,7 +335,7 @@ function FlowNode({
})}
>
<Icon />
{kindOptions.find((option) => option.value === item.kind)?.label}
{translate('::' + kindOptions.find((option) => option.value === item.kind)?.value)}
</span>
<strong className="break-words text-sm leading-tight [overflow-wrap:anywhere]">
{item.title}

View file

@ -0,0 +1,512 @@
import React from 'react'
import { FaEdit, FaTrash } from 'react-icons/fa'
import classNames from 'classnames'
import { Button, Dialog, FormContainer, FormItem, Input, Select, Table } from '@/components/ui'
import TBody from '@/components/ui/Table/TBody'
import THead from '@/components/ui/Table/THead'
import Td from '@/components/ui/Table/Td'
import Th from '@/components/ui/Table/Th'
import Tr from '@/components/ui/Table/Tr'
import { kindOptions, operatorOptions } from '@/utils/workflow/workflowConstants'
import {
compareOutcomeRuleText,
criteriaSummary,
emptyCompareOutcome1,
emptyCompareOutcome2,
targetTitle,
} from '@/utils/workflow/workflowHelpers'
import type { CompareOutcomeDto, WorkflowCriteriaDto } from '@/services/workflow.service'
import { SelectBoxOption } from '@/types/shared'
import { useLocalization } from '@/utils/hooks/useLocalization'
import type { DatabaseColumnDto } from '@/proxy/sql-query-manager/models'
type WorkflowCriteriaProps = {
criteria: WorkflowCriteriaDto[]
selectedId: string
formValues: any
busy: boolean
userList: SelectBoxOption[]
selectCommandColumns: DatabaseColumnDto[]
isLoadingSelectCommandColumns: boolean
onSelect: (id: string) => void
onChange: (form: any) => void
onSubmit: (event: React.FormEvent<HTMLFormElement>) => void
onDelete: (id: string) => void
}
export function WorkflowCriteria({
criteria,
selectedId,
formValues,
busy,
userList,
selectCommandColumns,
isLoadingSelectCommandColumns,
onSelect,
onChange,
onSubmit,
onDelete,
}: WorkflowCriteriaProps) {
const setField = (name: string, value: unknown) => onChange({ ...formValues, [name]: value })
const targetOptions = [
{ value: '', label: 'Bağlantı yok' },
...criteria
.filter((item) => item.id !== formValues.id)
.map((item) => ({ value: item.id, label: `${item.id} - ${item.title}` })),
]
const compareColumnOptions = selectCommandColumns.length
? selectCommandColumns.map((column) => ({
value: column.columnName,
label: `${column.columnName} (${column.dataType})`,
}))
: []
const defaultCompareColumn = compareColumnOptions[0]?.value ?? 'Tutar'
const updateCompareOutcome = (index: number, patch: Partial<CompareOutcomeDto>) => {
const next = [...(formValues.compareOutcomes || [])]
next[index] = { ...next[index], ...patch }
setField('compareOutcomes', next)
}
const updateCompareCondition = (
outcomeIndex: number,
conditionIndex: number,
patch: Record<string, unknown>,
) => {
const next = [...(formValues.compareOutcomes || [])]
const conditions = [...(next[outcomeIndex]?.conditions || [])]
conditions[conditionIndex] = { ...conditions[conditionIndex], ...patch }
next[outcomeIndex] = { ...next[outcomeIndex], conditions }
setField('compareOutcomes', next)
}
const addCompareCondition = (outcomeIndex: number) => {
const next = [...(formValues.compareOutcomes || [])]
next[outcomeIndex] = {
...next[outcomeIndex],
conditions: [
...(next[outcomeIndex]?.conditions || []),
{ compareColumn: defaultCompareColumn, compareOperator: '>', compareValue: 0 },
],
}
setField('compareOutcomes', next)
}
const removeCompareCondition = (outcomeIndex: number, conditionIndex: number) => {
const next = [...(formValues.compareOutcomes || [])]
const conditions = (next[outcomeIndex]?.conditions || []).filter(
(_: unknown, index: number) => index !== conditionIndex,
)
next[outcomeIndex] = { ...next[outcomeIndex], conditions }
setField('compareOutcomes', next)
}
const removeCompareOutcome = (index: number) => {
setField(
'compareOutcomes',
(formValues.compareOutcomes || []).filter(
(_: unknown, itemIndex: number) => itemIndex !== index,
),
)
}
const targetSelect = (
value: string | null | undefined,
onSelectTarget: (value: string) => void,
required = false,
className = '',
) => (
<SelectField
required={required}
options={targetOptions}
value={value || ''}
onChange={onSelectTarget}
className={className}
/>
)
const closeDialog = () => onSelect('')
const { translate } = useLocalization()
return (
<>
<Table compact>
<THead>
<Tr>
<Th className="text-center w-2/12">{translate('::Operation')}</Th>
<Th>{translate('::App.Platform.ID')}</Th>
<Th>{translate('::App.Platform.Type')}</Th>
<Th>{translate('::ListForms.ListFormEdit.Workflow.CriteriaTitleRule')}</Th>
<Th>{translate('::ListForms.ListFormEdit.Workflow.CriteriaConnections')}</Th>
</Tr>
</THead>
<TBody>
{criteria.length === 0 && (
<Tr>
<Td colSpan={5} className="text-center">
{translate('::ListForms.ListFormEdit.WorkflowNoCriteria')}
</Td>
</Tr>
)}
{criteria.map((item) => {
const isSelected = item.id === selectedId
const connectionSummary = criteriaConnectionSummary(item, criteria)
return (
<Tr key={item.id} className={classNames(isSelected && 'bg-blue-50')}>
<Td>
<div className="flex-wrap inline-flex xl:flex items-center gap-2">
<Button
shape="circle"
variant="plain"
type="button"
size="sm"
title="Edit"
icon={<FaEdit />}
onClick={(event) => {
event.preventDefault()
onSelect(item.id)
}}
/>
<Button
shape="circle"
variant="plain"
type="button"
size="sm"
title="Delete"
icon={<FaTrash />}
disabled={busy}
onClick={(event) => {
event.preventDefault()
onDelete(item.id)
}}
/>
</div>
</Td>
<Td>
<strong className="break-words">{item.id}</strong>
</Td>
<Td>
{translate(
'::' + kindOptions.find((option) => option.value === item.kind)?.value,
)}
</Td>
<Td className="min-w-[220px] break-words">{criteriaSummaryContent(item)}</Td>
<Td className="min-w-[220px] break-words">{connectionSummary}</Td>
</Tr>
)
})}
</TBody>
</Table>
<Dialog isOpen={!!selectedId} onClose={closeDialog} onRequestClose={closeDialog} width="lg">
<form onSubmit={onSubmit} className="flex flex-1 flex-col min-h-0">
<Dialog.Body className="flex flex-col">
<div className="flex-1 min-h-0 overflow-y-auto pr-1">
<FormContainer size="sm">
<div className="grid grid-cols-2 gap-1">
<FormItem label="Tip" asterisk>
<SelectField
required
options={kindOptions}
value={formValues.kind}
onChange={(value) => setField('kind', value)}
/>
</FormItem>
<FormItem label="Başlık" asterisk>
<Input
required
value={formValues.title}
onChange={(event) => setField('title', event.target.value)}
/>
</FormItem>
<FormItem
label="Onaylayacak Kişi"
asterisk={formValues.kind === 'Approval' || formValues.kind === 'Inform'}
>
<SelectField
required
options={userList}
value={formValues.approver}
onChange={(value) => setField('approver', value)}
/>
</FormItem>
{(formValues.kind === 'Start' || formValues.kind === 'Inform') && (
<FormItem label="Sonraki adım" asterisk>
{targetSelect(
formValues.nextOnStart,
(value) => setField('nextOnStart', value),
true,
)}
</FormItem>
)}
{formValues.kind === 'Approval' && (
<>
<FormItem label="Onay adımı" asterisk>
{targetSelect(
formValues.nextOnApprove,
(value) => setField('nextOnApprove', value),
true,
)}
</FormItem>
<FormItem label="Red adımı" asterisk>
{targetSelect(
formValues.nextOnReject,
(value) => setField('nextOnReject', value),
true,
)}
</FormItem>
</>
)}
</div>
{formValues.kind === 'Compare' && (
<div className="mb-4">
<div className="mb-3 flex items-center justify-between gap-2">
<h6>
Karşılaştırma durumları
{isLoadingSelectCommandColumns && (
<span className="ml-2 text-xs font-normal text-gray-400">
Sütunlar yükleniyor...
</span>
)}
</h6>
<Button
type="button"
size="sm"
disabled={(formValues.compareOutcomes || []).length >= 4}
onClick={() =>
setField('compareOutcomes', [
...(formValues.compareOutcomes || []),
emptyCompareOutcome1(
`Durum ${(formValues.compareOutcomes || []).length + 1}`,
),
])
}
>
Karşılaştırma Ekle
</Button>
</div>
<div className="grid gap-3">
{(formValues.compareOutcomes || []).map(
(outcome: CompareOutcomeDto, index: number) => (
<div key={index} className="rounded border border-gray-200 p-3">
<div className="flex flex-col-11 items-center gap-2 mb-2">
<strong className="flex-[5]">Durum {index + 1}</strong>
<strong className="flex-[5]">Bağlantı</strong>
<span className="flex-1" />
</div>
<div className="flex flex-col-11 items-center gap-2">
<Input
required
value={outcome.label}
aria-label="Durum adı zorunlu"
onChange={(event) =>
updateCompareOutcome(index, {
label: event.target.value,
})
}
className="flex-[5]"
/>
{targetSelect(
outcome.targetId,
(targetId) =>
updateCompareOutcome(index, {
targetId,
}),
true,
'flex-[5]',
)}
<Button
type="button"
size="sm"
variant="plain"
className="flex-1"
disabled={(formValues.compareOutcomes || []).length <= 2}
onClick={() => removeCompareOutcome(index)}
>
Sil
</Button>
</div>
<div className="mt-3 grid gap-2">
{(outcome.conditions || []).map((condition, conditionIndex) => (
<div
key={conditionIndex}
className="flex flex-col-12 items-center gap-2"
>
<SelectField
options={compareColumnOptions}
value={condition.compareColumn}
onChange={(value) =>
updateCompareCondition(index, conditionIndex, {
compareColumn: value,
})
}
className="flex-[4]"
/>
<SelectField
options={operatorOptions}
value={condition.compareOperator}
onChange={(value) =>
updateCompareCondition(index, conditionIndex, {
compareOperator: value,
})
}
className="flex-[3]"
/>
<Input
required
type="number"
step="0.01"
value={condition.compareValue}
onChange={(event) =>
updateCompareCondition(index, conditionIndex, {
compareValue: event.target.value,
})
}
className="flex-[3]"
/>
<Button
type="button"
size="sm"
variant="plain"
disabled={(outcome.conditions || []).length <= 1}
onClick={() => removeCompareCondition(index, conditionIndex)}
className="flex-[1]"
>
Sil
</Button>
<Button
type="button"
size="sm"
onClick={() => addCompareCondition(index)}
className="flex-[1]"
>
Ekle
</Button>
</div>
))}
</div>
</div>
),
)}
</div>
</div>
)}
</FormContainer>
</div>
</Dialog.Body>
<Dialog.Footer className="flex justify-end gap-2 border-t pt-3 mt-1">
<Button type="button" variant="plain" disabled={busy} onClick={closeDialog}>
Cancel
</Button>
<Button
type="button"
variant="plain"
disabled={busy || !formValues.id}
onClick={() => onDelete(formValues.id)}
>
Sil
</Button>
<Button variant="solid" loading={busy} type="submit">
Kaydet
</Button>
</Dialog.Footer>
</form>
</Dialog>
</>
)
}
function SelectField({
options,
value,
onChange,
required = false,
className = '',
}: {
options: SelectBoxOption[]
value: string
onChange: (value: string) => void
required?: boolean
className?: string
}) {
const selectedOption = options.find((option) => option.value === value) ?? null
return (
<>
<Select
options={options}
value={selectedOption}
onChange={(option: any) => onChange(option?.value ?? '')}
className={className}
/>
{required && (
<input
tabIndex={-1}
className="pointer-events-none h-0 w-0 opacity-0"
required
value={value}
onChange={() => undefined}
/>
)}
</>
)
}
function criteriaSummaryContent(item: WorkflowCriteriaDto) {
if (item.kind === 'Compare') {
const outcomes = item.compareOutcomes || []
if (!outcomes.length) return '-'
return (
<ul className="m-0 grid gap-1">
{outcomes.map((outcome, index: number) => (
<li key={`${outcome.label || 'outcome'}-${index}`}>
<strong>{outcome.label || `Durum ${index + 1}`}:</strong>{' '}
{compareOutcomeRuleText(outcome)}
</li>
))}
</ul>
)
}
return criteriaSummary(item)
}
function criteriaConnectionSummary(item: WorkflowCriteriaDto, criteria: WorkflowCriteriaDto[]) {
if (item.kind === 'Compare') {
const outcomes = item.compareOutcomes || []
if (!outcomes.length) return '-'
return (
<ul className="m-0 grid gap-1">
{outcomes.map((outcome, index: number) => (
<li key={`${outcome.label || 'target'}-${index}`}>
<strong>{outcome.label || `Durum ${index + 1}`}:</strong>{' '}
{targetTitle(criteria, outcome.targetId)}
</li>
))}
</ul>
)
}
if (item.kind === 'Approval') {
return (
<ul className="m-0 grid gap-1">
<li>
<strong>Onay:</strong> {targetTitle(criteria, item.nextOnApprove)}
</li>
<li>
<strong>Red:</strong> {targetTitle(criteria, item.nextOnReject)}
</li>
</ul>
)
}
if (item.kind === 'Start' || item.kind === 'Inform') {
return targetTitle(criteria, item.nextOnStart)
}
return '-'
}

View file

@ -2,10 +2,13 @@ import { DndContext } from '@dnd-kit/core'
import classNames from 'classnames'
import { FiMaximize2, FiRefreshCw, FiZoomIn, FiZoomOut } from 'react-icons/fi'
import { kindIcon, kindOptions } from '@/utils/workflow/workflowConstants'
import { CriteriaTable } from './CriteriaTable'
import { FlowCanvas } from './FlowCanvas'
import { WorkflowCriteria } from './WorkflowCriteria'
import { WorkflowCanvas } from './WorkflowCanvas'
import type { FormEvent, RefObject } from 'react'
import type { WorkflowCriteriaDto } from '@/services/workflow.service'
import { SelectBoxOption } from '@/types/shared'
import { useLocalization } from '@/utils/hooks/useLocalization'
import type { DatabaseColumnDto } from '@/proxy/sql-query-manager/models'
type WorkflowDesignerProps = {
busy: boolean
@ -17,6 +20,9 @@ type WorkflowDesignerProps = {
dragPreview: any
pendingLink: any
selectedCriteriaId: string
userList: SelectBoxOption[]
selectCommandColumns: DatabaseColumnDto[]
isLoadingSelectCommandColumns: boolean
onAddCriteria: (kind: string) => void
onBeginLink: (sourceId: string, outcome: string) => void
onChangeCriteriaForm: (form: any) => void
@ -54,6 +60,9 @@ export function WorkflowDesigner({
dragPreview,
pendingLink,
selectedCriteriaId,
userList,
selectCommandColumns,
isLoadingSelectCommandColumns,
onAddCriteria,
onBeginLink,
onChangeCriteriaForm,
@ -72,64 +81,73 @@ export function WorkflowDesigner({
onZoomIn,
onZoomOut,
}: WorkflowDesignerProps) {
const { translate } = useLocalization()
return (
<section className="relative min-w-0 rounded-lg border border-gray-200 bg-white p-4 max-[1080px]:pr-4">
<div className="mb-3.5 flex items-center justify-between gap-4 max-[720px]:flex-col max-[720px]:items-stretch">
<DesignerTabs activeTab={designerTab} onChange={onSetDesignerTab} />
<div className="min-h-screen">
<main className="grid">
<section className="relative min-w-0 rounded-lg border border-gray-200 bg-white p-4 max-[1080px]:pr-4">
<div className="mb-3.5 flex items-center justify-between gap-4 max-[720px]:flex-col max-[720px]:items-stretch">
<DesignerTabs activeTab={designerTab} onChange={onSetDesignerTab} />
{designerTab === 'flow' && (
<DesignerToolbar
busy={busy}
currentCriteria={currentCriteria}
zoom={canvasZoom}
onAddCriteria={onAddCriteria}
onFitLayout={onFitLayout}
onResetDemo={onResetDemo}
onZoomIn={onZoomIn}
onZoomOut={onZoomOut}
/>
)}
</div>
{designerTab === 'flow' && (
<DesignerToolbar
busy={busy}
currentCriteria={currentCriteria}
zoom={canvasZoom}
onAddCriteria={onAddCriteria}
onFitLayout={onFitLayout}
onResetDemo={onResetDemo}
onZoomIn={onZoomIn}
onZoomOut={onZoomOut}
/>
)}
</div>
{designerTab === 'flow' && (
<div className="block min-w-0 max-[1080px]:grid-cols-1">
<DndContext
onDragMove={onDragMove}
onDragCancel={() => onDragMove(null)}
onDragEnd={onUpdateNodePosition}
>
<FlowCanvas
currentCriteria={currentCriteria}
dragPreview={dragPreview}
zoom={canvasZoom}
{designerTab === 'flow' && (
<div className="block min-w-0 max-[1080px]:grid-cols-1">
<DndContext
onDragMove={onDragMove}
onDragCancel={() => onDragMove(null)}
onDragEnd={onUpdateNodePosition}
>
<WorkflowCanvas
currentCriteria={currentCriteria}
dragPreview={dragPreview}
zoom={canvasZoom}
selectedId={selectedCriteriaId}
pendingLink={pendingLink}
canvasRef={canvasRef}
onSelect={onSelectCriteria}
onOpenDetails={onOpenDetails}
onClearSelection={onClearSelection}
onDelete={onDeleteCriteria}
onDeleteLink={onDeleteLink}
onBeginLink={onBeginLink}
onConnect={onConnect}
/>
</DndContext>
</div>
)}
{designerTab === 'criteria' && (
<WorkflowCriteria
criteria={currentCriteria}
selectedId={selectedCriteriaId}
pendingLink={pendingLink}
canvasRef={canvasRef}
formValues={criteriaForm}
busy={busy}
userList={userList}
selectCommandColumns={selectCommandColumns}
isLoadingSelectCommandColumns={isLoadingSelectCommandColumns}
onSelect={onSelectCriteria}
onOpenDetails={onOpenDetails}
onClearSelection={onClearSelection}
onChange={onChangeCriteriaForm}
onSubmit={onSaveCriteria}
onDelete={onDeleteCriteria}
onDeleteLink={onDeleteLink}
onBeginLink={onBeginLink}
onConnect={onConnect}
/>
</DndContext>
</div>
)}
{designerTab === 'criteria' && (
<CriteriaTable
criteria={currentCriteria}
selectedId={selectedCriteriaId}
form={criteriaForm}
busy={busy}
onSelect={onSelectCriteria}
onChange={onChangeCriteriaForm}
onSubmit={onSaveCriteria}
onDelete={onDeleteCriteria}
/>
)}
</section>
)}
</section>
</main>
</div>
)
}
@ -152,9 +170,11 @@ function DesignerToolbar({
onZoomIn: () => void
onZoomOut: () => void
}) {
const { translate } = useLocalization()
return (
<div className="flex flex-wrap justify-end gap-2">
<button
{/* <button
type="button"
className={classNames(designerButtonClass, 'border-gray-300 bg-white text-slate-700')}
disabled={busy}
@ -163,7 +183,7 @@ function DesignerToolbar({
>
<FiRefreshCw />
Demo
</button>
</button> */}
<button
type="button"
className={classNames(designerButtonClass, 'border-blue-600 bg-white text-blue-600')}
@ -204,7 +224,7 @@ function DesignerToolbar({
onClick={() => onAddCriteria(option.value)}
>
<Icon />
{option.label}
{translate(`::ListForms.ListFormEdit.Workflow.Criteria${option.value}`)}
</button>
)
})}
@ -219,6 +239,8 @@ function DesignerTabs({
activeTab: string
onChange: (tab: string) => void
}) {
const { translate } = useLocalization()
return (
<div className="inline-flex gap-1 rounded-lg" role="tablist" aria-label="Akış tasarımı">
<button
@ -232,7 +254,7 @@ function DesignerTabs({
)}
onClick={() => onChange('flow')}
>
Akış
{translate('::ListForms.ListFormEdit.TabWorkflow')}
</button>
<button
type="button"
@ -245,7 +267,7 @@ function DesignerTabs({
)}
onClick={() => onChange('criteria')}
>
Adımlar
{translate('::ListForms.ListFormEdit.Workflow.Criteria')}
</button>
</div>
)