From 6262baa6f12d695a25d83304af985092715d439a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sedat=20=C3=96zt=C3=BCrk?= Date: Sun, 24 May 2026 00:12:01 +0300 Subject: [PATCH] =?UTF-8?q?ListFormWorkflowun=20Grid=20ve=20Tree=20=C3=BCz?= =?UTF-8?q?erinden=20=C3=A7al=C4=B1=C5=9Ft=C4=B1r?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../GridOptionsDto/GridOptionsDto.cs | 13 + .../GridOptionsDto/GridOptionsEditDto.cs | 13 - .../ListForms/GridOptionsDto/WorkflowDto.cs | 7 +- .../ListForms/Workflow/CompareOutcomeDto.cs | 2 +- ...CreateUpdateListFormWorkflowCriteriaDto.cs | 2 +- .../ListForms/Workflow/DecisionWorkflowDto.cs | 5 +- .../Workflow/IListFormWorkflowAppService.cs | 7 +- .../Workflow/ListFormWorkflowCriteriaDto.cs | 4 +- .../Workflow/ListFormWorkflowStateDto.cs | 2 +- .../ListForms/Workflow/StartWorkflowDto.cs | 8 + .../Workflow/WorkflowConditionDto.cs | 2 +- .../ListForms/Workflow/WorkflowHistoryDto.cs | 2 +- .../Workflow/WorkflowRunResultDto.cs | 12 + .../ListForms/ListFormSelectAppService.cs | 159 ++++- .../ListForms/ListFormWorkflowAppService.cs | 597 +++++++++++++++++- .../Seeds/LanguagesData.json | 60 ++ .../Queries/SelectQueryManager.cs | 24 + ....cs => 20260523160811_Initial.Designer.cs} | 2 +- ...9_Initial.cs => 20260523160811_Initial.cs} | 0 ui/public/version.json | 15 +- ui/src/proxy/form/models.ts | 35 +- ui/src/services/workflow.service.ts | 45 +- ui/src/utils/workflow/workflowHelpers.ts | 56 +- .../admin/listForm/edit/FormTabWorkflow.tsx | 32 +- .../listForm/workflow/WorkflowCriteria.tsx | 93 ++- .../developerKit/SqlTableDesignerDialog.tsx | 31 +- ui/src/views/form/useFormData.tsx | 6 +- ui/src/views/list/Grid.tsx | 118 +++- ui/src/views/list/Tree.tsx | 79 ++- ui/src/views/list/useListFormColumns.ts | 2 +- .../views/list/useListFormCustomDataSource.ts | 33 +- ui/src/views/list/useToolbar.tsx | 312 ++++++++- 32 files changed, 1643 insertions(+), 135 deletions(-) create mode 100644 api/src/Sozsoft.Platform.Application.Contracts/ListForms/Workflow/StartWorkflowDto.cs create mode 100644 api/src/Sozsoft.Platform.Application.Contracts/ListForms/Workflow/WorkflowRunResultDto.cs rename api/src/Sozsoft.Platform.EntityFrameworkCore/Migrations/{20260523104659_Initial.Designer.cs => 20260523160811_Initial.Designer.cs} (99%) rename api/src/Sozsoft.Platform.EntityFrameworkCore/Migrations/{20260523104659_Initial.cs => 20260523160811_Initial.cs} (100%) diff --git a/api/src/Sozsoft.Platform.Application.Contracts/ListForms/GridOptionsDto/GridOptionsDto.cs b/api/src/Sozsoft.Platform.Application.Contracts/ListForms/GridOptionsDto/GridOptionsDto.cs index dba2162..01e9a89 100644 --- a/api/src/Sozsoft.Platform.Application.Contracts/ListForms/GridOptionsDto/GridOptionsDto.cs +++ b/api/src/Sozsoft.Platform.Application.Contracts/ListForms/GridOptionsDto/GridOptionsDto.cs @@ -394,6 +394,19 @@ public class GridOptionsDto : AuditedEntityDto set { LayoutJson = JsonSerializer.Serialize(value); } } + [JsonIgnore] + public string WorkflowJson { get; set; } + public WorkflowDto WorkflowDto + { + get + { + if (!string.IsNullOrEmpty(WorkflowJson)) + return JsonSerializer.Deserialize(WorkflowJson); + return new WorkflowDto(); + } + set { WorkflowJson = JsonSerializer.Serialize(value); } + } + /// Chart a yetkili kullanıcı. UserId ve RoleId boş ise herkes yetkilidir /// public string UserId { get; set; } diff --git a/api/src/Sozsoft.Platform.Application.Contracts/ListForms/GridOptionsDto/GridOptionsEditDto.cs b/api/src/Sozsoft.Platform.Application.Contracts/ListForms/GridOptionsDto/GridOptionsEditDto.cs index 34d94e1..c0c4e1c 100644 --- a/api/src/Sozsoft.Platform.Application.Contracts/ListForms/GridOptionsDto/GridOptionsEditDto.cs +++ b/api/src/Sozsoft.Platform.Application.Contracts/ListForms/GridOptionsDto/GridOptionsEditDto.cs @@ -123,18 +123,5 @@ public class GridOptionsEditDto : GridOptionsDto } set { ExtraFilterJson = JsonSerializer.Serialize(value); } } - - [JsonIgnore] - public string WorkflowJson { get; set; } - public WorkflowDto WorkflowDto - { - get - { - if (!string.IsNullOrEmpty(WorkflowJson)) - return JsonSerializer.Deserialize(WorkflowJson); - return new WorkflowDto(); - } - set { WorkflowJson = JsonSerializer.Serialize(value); } - } } diff --git a/api/src/Sozsoft.Platform.Application.Contracts/ListForms/GridOptionsDto/WorkflowDto.cs b/api/src/Sozsoft.Platform.Application.Contracts/ListForms/GridOptionsDto/WorkflowDto.cs index bd707aa..2db2281 100644 --- a/api/src/Sozsoft.Platform.Application.Contracts/ListForms/GridOptionsDto/WorkflowDto.cs +++ b/api/src/Sozsoft.Platform.Application.Contracts/ListForms/GridOptionsDto/WorkflowDto.cs @@ -1,8 +1,13 @@ -namespace Sozsoft.Platform.ListForms; +using System.Collections.Generic; + +namespace Sozsoft.Platform.ListForms; public class WorkflowDto { public string ApprovalUserFieldName { get; set; } public string ApprovalDateFieldName { get; set; } public string ApprovalStatusFieldName { get; set; } + public string ApprovalDescriptionFieldName { get; set; } + + public List Criteria { get; set; } = []; } diff --git a/api/src/Sozsoft.Platform.Application.Contracts/ListForms/Workflow/CompareOutcomeDto.cs b/api/src/Sozsoft.Platform.Application.Contracts/ListForms/Workflow/CompareOutcomeDto.cs index 3d54767..f0f4a3a 100644 --- a/api/src/Sozsoft.Platform.Application.Contracts/ListForms/Workflow/CompareOutcomeDto.cs +++ b/api/src/Sozsoft.Platform.Application.Contracts/ListForms/Workflow/CompareOutcomeDto.cs @@ -1,6 +1,6 @@ using System.Collections.Generic; -namespace Sozsoft.Platform.ListForms.Workflow; +namespace Sozsoft.Platform.ListForms; public class CompareOutcomeDto { diff --git a/api/src/Sozsoft.Platform.Application.Contracts/ListForms/Workflow/CreateUpdateListFormWorkflowCriteriaDto.cs b/api/src/Sozsoft.Platform.Application.Contracts/ListForms/Workflow/CreateUpdateListFormWorkflowCriteriaDto.cs index cff8e97..05f18ff 100644 --- a/api/src/Sozsoft.Platform.Application.Contracts/ListForms/Workflow/CreateUpdateListFormWorkflowCriteriaDto.cs +++ b/api/src/Sozsoft.Platform.Application.Contracts/ListForms/Workflow/CreateUpdateListFormWorkflowCriteriaDto.cs @@ -1,7 +1,7 @@ using System; using System.Collections.Generic; -namespace Sozsoft.Platform.ListForms.Workflow; +namespace Sozsoft.Platform.ListForms; public class CreateUpdateListFormWorkflowCriteriaDto { diff --git a/api/src/Sozsoft.Platform.Application.Contracts/ListForms/Workflow/DecisionWorkflowDto.cs b/api/src/Sozsoft.Platform.Application.Contracts/ListForms/Workflow/DecisionWorkflowDto.cs index 1343de3..8eb2725 100644 --- a/api/src/Sozsoft.Platform.Application.Contracts/ListForms/Workflow/DecisionWorkflowDto.cs +++ b/api/src/Sozsoft.Platform.Application.Contracts/ListForms/Workflow/DecisionWorkflowDto.cs @@ -1,7 +1,10 @@ -namespace Sozsoft.Platform.ListForms.Workflow; +namespace Sozsoft.Platform.ListForms; public class DecisionWorkflowDto { + public string ListFormCode { get; set; } + public object[] Keys { get; set; } + public string CurrentNodeId { get; set; } public bool Approved { get; set; } public string Note { get; set; } } diff --git a/api/src/Sozsoft.Platform.Application.Contracts/ListForms/Workflow/IListFormWorkflowAppService.cs b/api/src/Sozsoft.Platform.Application.Contracts/ListForms/Workflow/IListFormWorkflowAppService.cs index 16a0b04..c90045a 100644 --- a/api/src/Sozsoft.Platform.Application.Contracts/ListForms/Workflow/IListFormWorkflowAppService.cs +++ b/api/src/Sozsoft.Platform.Application.Contracts/ListForms/Workflow/IListFormWorkflowAppService.cs @@ -2,13 +2,14 @@ using System; using System.Threading.Tasks; using Volo.Abp.Application.Services; -namespace Sozsoft.Platform.ListForms.Workflow; +namespace Sozsoft.Platform.ListForms; public interface IListFormWorkflowAppService : IApplicationService { - Task GetStateAsync(string listFormCode = null); + Task GetCriteriaAsync(string listFormCode = null); Task SaveCriteriaAsync(CreateUpdateListFormWorkflowCriteriaDto input); Task DeleteCriteriaAsync(string id); Task ResetDemoAsync(string listFormCode = null); + Task StartAsync(StartWorkflowDto input); + Task DecisionAsync(DecisionWorkflowDto input); } - diff --git a/api/src/Sozsoft.Platform.Application.Contracts/ListForms/Workflow/ListFormWorkflowCriteriaDto.cs b/api/src/Sozsoft.Platform.Application.Contracts/ListForms/Workflow/ListFormWorkflowCriteriaDto.cs index a9e791b..8330f5f 100644 --- a/api/src/Sozsoft.Platform.Application.Contracts/ListForms/Workflow/ListFormWorkflowCriteriaDto.cs +++ b/api/src/Sozsoft.Platform.Application.Contracts/ListForms/Workflow/ListFormWorkflowCriteriaDto.cs @@ -2,9 +2,9 @@ using System; using System.Collections.Generic; using Volo.Abp.Application.Dtos; -namespace Sozsoft.Platform.ListForms.Workflow; +namespace Sozsoft.Platform.ListForms; -public class ListFormWorkflowCriteriaDto : AuditedEntityDto +public class ListFormWorkflowCriteriaDto : EntityDto { public string ListFormCode { get; set; } public string Kind { get; set; } diff --git a/api/src/Sozsoft.Platform.Application.Contracts/ListForms/Workflow/ListFormWorkflowStateDto.cs b/api/src/Sozsoft.Platform.Application.Contracts/ListForms/Workflow/ListFormWorkflowStateDto.cs index 2c1a4a7..eee7579 100644 --- a/api/src/Sozsoft.Platform.Application.Contracts/ListForms/Workflow/ListFormWorkflowStateDto.cs +++ b/api/src/Sozsoft.Platform.Application.Contracts/ListForms/Workflow/ListFormWorkflowStateDto.cs @@ -1,6 +1,6 @@ using System.Collections.Generic; -namespace Sozsoft.Platform.ListForms.Workflow; +namespace Sozsoft.Platform.ListForms; public class ListFormWorkflowStateDto { diff --git a/api/src/Sozsoft.Platform.Application.Contracts/ListForms/Workflow/StartWorkflowDto.cs b/api/src/Sozsoft.Platform.Application.Contracts/ListForms/Workflow/StartWorkflowDto.cs new file mode 100644 index 0000000..ec60693 --- /dev/null +++ b/api/src/Sozsoft.Platform.Application.Contracts/ListForms/Workflow/StartWorkflowDto.cs @@ -0,0 +1,8 @@ +namespace Sozsoft.Platform.ListForms; + +public class StartWorkflowDto +{ + public string ListFormCode { get; set; } + public object[] Keys { get; set; } +} + diff --git a/api/src/Sozsoft.Platform.Application.Contracts/ListForms/Workflow/WorkflowConditionDto.cs b/api/src/Sozsoft.Platform.Application.Contracts/ListForms/Workflow/WorkflowConditionDto.cs index 25ab9c9..d87f04b 100644 --- a/api/src/Sozsoft.Platform.Application.Contracts/ListForms/Workflow/WorkflowConditionDto.cs +++ b/api/src/Sozsoft.Platform.Application.Contracts/ListForms/Workflow/WorkflowConditionDto.cs @@ -1,4 +1,4 @@ -namespace Sozsoft.Platform.ListForms.Workflow; +namespace Sozsoft.Platform.ListForms; public class WorkflowConditionDto { diff --git a/api/src/Sozsoft.Platform.Application.Contracts/ListForms/Workflow/WorkflowHistoryDto.cs b/api/src/Sozsoft.Platform.Application.Contracts/ListForms/Workflow/WorkflowHistoryDto.cs index b5cf770..1ef240e 100644 --- a/api/src/Sozsoft.Platform.Application.Contracts/ListForms/Workflow/WorkflowHistoryDto.cs +++ b/api/src/Sozsoft.Platform.Application.Contracts/ListForms/Workflow/WorkflowHistoryDto.cs @@ -1,6 +1,6 @@ using System; -namespace Sozsoft.Platform.ListForms.Workflow; +namespace Sozsoft.Platform.ListForms; public class WorkflowHistoryDto { diff --git a/api/src/Sozsoft.Platform.Application.Contracts/ListForms/Workflow/WorkflowRunResultDto.cs b/api/src/Sozsoft.Platform.Application.Contracts/ListForms/Workflow/WorkflowRunResultDto.cs new file mode 100644 index 0000000..0436c15 --- /dev/null +++ b/api/src/Sozsoft.Platform.Application.Contracts/ListForms/Workflow/WorkflowRunResultDto.cs @@ -0,0 +1,12 @@ +namespace Sozsoft.Platform.ListForms; + +public class WorkflowRunResultDto +{ + public object[] Keys { get; set; } + public string CurrentNodeId { get; set; } + public string CurrentNodeTitle { get; set; } + public string CurrentNodeKind { get; set; } + public bool WaitingApproval { get; set; } + public bool Completed { get; set; } +} + diff --git a/api/src/Sozsoft.Platform.Application/ListForms/ListFormSelectAppService.cs b/api/src/Sozsoft.Platform.Application/ListForms/ListFormSelectAppService.cs index 084671f..ec3dbcd 100644 --- a/api/src/Sozsoft.Platform.Application/ListForms/ListFormSelectAppService.cs +++ b/api/src/Sozsoft.Platform.Application/ListForms/ListFormSelectAppService.cs @@ -21,6 +21,7 @@ public class ListFormSelectAppService : PlatformAppService, IListFormSelectAppSe { private readonly IRepository listFormRepository; private readonly IRepository listFormFieldRepository; + private readonly IRepository listFormWorkflowRepository; private readonly ICurrentUser currentUser; private readonly ISelectQueryManager selectQueryManager; private readonly IListFormAuthorizationManager authManager; @@ -39,6 +40,7 @@ public class ListFormSelectAppService : PlatformAppService, IListFormSelectAppSe public ListFormSelectAppService( IRepository listFormRepository, IRepository listFormFieldRepository, + IRepository listFormWorkflowRepository, ICurrentUser currentUser, ISelectQueryManager selectQueryManager, IListFormAuthorizationManager authManager, @@ -54,6 +56,7 @@ public class ListFormSelectAppService : PlatformAppService, IListFormSelectAppSe { this.listFormRepository = listFormRepository; this.listFormFieldRepository = listFormFieldRepository; + this.listFormWorkflowRepository = listFormWorkflowRepository; this.currentUser = currentUser; this.selectQueryManager = selectQueryManager; this.authManager = authManager; @@ -165,6 +168,7 @@ public class ListFormSelectAppService : PlatformAppService, IListFormSelectAppSe var data = await dynamicDataRepository.QueryAsync(selectQueryManager.GroupQuery, connectionString, param); var dataQueryable = data.AsQueryable(); + var groups = new List<(string, int, List)>(selectQueryManager.GroupTuples.Count); for (int i = 0; i < selectQueryManager.GroupTuples.Count; i++) { @@ -231,7 +235,7 @@ public class ListFormSelectAppService : PlatformAppService, IListFormSelectAppSe { var widgetList = JsonSerializer.Deserialize(listForm.WidgetsJson) ?? []; var activeWidgets = widgetList.Where(w => w.IsActive && !string.IsNullOrWhiteSpace(w.SqlQuery)).ToList(); - + if (activeWidgets.Count == 0) return Widgets; @@ -305,6 +309,11 @@ public class ListFormSelectAppService : PlatformAppService, IListFormSelectAppSe Widgets = await GetWidgetsAsync(listForm) }; + //Workflow kriterlerini alıp GridOptionsDto içine atıyoruz ki frontend'de kullanabilelim + var workflowDto = result.GridOptions.WorkflowDto; + workflowDto.Criteria = await GetWorkflowCriteriaAsync(ListFormCode); + result.GridOptions.WorkflowDto = workflowDto; + var queryParameters = httpContextAccessor.HttpContext.Request.Query.ToDictionary(x => x.Key, x => x.Value); var defaultFields = await defaultValueManager.GenerateDefaultValuesAsync(listForm, fields, Enums.OperationEnum.Select, queryParameters: queryParameters); @@ -491,5 +500,153 @@ public class ListFormSelectAppService : PlatformAppService, IListFormSelectAppSe return sql + " " + insertText; } + + //Workflow kriterlerini alma + private async Task> GetWorkflowCriteriaAsync(string listFormCode) + { + var criteria = await listFormWorkflowRepository.GetListAsync(x => x.ListFormCode == listFormCode); + return OrderWorkflowCriteria(criteria.Select(MapWorkflowCriteria).ToList()); + } + + private static List OrderWorkflowCriteria(List criteria) + { + if (criteria.Count <= 1) + { + return criteria; + } + + var criteriaById = criteria.ToDictionary(x => x.Id); + var ordered = new List(); + var visited = new HashSet(); + var visiting = new HashSet(); + + var roots = criteria + .Where(x => x.Kind == "Start") + .OrderBy(x => x.Id) + .ToList(); + + if (roots.Count == 0) + { + var referencedIds = criteria + .SelectMany(GetWorkflowTargetIds) + .Where(criteriaById.ContainsKey) + .ToHashSet(); + + roots = criteria + .Where(x => !referencedIds.Contains(x.Id)) + .OrderBy(x => x.Id) + .ToList(); + } + + foreach (var root in roots) + { + VisitWorkflowCriteria(root); + } + + foreach (var item in criteria.OrderBy(x => x.Id)) + { + VisitWorkflowCriteria(item); + } + + return ordered; + + void VisitWorkflowCriteria(ListFormWorkflowCriteriaDto item) + { + if (visited.Contains(item.Id) || visiting.Contains(item.Id)) + { + return; + } + + visiting.Add(item.Id); + ordered.Add(item); + + foreach (var targetId in GetWorkflowTargetIds(item)) + { + if (criteriaById.TryGetValue(targetId, out var target)) + { + VisitWorkflowCriteria(target); + } + } + + visiting.Remove(item.Id); + visited.Add(item.Id); + } + } + + private static IEnumerable GetWorkflowTargetIds(ListFormWorkflowCriteriaDto criteria) + { + if (!criteria.NextOnStart.IsNullOrWhiteSpace()) + { + yield return criteria.NextOnStart; + } + + foreach (var outcome in criteria.CompareOutcomes ?? []) + { + if (!outcome.TargetId.IsNullOrWhiteSpace()) + { + yield return outcome.TargetId; + } + } + + if (!criteria.NextOnTrue.IsNullOrWhiteSpace()) + { + yield return criteria.NextOnTrue; + } + + if (!criteria.NextOnFalse.IsNullOrWhiteSpace()) + { + yield return criteria.NextOnFalse; + } + + if (!criteria.NextOnApprove.IsNullOrWhiteSpace()) + { + yield return criteria.NextOnApprove; + } + + if (!criteria.NextOnReject.IsNullOrWhiteSpace()) + { + yield return criteria.NextOnReject; + } + } + + private static ListFormWorkflowCriteriaDto MapWorkflowCriteria(ListFormWorkflow criteria) + { + return new ListFormWorkflowCriteriaDto + { + Id = criteria.Id, + ListFormCode = criteria.ListFormCode, + Kind = criteria.Kind, + Title = criteria.Title, + CompareColumn = criteria.CompareColumn, + CompareOperator = criteria.CompareOperator, + CompareValue = criteria.CompareValue, + Approver = criteria.Approver, + NextOnStart = criteria.NextOnStart, + NextOnTrue = criteria.NextOnTrue, + NextOnFalse = criteria.NextOnFalse, + NextOnApprove = criteria.NextOnApprove, + NextOnReject = criteria.NextOnReject, + PositionX = criteria.PositionX, + PositionY = criteria.PositionY, + CompareOutcomes = DeserializeCompareOutcomes(criteria.CompareOutcomesJson) + }; + } + + private static List DeserializeCompareOutcomes(string json) + { + if (json.IsNullOrWhiteSpace()) + { + return []; + } + + try + { + return JsonSerializer.Deserialize>(json) ?? []; + } + catch + { + return []; + } + } } diff --git a/api/src/Sozsoft.Platform.Application/ListForms/ListFormWorkflowAppService.cs b/api/src/Sozsoft.Platform.Application/ListForms/ListFormWorkflowAppService.cs index 7dd49ca..5fa5a26 100644 --- a/api/src/Sozsoft.Platform.Application/ListForms/ListFormWorkflowAppService.cs +++ b/api/src/Sozsoft.Platform.Application/ListForms/ListFormWorkflowAppService.cs @@ -1,11 +1,15 @@ using System; using System.Collections.Generic; +using System.Globalization; using System.Linq; using System.Text.Json; using System.Threading.Tasks; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Sozsoft.Platform.Entities; +using Sozsoft.Platform.Enums; +using Sozsoft.Platform.ListForms.Select; +using Sozsoft.Platform.Queries; using Volo.Abp; using Volo.Abp.Domain.Repositories; @@ -15,36 +19,149 @@ namespace Sozsoft.Platform.ListForms.Workflow; [Route("api/app/list-form-workflow")] public class ListFormWorkflowAppService : PlatformAppService, IListFormWorkflowAppService { - private const string DefaultListFormCode = "workflow"; private const string CriteriaIdPrefix = "N"; private const int CriteriaIdPadding = 3; + private const string SystemApprovalDescription = "Sistem tarafından otomatik olarak onaylandı."; private readonly IRepository criteriaRepository; + private readonly IListFormManager listFormManager; + private readonly IListFormAuthorizationManager authManager; + private readonly IListFormSelectAppService listFormSelectAppService; + private readonly IQueryManager queryManager; - public ListFormWorkflowAppService(IRepository criteriaRepository) + public ListFormWorkflowAppService( + IRepository criteriaRepository, + IListFormManager listFormManager, + IListFormAuthorizationManager authManager, + IListFormSelectAppService listFormSelectAppService, + IQueryManager queryManager) { this.criteriaRepository = criteriaRepository; + this.listFormManager = listFormManager; + this.authManager = authManager; + this.listFormSelectAppService = listFormSelectAppService; + this.queryManager = queryManager; } - [HttpGet("state")] - public async Task GetStateAsync(string listFormCode = null) + [HttpGet("criteria")] + public async Task GetCriteriaAsync(string listFormCode = null) { - var code = NormalizeListFormCode(listFormCode); + var code = listFormCode; var criteria = (await criteriaRepository.GetListAsync(x => x.ListFormCode == code)) - .OrderBy(x => x.PositionX) - .ThenBy(x => x.PositionY) + .OrderBy(x => x.Id) .ToList(); return new ListFormWorkflowStateDto { - Criteria = criteria.Select(MapCriteria).ToList() + Criteria = OrderWorkflowCriteria(criteria.Select(MapCriteria).ToList()) }; } + private static List OrderWorkflowCriteria(List criteria) + { + if (criteria.Count <= 1) + { + return criteria; + } + + var criteriaById = criteria.ToDictionary(x => x.Id); + var ordered = new List(); + var visited = new HashSet(); + var visiting = new HashSet(); + + var roots = criteria + .Where(x => x.Kind == "Start") + .OrderBy(x => x.Id) + .ToList(); + + if (roots.Count == 0) + { + var referencedIds = criteria + .SelectMany(GetWorkflowTargetIds) + .Where(criteriaById.ContainsKey) + .ToHashSet(); + + roots = criteria + .Where(x => !referencedIds.Contains(x.Id)) + .OrderBy(x => x.Id) + .ToList(); + } + + foreach (var root in roots) + { + VisitWorkflowCriteria(root); + } + + foreach (var item in criteria.OrderBy(x => x.Id)) + { + VisitWorkflowCriteria(item); + } + + return ordered; + + void VisitWorkflowCriteria(ListFormWorkflowCriteriaDto item) + { + if (visited.Contains(item.Id) || visiting.Contains(item.Id)) + { + return; + } + + visiting.Add(item.Id); + ordered.Add(item); + + foreach (var targetId in GetWorkflowTargetIds(item)) + { + if (criteriaById.TryGetValue(targetId, out var target)) + { + VisitWorkflowCriteria(target); + } + } + + visiting.Remove(item.Id); + visited.Add(item.Id); + } + } + + private static IEnumerable GetWorkflowTargetIds(ListFormWorkflowCriteriaDto criteria) + { + if (!criteria.NextOnStart.IsNullOrWhiteSpace()) + { + yield return criteria.NextOnStart; + } + + foreach (var outcome in criteria.CompareOutcomes ?? []) + { + if (!outcome.TargetId.IsNullOrWhiteSpace()) + { + yield return outcome.TargetId; + } + } + + if (!criteria.NextOnTrue.IsNullOrWhiteSpace()) + { + yield return criteria.NextOnTrue; + } + + if (!criteria.NextOnFalse.IsNullOrWhiteSpace()) + { + yield return criteria.NextOnFalse; + } + + if (!criteria.NextOnApprove.IsNullOrWhiteSpace()) + { + yield return criteria.NextOnApprove; + } + + if (!criteria.NextOnReject.IsNullOrWhiteSpace()) + { + yield return criteria.NextOnReject; + } + } + [HttpPost("criteria")] public async Task SaveCriteriaAsync(CreateUpdateListFormWorkflowCriteriaDto input) { - var code = NormalizeListFormCode(input.ListFormCode); + var code = input.ListFormCode; var isNew = input.Id.IsNullOrWhiteSpace(); var criteria = isNew ? new ListFormWorkflow(await GenerateNextCriteriaIdAsync()) @@ -58,7 +175,7 @@ public class ListFormWorkflowAppService : PlatformAppService, IListFormWorkflowA criteria.ListFormCode = code; criteria.Kind = NormalizeRequired(input.Kind, "Compare"); criteria.Title = NormalizeRequired(input.Title, criteria.Kind); - criteria.CompareColumn = NormalizeRequired(input.CompareColumn, "Tutar"); + criteria.CompareColumn = NormalizeRequired(input.CompareColumn, "Price"); criteria.CompareOperator = NormalizeRequired(input.CompareOperator, ">"); criteria.CompareValue = input.CompareValue; criteria.Approver = input.Approver ?? string.Empty; @@ -110,7 +227,7 @@ public class ListFormWorkflowAppService : PlatformAppService, IListFormWorkflowA [HttpPost("reset-demo")] public async Task ResetDemoAsync(string listFormCode = null) { - var code = NormalizeListFormCode(listFormCode); + var code = listFormCode; var existing = await criteriaRepository.GetListAsync(x => x.ListFormCode == code); foreach (var item in existing) { @@ -121,7 +238,7 @@ public class ListFormWorkflowAppService : PlatformAppService, IListFormWorkflowA var compare = await CreateCriteriaAsync(code, "Compare", "Tutar kontrolü", 330, 130); 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); + var end = await CreateCriteriaAsync(code, "End", "İş Akışı Bitir", 850, 150); start.NextOnStart = compare.Id; compare.NextOnTrue = approval.Id; @@ -131,13 +248,13 @@ public class ListFormWorkflowAppService : PlatformAppService, IListFormWorkflowA { Label = "Onay gerekir", TargetId = approval.Id, - Conditions = [new WorkflowConditionDto { CompareColumn = "Tutar", CompareOperator = ">", CompareValue = 5000 }] + Conditions = [new WorkflowConditionDto { CompareColumn = "Price", CompareOperator = ">", CompareValue = 5000 }] }, new CompareOutcomeDto { Label = "Bilgilendir", TargetId = inform.Id, - Conditions = [new WorkflowConditionDto { CompareColumn = "Tutar", CompareOperator = "<=", CompareValue = 5000 }] + Conditions = [new WorkflowConditionDto { CompareColumn = "Price", CompareOperator = "<=", CompareValue = 5000 }] } ]); approval.NextOnApprove = inform.Id; @@ -149,7 +266,422 @@ public class ListFormWorkflowAppService : PlatformAppService, IListFormWorkflowA await criteriaRepository.UpdateAsync(approval, autoSave: true); await criteriaRepository.UpdateAsync(inform, autoSave: true); - return await GetStateAsync(code); + return await GetCriteriaAsync(code); + } + + [HttpPost("start")] + public async Task StartAsync(StartWorkflowDto input) + { + if (input.Keys?.Length > 1) + { + return await RunForEachKeyAsync(input.Keys, keys => StartSingleAsync(input.ListFormCode, keys)); + } + + return await StartSingleAsync(input.ListFormCode, input.Keys); + } + + private async Task StartSingleAsync(string listFormCode, object[] keys) + { + var context = await CreateRunContextAsync(listFormCode, keys); + var start = context.Criteria.FirstOrDefault(x => x.Kind == "Start") + ?? throw new UserFriendlyException("Workflow başlangıç adımı bulunamadı."); + + return await RunUntilWaitAsync(context, start); + } + + [HttpPost("decision")] + public async Task DecisionAsync(DecisionWorkflowDto input) + { + if (input.Keys?.Length > 1) + { + return await RunForEachKeyAsync(input.Keys, keys => DecisionSingleAsync(input, keys)); + } + + return await DecisionSingleAsync(input, input.Keys); + } + + private async Task DecisionSingleAsync(DecisionWorkflowDto input, object[] keys) + { + var context = await CreateRunContextAsync(input.ListFormCode, keys); + var currentNodeId = GetRowValue(context.Row, context.Workflow.ApprovalStatusFieldName)?.ToString(); + var current = FindCurrentCriteria(context.Criteria, currentNodeId); + + if (current == null && currentNodeId.IsNullOrWhiteSpace()) + { + var start = context.Criteria.FirstOrDefault(x => x.Kind == "Start") + ?? throw new UserFriendlyException("Workflow başlangıç adımı bulunamadı."); + + var started = await RunUntilWaitAsync(context, start); + current = FindCurrentCriteria(context.Criteria, started.CurrentNodeId); + } + else if (current != null && current.Kind != "Approval" && current.Kind != "End") + { + var progressed = await RunUntilWaitAsync(context, current); + current = FindCurrentCriteria(context.Criteria, progressed.CurrentNodeId); + } + + if (current?.Kind != "Approval") + { + throw new UserFriendlyException("Seçili kayıt onay adımında beklemiyor."); + } + if (!input.CurrentNodeId.IsNullOrWhiteSpace() && input.CurrentNodeId != current.Id) + { + throw new UserFriendlyException("Seçili kayıt bu onay adımında beklemiyor."); + } + + var update = new Dictionary(); + if (!context.Workflow.ApprovalUserFieldName.IsNullOrWhiteSpace()) + { + update[context.Workflow.ApprovalUserFieldName] = CurrentUser.UserName ?? CurrentUser.Name ?? string.Empty; + } + if (!context.Workflow.ApprovalDateFieldName.IsNullOrWhiteSpace()) + { + update[context.Workflow.ApprovalDateFieldName] = Clock.Now; + } + if (!context.Workflow.ApprovalDescriptionFieldName.IsNullOrWhiteSpace()) + { + update[context.Workflow.ApprovalDescriptionFieldName] = input.Note ?? string.Empty; + context.UserUpdatedFields.Add(context.Workflow.ApprovalDescriptionFieldName); + } + + if (update.Count > 0) + { + await UpdateRowAsync(context, update); + MergeRowValues(context.Row, update); + } + + var next = FindNextCriteria(context.Criteria, input.Approved ? current.NextOnApprove : current.NextOnReject); + return await RunUntilWaitAsync(context, next); + } + + private async Task RunForEachKeyAsync( + object[] keys, + Func> action) + { + var results = new List(); + + foreach (var key in keys) + { + results.Add(await action([key])); + } + + var last = results.LastOrDefault(); + return new WorkflowRunResultDto + { + Keys = keys, + CurrentNodeId = last?.CurrentNodeId, + CurrentNodeTitle = last?.CurrentNodeTitle, + CurrentNodeKind = last?.CurrentNodeKind, + WaitingApproval = results.Any(x => x.WaitingApproval), + Completed = results.All(x => x.Completed) + }; + } + + private async Task CreateRunContextAsync(string listFormCode, object[] keys) + { + var code = listFormCode; + if (!await authManager.CanAccess(code, AuthorizationTypeEnum.Update)) + { + throw new UserFriendlyException("Workflow işlemi için güncelleme yetkiniz yok."); + } + + var listForm = await listFormManager.GetUserListForm(code); + var workflow = DeserializeWorkflow(listForm.WorkflowJson); + if (workflow.ApprovalStatusFieldName.IsNullOrWhiteSpace()) + { + throw new UserFriendlyException("Workflow durum alanı tanımlı değil."); + } + if (listForm.KeyFieldName.IsNullOrWhiteSpace()) + { + throw new UserFriendlyException("Liste formu anahtar alanı tanımlı değil."); + } + if (keys == null || keys.Length == 0) + { + throw new UserFriendlyException("Workflow için kayıt anahtarı seçilmelidir."); + } + + var criteria = (await criteriaRepository.GetListAsync(x => x.ListFormCode == code)) + .OrderBy(x => x.Id) + .ToList(); + var row = await GetRowAsync(code, listForm.KeyFieldName, keys[0]); + + return new WorkflowRunContext(code, listForm.KeyFieldName, keys, workflow, criteria, row); + } + + private async Task> GetRowAsync(string listFormCode, string keyFieldName, object key) + { + var filter = JsonSerializer.Serialize(new object[] { keyFieldName, "=", key }); + var result = await listFormSelectAppService.GetSelectAsync(new SelectRequestDto + { + ListFormCode = listFormCode, + Filter = filter, + Skip = 0, + Take = 1, + RequireTotalCount = false, + RequireGroupCount = false + }); + + var row = ((result?.Data as System.Collections.IEnumerable)?.Cast()?.FirstOrDefault()) + ?? throw new UserFriendlyException("Workflow kaydı bulunamadı."); + + if (row is IDictionary dictionary) + { + return dictionary; + } + + throw new UserFriendlyException("Workflow kaydı okunamadı."); + } + + private async Task RunUntilWaitAsync(WorkflowRunContext context, ListFormWorkflow current) + { + var visited = new HashSet(); + + while (current != null) + { + if (!visited.Add(current.Id)) + { + throw new UserFriendlyException("Workflow adımlarında döngü tespit edildi."); + } + + await ApplyNodeAsync(context, current); + + if (current.Kind == "Approval") + { + return ToRunResult(context, current, waitingApproval: true, completed: false); + } + + if (current.Kind == "End") + { + return ToRunResult(context, current, waitingApproval: false, completed: true); + } + + current = FindNextCriteria(context.Criteria, ResolveNextNodeId(context, current)); + } + + return ToRunResult(context, null, waitingApproval: false, completed: true); + } + + private async Task ApplyNodeAsync(WorkflowRunContext context, ListFormWorkflow node) + { + var update = new Dictionary + { + [context.Workflow.ApprovalStatusFieldName] = node.Title + }; + + if (node.Kind == "Approval") + { + if (!context.Workflow.ApprovalUserFieldName.IsNullOrWhiteSpace()) + { + update[context.Workflow.ApprovalUserFieldName] = node.Approver ?? string.Empty; + } + } + else if (node.Kind == "End") + { + if (!context.Workflow.ApprovalUserFieldName.IsNullOrWhiteSpace() && + IsRowValueEmpty(context.Row, context.Workflow.ApprovalUserFieldName)) + { + update[context.Workflow.ApprovalUserFieldName] = PlatformConsts.AbpIdentity.User.AdminEmailDefaultValue; + } + if (!context.Workflow.ApprovalDateFieldName.IsNullOrWhiteSpace() && + IsRowValueEmpty(context.Row, context.Workflow.ApprovalDateFieldName)) + { + update[context.Workflow.ApprovalDateFieldName] = Clock.Now; + } + if (!context.Workflow.ApprovalDescriptionFieldName.IsNullOrWhiteSpace() && + !context.UserUpdatedFields.Contains(context.Workflow.ApprovalDescriptionFieldName) && + IsRowValueEmpty(context.Row, context.Workflow.ApprovalDescriptionFieldName)) + { + update[context.Workflow.ApprovalDescriptionFieldName] = SystemApprovalDescription; + } + } + + await UpdateRowAsync(context, update); + MergeRowValues(context.Row, update); + } + + private async Task UpdateRowAsync(WorkflowRunContext context, Dictionary data) + { + await queryManager.GenerateAndRunQueryAsync( + context.ListFormCode, + OperationEnum.Update, + JsonSerializer.Serialize(data), + context.Keys); + } + + private string ResolveNextNodeId(WorkflowRunContext context, ListFormWorkflow current) + { + if (current.Kind == "Start" || current.Kind == "Inform") + { + return current.NextOnStart; + } + + if (current.Kind == "Compare") + { + var outcomes = DeserializeCompareOutcomes(current.CompareOutcomesJson); + var matched = outcomes.FirstOrDefault(outcome => + outcome.Conditions == null || + outcome.Conditions.Count == 0 || + outcome.Conditions.All(condition => EvaluateCondition(context.Row, condition, current.CompareColumn))); + + if (matched != null && !matched.TargetId.IsNullOrWhiteSpace()) + { + return matched.TargetId; + } + + return EvaluateCondition(context.Row, new WorkflowConditionDto + { + CompareColumn = current.CompareColumn, + CompareOperator = current.CompareOperator, + CompareValue = current.CompareValue + }, current.CompareColumn) + ? current.NextOnTrue + : current.NextOnFalse; + } + + return null; + } + + private static bool EvaluateCondition( + IDictionary row, + WorkflowConditionDto condition, + string fallbackCompareColumn = null) + { + if (condition == null || condition.CompareColumn.IsNullOrWhiteSpace()) + { + return false; + } + + var compareColumn = ResolveCompareColumn(row, condition.CompareColumn, fallbackCompareColumn); + if (!TryGetRowValue(row, compareColumn, out var rawValue)) + { + throw new UserFriendlyException( + $"Workflow karşılaştırma alanı bulunamadı: {condition.CompareColumn}. Mevcut alanlar: {string.Join(", ", row.Keys)}"); + } + + if (rawValue == null || + (!decimal.TryParse(rawValue.ToString(), NumberStyles.Any, CultureInfo.InvariantCulture, out var value) && + !decimal.TryParse(rawValue.ToString(), NumberStyles.Any, CultureInfo.CurrentCulture, out value))) + { + throw new UserFriendlyException( + $"Workflow karşılaştırma alanı sayısal değer olarak okunamadı: {compareColumn} = {rawValue}"); + } + + return condition.CompareOperator switch + { + ">" => value > condition.CompareValue, + ">=" => value >= condition.CompareValue, + "<" => value < condition.CompareValue, + "<=" => value <= condition.CompareValue, + "==" or "=" => value == condition.CompareValue, + "!=" or "<>" => value != condition.CompareValue, + _ => false + }; + } + + private static string ResolveCompareColumn( + IDictionary row, + string compareColumn, + string fallbackCompareColumn) + { + if (TryGetRowValue(row, compareColumn, out _)) + { + return compareColumn; + } + + if (!fallbackCompareColumn.IsNullOrWhiteSpace() && + TryGetRowValue(row, fallbackCompareColumn, out _)) + { + return fallbackCompareColumn; + } + + var numericKeys = row + .Where(item => + item.Value != null && + decimal.TryParse(item.Value.ToString(), NumberStyles.Any, CultureInfo.InvariantCulture, out _)) + .Select(item => item.Key) + .ToList(); + + if (numericKeys.Count == 1) + { + return numericKeys[0]; + } + + throw new UserFriendlyException( + $"Workflow karşılaştırma alanı bulunamadı: {compareColumn}. Mevcut alanlar: {string.Join(", ", row.Keys)}"); + } + + private static object GetRowValue(IDictionary row, string fieldName) + { + return TryGetRowValue(row, fieldName, out var value) ? value : null; + } + + private static bool IsRowValueEmpty(IDictionary row, string fieldName) + { + if (!TryGetRowValue(row, fieldName, out var value)) + { + return true; + } + + return value == null || value == DBNull.Value || value.ToString().IsNullOrWhiteSpace(); + } + + private static bool TryGetRowValue(IDictionary row, string fieldName, out object value) + { + if (row.TryGetValue(fieldName, out value)) + { + return true; + } + + var key = row.Keys.FirstOrDefault(x => string.Equals(x, fieldName, StringComparison.OrdinalIgnoreCase)); + if (key == null) + { + value = null; + return false; + } + + value = row[key]; + return true; + } + + private static void MergeRowValues(IDictionary row, Dictionary values) + { + foreach (var value in values) + { + row[value.Key] = value.Value; + } + } + + private static ListFormWorkflow FindNextCriteria(List criteria, string id) + { + return id.IsNullOrWhiteSpace() ? null : criteria.FirstOrDefault(x => x.Id == id); + } + + private static ListFormWorkflow FindCurrentCriteria(List criteria, string currentNodeId) + { + if (currentNodeId.IsNullOrWhiteSpace()) + { + return null; + } + + return criteria.FirstOrDefault(x => string.Equals(x.Id, currentNodeId, StringComparison.OrdinalIgnoreCase)) + ?? criteria.FirstOrDefault(x => string.Equals(x.Title, currentNodeId, StringComparison.OrdinalIgnoreCase)); + } + + private static WorkflowRunResultDto ToRunResult( + WorkflowRunContext context, + ListFormWorkflow node, + bool waitingApproval, + bool completed) + { + return new WorkflowRunResultDto + { + Keys = context.Keys, + CurrentNodeId = node?.Id, + CurrentNodeTitle = node?.Title, + CurrentNodeKind = node?.Kind, + WaitingApproval = waitingApproval, + Completed = completed + }; } private async Task CreateCriteriaAsync( @@ -165,7 +697,7 @@ public class ListFormWorkflowAppService : PlatformAppService, IListFormWorkflowA ListFormCode = listFormCode, Kind = kind, Title = title, - CompareColumn = "Tutar", + CompareColumn = "Price", CompareOperator = ">", CompareValue = 5000, Approver = approver, @@ -287,11 +819,6 @@ public class ListFormWorkflowAppService : PlatformAppService, IListFormWorkflowA }; } - private static string NormalizeListFormCode(string listFormCode) - { - return listFormCode.IsNullOrWhiteSpace() ? DefaultListFormCode : listFormCode.Trim(); - } - private static string NormalizeRequired(string value, string fallback) { return value.IsNullOrWhiteSpace() ? fallback : value.Trim(); @@ -318,4 +845,32 @@ public class ListFormWorkflowAppService : PlatformAppService, IListFormWorkflowA return []; } } + + private static WorkflowDto DeserializeWorkflow(string json) + { + if (json.IsNullOrWhiteSpace()) + { + return new WorkflowDto(); + } + + try + { + return JsonSerializer.Deserialize(json) ?? new WorkflowDto(); + } + catch + { + return new WorkflowDto(); + } + } + + private sealed record WorkflowRunContext( + string ListFormCode, + string KeyFieldName, + object[] Keys, + WorkflowDto Workflow, + List Criteria, + IDictionary Row) + { + public HashSet UserUpdatedFields { get; } = []; + } } diff --git a/api/src/Sozsoft.Platform.DbMigrator/Seeds/LanguagesData.json b/api/src/Sozsoft.Platform.DbMigrator/Seeds/LanguagesData.json index 38910b3..9de8700 100644 --- a/api/src/Sozsoft.Platform.DbMigrator/Seeds/LanguagesData.json +++ b/api/src/Sozsoft.Platform.DbMigrator/Seeds/LanguagesData.json @@ -3624,6 +3624,12 @@ "en": "MENU", "tr": "MENÜ" }, + { + "resourceName": "Platform", + "key": "ListForms.ListForm.SelectRecord", + "en": "Please select a row.", + "tr": "Lütfen bir satır seçin." + }, { "resourceName": "Platform", "key": "ListForms.ListForm.ClearRedisCache", @@ -16292,6 +16298,12 @@ "en": "Status", "tr": "Durum" }, + { + "resourceName": "Platform", + "key": "App.Listform.ListformField.Connection", + "en": "Connection", + "tr": "Bağlantı" + }, { "resourceName": "Platform", "key": "App.Listform.ListformField.FailureReason", @@ -16580,6 +16592,48 @@ "en": "Title", "tr": "Başlık" }, + { + "resourceName": "Platform", + "key": "App.Listform.ListformField.Approver", + "en": "Approver", + "tr": "Onayla" + }, + { + "resourceName": "Platform", + "key": "App.Listform.ListformField.NextOnStart", + "en": "Next on Start", + "tr": "Sonraki Adım" + }, + { + "resourceName": "Platform", + "key": "App.Listform.ListformField.NextOnApprove", + "en": "Next on Approve", + "tr": "Onay Adımı" + }, + { + "resourceName": "Platform", + "key": "App.Listform.ListformField.NextOnReject", + "en": "Next on Reject", + "tr": "Red Adımı" + }, + { + "resourceName": "Platform", + "key": "App.Listform.ListformField.AddCompareOutcome", + "en": "Add Compare Outcome", + "tr": "Karşılaştırma Adımı Ekle" + }, + { + "resourceName": "Platform", + "key": "App.Listform.ListformField.CompareOutcomes", + "en": "Compare Outcomes", + "tr": "Karşılaştırma Adımları" + }, + { + "resourceName": "Platform", + "key": "App.Listform.ListformField.LoadingColumns", + "en": "Loading Columns...", + "tr": "Sütunlar Yükleniyor..." + }, { "resourceName": "Platform", "key": "App.Listform.ListformField.Total", @@ -18745,6 +18799,12 @@ "key": "ListForms.ListFormEdit.Workflow.ApprovalStatusFieldName", "en": "Approval Status Field Name", "tr": "Onay Durumu Alanı Adı" + }, + { + "resourceName": "Platform", + "key": "ListForms.ListFormEdit.Workflow.ApprovalDescriptionFieldName", + "en": "Approval Description Field Name", + "tr": "Onay Açıklaması Alanı Adı" } ] } \ No newline at end of file diff --git a/api/src/Sozsoft.Platform.Domain/Queries/SelectQueryManager.cs b/api/src/Sozsoft.Platform.Domain/Queries/SelectQueryManager.cs index f95a9b0..96850b6 100644 --- a/api/src/Sozsoft.Platform.Domain/Queries/SelectQueryManager.cs +++ b/api/src/Sozsoft.Platform.Domain/Queries/SelectQueryManager.cs @@ -135,6 +135,8 @@ public class SelectQueryManager : PlatformDomainService, ISelectQueryManager List listFormCustomizations = null, QueryParameters queryParams = null) { + ResetQueryState(); + if (listform == null) { return; @@ -213,6 +215,28 @@ public class SelectQueryManager : PlatformDomainService, ISelectQueryManager #endregion } + private void ResetQueryState() + { + SelectCommand = null; + TableName = null; + KeyFieldName = null; + SelectFields = []; + JoinParts = []; + WhereParts = []; + SortParts = []; + SelectQuery = null; + SelectQueryParameters = []; + TotalCountQuery = null; + GroupQuery = null; + DeleteQuery = null; + ChartQuery = null; + GroupTuples = []; + GroupSummaryTuples = []; + SummaryQueries = []; + IsAppliedGridFilter = false; + IsAppliedServerFilter = false; + } + private List GetSelectAndJoinFields(List listFormFields) { List selectFields = []; diff --git a/api/src/Sozsoft.Platform.EntityFrameworkCore/Migrations/20260523104659_Initial.Designer.cs b/api/src/Sozsoft.Platform.EntityFrameworkCore/Migrations/20260523160811_Initial.Designer.cs similarity index 99% rename from api/src/Sozsoft.Platform.EntityFrameworkCore/Migrations/20260523104659_Initial.Designer.cs rename to api/src/Sozsoft.Platform.EntityFrameworkCore/Migrations/20260523160811_Initial.Designer.cs index 896d038..7a1e36b 100644 --- a/api/src/Sozsoft.Platform.EntityFrameworkCore/Migrations/20260523104659_Initial.Designer.cs +++ b/api/src/Sozsoft.Platform.EntityFrameworkCore/Migrations/20260523160811_Initial.Designer.cs @@ -13,7 +13,7 @@ using Volo.Abp.EntityFrameworkCore; namespace Sozsoft.Platform.Migrations { [DbContext(typeof(PlatformDbContext))] - [Migration("20260523104659_Initial")] + [Migration("20260523160811_Initial")] partial class Initial { /// diff --git a/api/src/Sozsoft.Platform.EntityFrameworkCore/Migrations/20260523104659_Initial.cs b/api/src/Sozsoft.Platform.EntityFrameworkCore/Migrations/20260523160811_Initial.cs similarity index 100% rename from api/src/Sozsoft.Platform.EntityFrameworkCore/Migrations/20260523104659_Initial.cs rename to api/src/Sozsoft.Platform.EntityFrameworkCore/Migrations/20260523160811_Initial.cs diff --git a/ui/public/version.json b/ui/public/version.json index 9261f6b..e2b3ab3 100644 --- a/ui/public/version.json +++ b/ui/public/version.json @@ -1,6 +1,17 @@ { - "commit": "e9d8f5e", + "commit": "8f3932b", "releases": [ + { + "version": "1.0.10", + "buildDate": "2026-05-11", + "commit": "414006204e324018be597a598d3dd102868c5cca", + "changeLog": [ + "- Backup dosyalarını son 5 günün kalması", + "- Grid için Fit Columns özelliği eklendi.", + "- Notification Desktop, UiActivity, UiToast özelliği eklendi", + "- Versiyon güncellemeleri için \"System Updating\" mesajı" + ] + }, { "version": "1.0.9", "buildDate": "2026-05-09", @@ -93,4 +104,4 @@ ] } ] -} +} \ No newline at end of file diff --git a/ui/src/proxy/form/models.ts b/ui/src/proxy/form/models.ts index d15216f..108cecc 100644 --- a/ui/src/proxy/form/models.ts +++ b/ui/src/proxy/form/models.ts @@ -586,6 +586,7 @@ export interface GridOptionsDto extends AuditedEntityDto { extraFilterJson?: string extraFilterDto: ExtraFilterDto[] layoutDto: LayoutDto + workflowDto: WorkflowDto //ChartEditDto userId?: string @@ -663,7 +664,6 @@ export interface GridOptionsEditDto extends GridOptionsDto, Record formFieldsDefaultValueDto: FieldsDefaultValueDto[] widgetsJson?: string widgetsDto: WidgetEditDto[] - workflowDto: WorkflowDto extraFilterEditDto: ExtraFilterEditDto[] } @@ -910,6 +910,39 @@ export interface WorkflowDto { approvalUserFieldName: string approvalDateFieldName: string approvalStatusFieldName: string + approvalDescriptionFieldName: string + criteria: ListFormWorkflowCriteriaDto[] +} + +export interface WorkflowConditionDto { + compareColumn: string + compareOperator: string + compareValue: number +} + +export interface CompareOutcomeDto { + label: string + targetId?: string | null + conditions: WorkflowConditionDto[] +} + +export interface ListFormWorkflowCriteriaDto { + id: string + listFormCode: string + kind: string + title: string + compareColumn: string + compareOperator: string + compareValue: number + approver: string + nextOnStart?: string | null + nextOnTrue?: string | null + nextOnFalse?: string | null + nextOnApprove?: string | null + nextOnReject?: string | null + positionX: number + positionY: number + compareOutcomes: CompareOutcomeDto[] } export interface LayoutDto { diff --git a/ui/src/services/workflow.service.ts b/ui/src/services/workflow.service.ts index b7a1b2c..19934f1 100644 --- a/ui/src/services/workflow.service.ts +++ b/ui/src/services/workflow.service.ts @@ -32,10 +32,19 @@ export interface WorkflowCriteriaDto { compareOutcomes: CompareOutcomeDto[] } -export interface WorkflowStateDto { +export interface WorkflowCriteriasDto { criteria: WorkflowCriteriaDto[] } +export interface WorkflowRunResultDto { + keys: unknown[] + currentNodeId?: string | null + currentNodeTitle?: string | null + currentNodeKind?: string | null + waitingApproval: boolean + completed: boolean +} + export type SaveCriteriaInput = Omit, 'id'> & { id?: string | null listFormCode: string @@ -44,10 +53,10 @@ export type SaveCriteriaInput = Omit, 'id'> & { const baseUrl = '/api/app/list-form-workflow' export const workflowService = { - async getState(listFormCode?: string) { - const response = await apiService.fetchData({ + async getCriteria(listFormCode?: string) { + const response = await apiService.fetchData({ method: 'GET', - url: `${baseUrl}/state`, + url: `${baseUrl}/criteria`, params: { listFormCode }, }) @@ -72,7 +81,7 @@ export const workflowService = { }, async resetDemo(listFormCode?: string) { - const response = await apiService.fetchData({ + const response = await apiService.fetchData({ method: 'POST', url: `${baseUrl}/reset-demo`, params: { listFormCode }, @@ -80,4 +89,30 @@ export const workflowService = { return response.data }, + + async startWorkflow(listFormCode: string, keys: unknown[]) { + const response = await apiService.fetchData({ + method: 'POST', + url: `${baseUrl}/start`, + data: { listFormCode, keys }, + }) + + return response.data + }, + + async decideWorkflow( + listFormCode: string, + keys: unknown[], + approved: boolean, + note?: string, + currentNodeId?: string, + ) { + const response = await apiService.fetchData({ + method: 'POST', + url: `${baseUrl}/decision`, + data: { listFormCode, keys, approved, note, currentNodeId }, + }) + + return response.data + }, } diff --git a/ui/src/utils/workflow/workflowHelpers.ts b/ui/src/utils/workflow/workflowHelpers.ts index fecece6..3cd8971 100644 --- a/ui/src/utils/workflow/workflowHelpers.ts +++ b/ui/src/utils/workflow/workflowHelpers.ts @@ -1,3 +1,4 @@ +import { useLocalization } from '../hooks/useLocalization' import { getNodeHeight, nodeSize } from './workflowConstants' import type { CompareOutcomeDto, @@ -311,7 +312,7 @@ export function emptyCriteria(kind = 'Compare', listFormCode = ''): WorkflowCrit listFormCode, kind, title: defaultTitle(kind), - compareColumn: 'Tutar', + compareColumn: 'Price', compareOperator: '>', compareValue: 5000, approver: '', @@ -347,19 +348,31 @@ export function toCriteriaForm(item: WorkflowCriteriaDto): WorkflowCriteriaForm export function normalizeCriteria(item: WorkflowCriteriaForm): SaveCriteriaInput { const sharedPerson = item.approver || '' + const compareOutcomes = (item.compareOutcomes || []) + .slice(0, 4) + .filter((outcome) => outcome.label?.trim()) + .map(normalizeCompareOutcome) + const firstCompareColumn = compareOutcomes + .flatMap((outcome) => outcome.conditions || []) + .find((condition) => condition.compareColumn)?.compareColumn return { - ...item, id: item.id || null, listFormCode: item.listFormCode || '', + kind: item.kind || 'Compare', + title: item.title || defaultTitle(item.kind || 'Compare'), + compareColumn: firstCompareColumn || item.compareColumn || 'Price', + compareOperator: item.compareOperator || '>', compareValue: Number(item.compareValue || 0), approver: sharedPerson, + nextOnStart: item.nextOnStart || '', + nextOnTrue: compareOutcomes[0]?.targetId || item.nextOnTrue || '', + nextOnFalse: compareOutcomes[1]?.targetId || item.nextOnFalse || '', + nextOnApprove: item.nextOnApprove || '', + nextOnReject: item.nextOnReject || '', positionX: Number(item.positionX || 32), positionY: Number(item.positionY || 150), - compareOutcomes: (item.compareOutcomes || []) - .slice(0, 4) - .filter((outcome) => outcome.label?.trim()) - .map(normalizeCompareOutcome), + compareOutcomes, } } @@ -367,27 +380,27 @@ export function defaultTitle(kind: string) { return ( { Start: 'İş Akışı Başlat', - Compare: 'Tutar > 5000 TL', - Approval: 'Onaylanacak Kişi', - Inform: 'Bilgilendirme Yapılacak Personel', - End: 'Akışı Bitir', + Compare: 'Karşılaştırma', + Approval: 'Onay', + Inform: 'Bilgilendirme', + End: 'İş Akışı Bitir', }[kind] ?? 'İş Akışı Adımı' ) } -export function emptyCompareOutcome1(label = 'Durum'): CompareOutcomeDto { +export function emptyCompareOutcome1(label = 'Durum', compareColumn = 'Price'): CompareOutcomeDto { return { label, targetId: '', - conditions: [{ compareColumn: 'Tutar', compareOperator: '>', compareValue: 5000 }], + conditions: [{ compareColumn, compareOperator: '>', compareValue: 5000 }], } } -export function emptyCompareOutcome2(label = 'Durum'): CompareOutcomeDto { +export function emptyCompareOutcome2(label = 'Durum', compareColumn = 'Price'): CompareOutcomeDto { return { label, targetId: '', - conditions: [{ compareColumn: 'Tutar', compareOperator: '<=', compareValue: 5000 }], + conditions: [{ compareColumn, compareOperator: '<=', compareValue: 5000 }], } } @@ -401,7 +414,7 @@ export function toCompareOutcomeForm( ? outcome.conditions : [ { - compareColumn: outcome.compareColumn || 'Tutar', + compareColumn: outcome.compareColumn || 'Price', compareOperator: outcome.compareOperator || '>', compareValue: outcome.compareValue || 0, }, @@ -411,7 +424,7 @@ export function toCompareOutcomeForm( label: outcome.label || '', targetId: outcome.targetId || '', conditions: conditions.map((condition) => ({ - compareColumn: condition.compareColumn || 'Tutar', + compareColumn: condition.compareColumn || 'Price', compareOperator: condition.compareOperator || '>', compareValue: condition.compareValue ?? 0, })), @@ -429,7 +442,7 @@ export function normalizeCompareOutcome( ? outcome.conditions : [ { - compareColumn: outcome.compareColumn || 'Tutar', + compareColumn: outcome.compareColumn || 'Price', compareOperator: outcome.compareOperator || '>', compareValue: outcome.compareValue || 0, }, @@ -437,7 +450,7 @@ export function normalizeCompareOutcome( ) .filter((condition) => condition.compareOperator && String(condition.compareValue ?? '') !== '') .map((condition) => ({ - compareColumn: condition.compareColumn || 'Tutar', + compareColumn: condition.compareColumn || 'Price', compareOperator: condition.compareOperator || '>', compareValue: Number(condition.compareValue || 0), })) @@ -488,9 +501,10 @@ export function criteriaSummary(item: WorkflowCriteriaDto) { .join(' / ') || '-' ) } - if (item.kind === 'Approval') return item.approver || '-' - if (item.kind === 'Inform') return item.approver || '-' - return item.title + if (item.kind === 'Approval' || item.kind === 'Inform') { + return `${item.title} ${item.approver ? `- ${item.approver}` : ''}` + } + return `${item.title} ${item.approver ? `- ${item.approver}` : ''}` } export function targetTitle(criteria: WorkflowCriteriaDto[], id?: string | null) { diff --git a/ui/src/views/admin/listForm/edit/FormTabWorkflow.tsx b/ui/src/views/admin/listForm/edit/FormTabWorkflow.tsx index 192a6f7..dc64943 100644 --- a/ui/src/views/admin/listForm/edit/FormTabWorkflow.tsx +++ b/ui/src/views/admin/listForm/edit/FormTabWorkflow.tsx @@ -70,7 +70,7 @@ export function FormTabWorkflow( ) const loadState = useCallback(async () => { - const data = await workflowService.getState(props.listFormCode) + const data = await workflowService.getCriteria(props.listFormCode) setCriteria(data.criteria) return data }, [props.listFormCode]) @@ -299,6 +299,7 @@ export function FormTabWorkflow( approvalUserFieldName: string(), approvalDateFieldName: string(), approvalStatusFieldName: string(), + approvalDescriptionFieldName: string(), }) const initialValues = useStoreState((s) => s.admin.lists.values) @@ -319,7 +320,7 @@ export function FormTabWorkflow(
-
+
+ + + + {({ field, form }: FieldProps) => ( + setField('approver', value)} @@ -227,7 +269,7 @@ export function WorkflowCriteria({ {(formValues.kind === 'Start' || formValues.kind === 'Inform') && ( - + {targetSelect( formValues.nextOnStart, (value) => setField('nextOnStart', value), @@ -238,14 +280,14 @@ export function WorkflowCriteria({ {formValues.kind === 'Approval' && ( <> - + {targetSelect( formValues.nextOnApprove, (value) => setField('nextOnApprove', value), true, )} - + {targetSelect( formValues.nextOnReject, (value) => setField('nextOnReject', value), @@ -260,10 +302,10 @@ export function WorkflowCriteria({
- Karşılaştırma durumları + {translate('::App.Listform.ListformField.CompareOutcomes')} {isLoadingSelectCommandColumns && ( - Sütunlar yükleniyor... + {translate('::App.Listform.ListformField.LoadingColumns')} )}
@@ -276,11 +318,12 @@ export function WorkflowCriteria({ ...(formValues.compareOutcomes || []), emptyCompareOutcome1( `Durum ${(formValues.compareOutcomes || []).length + 1}`, + defaultCompareColumn, ), ]) } > - Karşılaştırma Ekle + {translate('::App.Listform.ListformField.AddCompareOutcome')}
@@ -289,8 +332,8 @@ export function WorkflowCriteria({ (outcome: CompareOutcomeDto, index: number) => (
- Durum {index + 1} - Bağlantı + {translate('::App.Listform.ListformField.Status')} {index + 1} + {translate('::App.Listform.ListformField.Connection')}
@@ -322,7 +365,7 @@ export function WorkflowCriteria({ disabled={(formValues.compareOutcomes || []).length <= 2} onClick={() => removeCompareOutcome(index)} > - Sil + {translate('::Delete')}
@@ -381,7 +424,7 @@ export function WorkflowCriteria({ onClick={() => addCompareCondition(index)} className="flex-[1]" > - Ekle + {translate('::Insert')}
))} @@ -398,7 +441,7 @@ export function WorkflowCriteria({ @@ -418,6 +461,20 @@ export function WorkflowCriteria({ ) } +function isNumericDataType(dataType?: string | null) { + const normalized = (dataType || '').toLowerCase() + return [ + 'int', + 'decimal', + 'numeric', + 'money', + 'float', + 'real', + 'double', + 'number', + ].some((typeName) => normalized.includes(typeName)) +} + function SelectField({ options, value, diff --git a/ui/src/views/developerKit/SqlTableDesignerDialog.tsx b/ui/src/views/developerKit/SqlTableDesignerDialog.tsx index c056bb6..a6b3981 100644 --- a/ui/src/views/developerKit/SqlTableDesignerDialog.tsx +++ b/ui/src/views/developerKit/SqlTableDesignerDialog.tsx @@ -250,6 +250,23 @@ function colToSqlLine(col: ColumnDefinition, addComma = true): string { return ` [${col.columnName}] ${typeSql} ${nullPart}${defaultPart}${addComma ? ',' : ''}` } +function buildCreateIndexIfNotExistsSql( + fullTableName: string, + indexName: string, + isClustered: boolean, + colsSql: string, +): string[] { + const escapedFullTableName = fullTableName.replace(/'/g, "''") + const escapedIndexName = indexName.replace(/'/g, "''") + + return [ + `IF INDEXPROPERTY(OBJECT_ID(N'${escapedFullTableName}'), '${escapedIndexName}', 'IndexID') IS NULL`, + `BEGIN`, + ` CREATE ${isClustered ? 'CLUSTERED ' : ''}INDEX [${indexName}] ON ${fullTableName} (${colsSql});`, + `END`, + ] +} + function generateCreateTableSql( columns: ColumnDefinition[], settings: TableSettings, @@ -305,7 +322,12 @@ function generateCreateTableSql( } else { extraIndexLines.push(`-- 📋 Index: [${idx.indexName}]`) extraIndexLines.push( - `CREATE ${idx.isClustered ? 'CLUSTERED ' : ''}INDEX [${idx.indexName}] ON ${fullTableName} (${colsSql});`, + ...buildCreateIndexIfNotExistsSql( + fullTableName, + idx.indexName, + idx.isClustered, + colsSql, + ), ) } } @@ -715,7 +737,12 @@ function generateAlterTableSql( } else { lines.push(`-- 📋 Index: [${ix.indexName}]`) lines.push( - `CREATE ${ix.isClustered ? 'CLUSTERED ' : ''}INDEX [${ix.indexName}] ON ${fullTableName} (${colsSql});`, + ...buildCreateIndexIfNotExistsSql( + fullTableName, + ix.indexName, + ix.isClustered, + colsSql, + ), ) } lines.push('') diff --git a/ui/src/views/form/useFormData.tsx b/ui/src/views/form/useFormData.tsx index b01d051..9e83aea 100644 --- a/ui/src/views/form/useFormData.tsx +++ b/ui/src/views/form/useFormData.tsx @@ -253,7 +253,11 @@ const useGridData = (props: { name: i.dataField, editorType2: i.editorType2, editorType: - i.editorType2 == PlatformEditorTypes.dxGridBox ? 'dxDropDownBox' : i.editorType2, + i.editorType2 == PlatformEditorTypes.dxGridBox + ? 'dxDropDownBox' + : i.editorType2 == PlatformEditorTypes.dxImageUpload + ? undefined + : i.editorType2, colSpan: i.colSpan, isRequired: i.isRequired, editorOptions: { diff --git a/ui/src/views/list/Grid.tsx b/ui/src/views/list/Grid.tsx index 4ea56ba..4b1ad69 100644 --- a/ui/src/views/list/Grid.tsx +++ b/ui/src/views/list/Grid.tsx @@ -65,7 +65,7 @@ import { GridBoxEditorComponent } from './editors/GridBoxEditorComponent' import { ImageUploadEditorComponent } from './editors/ImageUploadEditorComponent' import { TagBoxEditorComponent } from './editors/TagBoxEditorComponent' import { useFilters } from './useFilters' -import { useToolbar } from './useToolbar' +import { updateWorkflowApprovalToolbarItems, useToolbar } from './useToolbar' import { ImportDashboard } from '@/components/importManager/ImportDashboard' import WidgetGroup from '@/components/ui/Widget/WidgetGroup' import { GridExtraFilterToolbar } from './GridExtraFilterToolbar' @@ -75,6 +75,7 @@ import { useListFormCustomDataSource } from './useListFormCustomDataSource' import { useListFormColumns } from './useListFormColumns' import { Loading } from '@/components/shared' import { useStoreState } from '@/store' +import { workflowService } from '@/services/workflow.service' interface GridProps { listFormCode: string @@ -87,6 +88,24 @@ interface GridProps { const statedGridPanelColor = 'rgba(50, 200, 200, 0.5)' // kullanici tanimli gridState ile islem gormus gridin paneline ait renk +const isTemporaryDxKey = (key: unknown) => typeof key === 'string' && key.startsWith('_DX_KEY_') + +const getPersistedInsertedKey = ( + e: DataGridTypes.RowInsertedEvent, + keyFieldName?: string, +) => { + const dataKey = keyFieldName ? e.data?.[keyFieldName] : undefined + if (dataKey !== undefined && dataKey !== null && !isTemporaryDxKey(dataKey)) { + return dataKey + } + + if (e.key !== undefined && e.key !== null && !isTemporaryDxKey(e.key)) { + return e.key + } + + return undefined +} + const Grid = (props: GridProps) => { const { listFormCode, searchParams, isSubForm, level, gridDto: extGridDto } = props const { translate } = useLocalization() @@ -208,9 +227,28 @@ const Grid = (props: GridProps) => { return grd.getSelectedRowsData() }, []) + const updateWorkflowApprovalButtons = useCallback( + (component?: any, selectedRowsData?: Record[]) => { + const grd = component ?? gridRef.current?.instance() + if (!grd) { + return + } + + updateWorkflowApprovalToolbarItems( + grd, + gridDto?.gridOptions.workflowDto, + selectedRowsData ?? grd.getSelectedRowsData(), + config?.currentUser, + ) + }, + [config?.currentUser, gridDto], + ) + const refreshData = useCallback(() => { - gridRef.current?.instance()?.refresh() - }, []) + const grd = gridRef.current?.instance() + const refreshResult = grd?.refresh() + Promise.resolve(refreshResult).finally(() => updateWorkflowApprovalButtons(grd)) + }, [updateWorkflowApprovalButtons]) const getFilter = useCallback(() => { const grd = gridRef.current?.instance() @@ -257,6 +295,8 @@ const Grid = (props: GridProps) => { } // SubForm'ları gösterebilmek için secili satiri formData'ya at + updateWorkflowApprovalButtons(grd, data.selectedRowsData) + if (data.selectedRowsData.length) { setFormData(data.selectedRowsData[0]) } @@ -401,25 +441,32 @@ const Grid = (props: GridProps) => { [gridDto, searchParams, extraFilters, getNextSequenceValue], ) - const onRowInserting = useCallback((e: DataGridTypes.RowInsertingEvent) => { - if (!gridDto?.columnFormats) { - e.data = setFormEditingExtraItemValues(e.data) - return - } - const allowedFields = gridDto.columnFormats.filter(f => f.allowAdding).map(f => f.fieldName) - const filteredData: any = {} - for (const key of allowedFields) { - if (e.data.hasOwnProperty(key) && key) { - filteredData[key] = e.data[key] + const onRowInserting = useCallback( + (e: DataGridTypes.RowInsertingEvent) => { + if (!gridDto?.columnFormats) { + e.data = setFormEditingExtraItemValues(e.data) + return } - } - e.data = setFormEditingExtraItemValues(filteredData) - }, [gridDto]) + const allowedFields = gridDto.columnFormats + .filter((f) => f.allowAdding) + .map((f) => f.fieldName) + const filteredData: any = {} + for (const key of allowedFields) { + if (e.data.hasOwnProperty(key) && key) { + filteredData[key] = e.data[key] + } + } + e.data = setFormEditingExtraItemValues(filteredData) + }, + [gridDto], + ) const onRowUpdating = useCallback( (e: DataGridTypes.RowUpdatingEvent) => { if (!gridDto?.columnFormats) return - const allowedFields = gridDto.columnFormats.filter(f => f.allowEditing).map(f => f.fieldName) + const allowedFields = gridDto.columnFormats + .filter((f) => f.allowEditing) + .map((f) => f.fieldName) let newData = { ...e.oldData, ...e.newData } // Remove keys not allowed Object.keys(newData).forEach((key) => { @@ -452,7 +499,7 @@ const Grid = (props: GridProps) => { if (!e.data[field[0]]) { return } - + const json = JSON.parse(e.data[field[0]]) e.data[col.dataField] = json[field[1]] }) @@ -521,6 +568,19 @@ const Grid = (props: GridProps) => { const formItem = gridDto.gridOptions.editingFormDto .flatMap((group) => group.items || []) .find((i) => i.dataField === editor.dataField) + const fieldName = editor.dataField.split(':')[0] + const columnFormat = gridDto.columnFormats.find((column) => column.fieldName === fieldName) + const isNewRow = Boolean((editor as any).row?.isNewRow) || mode === 'new' + + if ( + (isNewRow && columnFormat?.allowAdding === false) || + (!isNewRow && columnFormat?.allowEditing === false) + ) { + editor.editorOptions.readOnly = true + } else if (isNewRow && columnFormat?.allowAdding === true) { + editor.editorOptions.readOnly = false + editor.editorOptions.disabled = false + } // Cascade mantığı const cascadeInfo = cascadeFieldsMap.get(editor.dataField) @@ -1192,6 +1252,7 @@ const Grid = (props: GridProps) => { showColumnHeaders={gridDto.gridOptions.columnOptionDto?.showColumnHeaders} filterSyncEnabled={true} onSelectionChanged={onSelectionChanged} + onContentReady={(e) => updateWorkflowApprovalButtons(e.component)} onInitNewRow={onInitNewRow} onCellPrepared={onCellPrepared} onRowInserting={onRowInserting} @@ -1212,7 +1273,18 @@ const Grid = (props: GridProps) => { setMode('view') setIsPopupFullScreen(false) }} - onRowInserted={() => { + onRowInserted={(e) => { + const insertedKey = getPersistedInsertedKey(e, gridDto.gridOptions.keyFieldName) + + if ( + gridDto.gridOptions.workflowDto?.approvalStatusFieldName && + insertedKey !== undefined + ) { + workflowService + .startWorkflow(listFormCode, [insertedKey]) + .then(() => gridRef.current?.instance()?.refresh()) + .catch(console.error) + } props.refreshData?.() }} onRowUpdated={() => { @@ -1342,9 +1414,9 @@ const Grid = (props: GridProps) => { if (mode === 'view') { return a.canRead } else if (mode === 'new') { - return (a.canCreate || a.canRead) && a.allowAdding + return a.canCreate && a.allowAdding } else if (mode === 'edit') { - return (a.canUpdate || a.canRead) && a.allowEditing + return a.canUpdate && a.allowEditing } else { return false } @@ -1371,9 +1443,9 @@ const Grid = (props: GridProps) => { if (mode === 'view') { return a.canRead } else if (mode === 'new') { - return (a.canCreate || a.canRead) && a.allowAdding + return a.canCreate && a.allowAdding } else if (mode === 'edit') { - return (a.canUpdate || a.canRead) && a.allowEditing + return a.canUpdate && a.allowEditing } else { return false } diff --git a/ui/src/views/list/Tree.tsx b/ui/src/views/list/Tree.tsx index f5b11b7..f524e19 100644 --- a/ui/src/views/list/Tree.tsx +++ b/ui/src/views/list/Tree.tsx @@ -55,7 +55,7 @@ import { import { GridBoxEditorComponent } from './editors/GridBoxEditorComponent' import { TagBoxEditorComponent } from './editors/TagBoxEditorComponent' import { useFilters } from './useFilters' -import { useToolbar } from './useToolbar' +import { updateWorkflowApprovalToolbarItems, useToolbar } from './useToolbar' import WidgetGroup from '@/components/ui/Widget/WidgetGroup' import { GridExtraFilterToolbar } from './GridExtraFilterToolbar' import { getList } from '@/services/form.service' @@ -66,6 +66,7 @@ import { DataType } from 'devextreme/common' import { useStoreState } from '@/store/store' import SubForms from '../form/SubForms' import { ImportDashboard } from '@/components/importManager/ImportDashboard' +import { workflowService } from '@/services/workflow.service' interface TreeProps { listFormCode: string @@ -78,6 +79,21 @@ interface TreeProps { const statedGridPanelColor = 'rgba(50, 200, 200, 0.5)' +const isTemporaryDxKey = (key: unknown) => typeof key === 'string' && key.startsWith('_DX_KEY_') + +const getPersistedInsertedKey = (e: any, keyFieldName?: string) => { + const dataKey = keyFieldName ? e.data?.[keyFieldName] : undefined + if (dataKey !== undefined && dataKey !== null && !isTemporaryDxKey(dataKey)) { + return dataKey + } + + if (e.key !== undefined && e.key !== null && !isTemporaryDxKey(e.key)) { + return e.key + } + + return undefined +} + const Tree = (props: TreeProps) => { const { listFormCode, searchParams, isSubForm, level, gridDto: extGridDto } = props const { translate } = useLocalization() @@ -246,9 +262,28 @@ const Tree = (props: TreeProps) => { }) }, []) + const updateWorkflowApprovalButtons = useCallback( + (component?: any, selectedRowsData?: Record[]) => { + const tree = component ?? gridRef.current?.instance() + if (!tree) { + return + } + + updateWorkflowApprovalToolbarItems( + tree, + gridDto?.gridOptions.workflowDto, + selectedRowsData ?? tree.getSelectedRowsData(), + config?.currentUser, + ) + }, + [config?.currentUser, gridDto], + ) + const refreshData = useCallback(() => { - gridRef.current?.instance().refresh() - }, []) + const tree = gridRef.current?.instance() + const refreshResult = tree?.refresh() + Promise.resolve(refreshResult).finally(() => updateWorkflowApprovalButtons(tree)) + }, [updateWorkflowApprovalButtons]) const getFilter = useCallback(() => { const tree = gridRef.current?.instance() @@ -300,6 +335,8 @@ const Tree = (props: TreeProps) => { } } + updateWorkflowApprovalButtons(tree, data.selectedRowsData) + if (data.selectedRowsData.length) { setFormData(data.selectedRowsData[0]) } @@ -480,6 +517,19 @@ const Tree = (props: TreeProps) => { const formItem = gridDto.gridOptions.editingFormDto .flatMap((group) => group.items || []) .find((i) => i.dataField === editor.dataField) + const fieldName = editor.dataField.split(':')[0] + const columnFormat = gridDto.columnFormats.find((column) => column.fieldName === fieldName) + const isNewRow = Boolean((editor as any).row?.isNewRow) || mode === 'new' + + if ( + (isNewRow && columnFormat?.allowAdding === false) || + (!isNewRow && columnFormat?.allowEditing === false) + ) { + editor.editorOptions.readOnly = true + } else if (isNewRow && columnFormat?.allowAdding === true) { + editor.editorOptions.readOnly = false + editor.editorOptions.disabled = false + } // Cascade disabled mantığı const colFormat = gridDto.columnFormats.find((c) => c.fieldName === editor.dataField) @@ -878,7 +928,18 @@ const Tree = (props: TreeProps) => { setMode('view') setIsPopupFullScreen(false) }} - onRowInserted={() => { + onRowInserted={(e) => { + const insertedKey = getPersistedInsertedKey(e, gridDto.gridOptions.keyFieldName) + + if ( + gridDto.gridOptions.workflowDto?.approvalStatusFieldName && + insertedKey !== undefined + ) { + workflowService + .startWorkflow(listFormCode, [insertedKey]) + .then(() => gridRef.current?.instance()?.refresh()) + .catch(console.error) + } props.refreshData?.() }} onRowUpdated={() => { @@ -889,6 +950,8 @@ const Tree = (props: TreeProps) => { }} onEditorPreparing={onEditorPreparing} onContentReady={(e) => { + updateWorkflowApprovalButtons(e.component) + // Restore expanded keys after data refresh (only if autoExpandAll is false) if ( !gridDto.gridOptions.treeOptionDto?.autoExpandAll && @@ -1117,9 +1180,9 @@ const Tree = (props: TreeProps) => { if (mode === 'view') { return a.canRead } else if (mode === 'new') { - return a.canCreate || a.canRead + return a.canCreate && a.allowAdding } else if (mode === 'edit') { - return a.canUpdate || a.canRead + return a.canUpdate && a.allowEditing } else { return false } @@ -1146,9 +1209,9 @@ const Tree = (props: TreeProps) => { if (mode === 'view') { return a.canRead } else if (mode === 'new') { - return a.canCreate || a.canRead + return a.canCreate && a.allowAdding } else if (mode === 'edit') { - return a.canUpdate || a.canRead + return a.canUpdate && a.allowEditing } else { return false } diff --git a/ui/src/views/list/useListFormColumns.ts b/ui/src/views/list/useListFormColumns.ts index 028adce..25f86c0 100644 --- a/ui/src/views/list/useListFormColumns.ts +++ b/ui/src/views/list/useListFormColumns.ts @@ -691,7 +691,7 @@ const useListFormColumns = ({ } } - column.allowEditing = colData?.allowEditing + column.allowEditing = colData?.allowEditing || colData?.allowAdding // #region lookup ayarlari if (colData.lookupDto?.dataSourceType) { diff --git a/ui/src/views/list/useListFormCustomDataSource.ts b/ui/src/views/list/useListFormCustomDataSource.ts index 19a66d9..f37020c 100644 --- a/ui/src/views/list/useListFormCustomDataSource.ts +++ b/ui/src/views/list/useListFormCustomDataSource.ts @@ -15,6 +15,34 @@ import { CardViewRef, CardViewTypes } from 'devextreme-react/cjs/card-view' const filteredGridPanelColor = 'rgba(10, 200, 10, 0.5)' // kullanici tanimli filtre ile filtrelenmis gridin paneline ait renk +const toInsertedRowData = (values: any, responseData: any, keyFieldName?: string | null) => { + if (!keyFieldName) { + return responseData ?? values + } + + if (responseData && typeof responseData === 'object' && !Array.isArray(responseData)) { + if (responseData[keyFieldName] !== undefined && responseData[keyFieldName] !== null) { + return { ...values, ...responseData } + } + + if ( + responseData.data && + typeof responseData.data === 'object' && + !Array.isArray(responseData.data) && + responseData.data[keyFieldName] !== undefined && + responseData.data[keyFieldName] !== null + ) { + return { ...values, ...responseData.data } + } + } + + if (responseData !== undefined && responseData !== null) { + return { ...values, [keyFieldName]: responseData } + } + + return values +} + const useListFormCustomDataSource = ({ gridRef, }: { @@ -372,7 +400,10 @@ const useListFormCustomDataSource = ({ } const insertUrl = getServiceAddress(gridOptions.insertServiceAddress) - return dynamicFetch(insertUrl, 'POST', searchParams, { data: values, listFormCode }) + return dynamicFetch(insertUrl, 'POST', searchParams, { data: values, listFormCode }).then( + (response: any) => + toInsertedRowData(values, response?.data, gridOptions.keyFieldName), + ) }, errorHandler: (error: any) => { console.log(error.message) diff --git a/ui/src/views/list/useToolbar.tsx b/ui/src/views/list/useToolbar.tsx index 30574d4..87c98f6 100644 --- a/ui/src/views/list/useToolbar.tsx +++ b/ui/src/views/list/useToolbar.tsx @@ -1,5 +1,5 @@ import { Button, Notification, toast } from '@/components/ui' -import { GridDto, UiCommandButtonPositionTypeEnum } from '@/proxy/form/models' +import { GridDto, UiCommandButtonPositionTypeEnum, WorkflowDto } from '@/proxy/form/models' import { dynamicFetch } from '@/services/form.service' import { useLocalization } from '@/utils/hooks/useLocalization' import { usePermission } from '@/utils/hooks/usePermission' @@ -10,6 +10,7 @@ import { useDialogContext } from '../shared/DialogContext' import { usePWA } from '@/utils/hooks/usePWA' import { layoutTypes, ListViewLayoutType } from '../admin/listForm/edit/types' import { useStoreState } from '@/store' +import { workflowService } from '@/services/workflow.service' type ToolbarModalData = { open: boolean @@ -51,7 +52,6 @@ const useToolbar = ({ const [toolbarData, setToolbarData] = useState([]) const [toolbarModalData, setToolbarModalData] = useState() - const grdOpt = gridDto?.gridOptions function getToolbarData() { @@ -112,6 +112,136 @@ const useToolbar = ({ location: 'after', }) + const workflowOptions = grdOpt.workflowDto + const approvalCriteria = workflowOptions?.criteria?.filter((item) => item.kind === 'Approval') ?? [] + if ( + workflowOptions?.approvalStatusFieldName && + approvalCriteria.length > 0 && + grdOpt.updateServiceAddress + ) { + items.push({ + widget: 'dxButton', + name: 'workflowStart', + location: 'after', + options: { + icon: 'play', + text: 'Workflow Start', + hint: 'Workflow Start', + visible: true, + disabled: true, + onClick: async () => { + const keys = (await Promise.resolve(getSelectedRowKeys() as any)) as unknown[] + if (!keys?.length) { + toast.push( + + {translate('::ListForms.ListForm.SelectRecord')} + , + { placement: 'top-end' }, + ) + return + } + + const selectedRows = ((await Promise.resolve(getSelectedRowsData() as any)) || + []) as Record[] + if ( + selectedRows.length === 0 || + !selectedRows.every((row) => isWorkflowNotStarted(row, workflowOptions)) + ) { + toast.push( + + Secili kayit icin workflow zaten baslamis. + , + { placement: 'top-end' }, + ) + return + } + + try { + await workflowService.startWorkflow(listFormCode, keys) + refreshData() + } catch (error: any) { + toast.push( + + {error?.response?.data?.error?.message || + error?.response?.data?.message || + error?.message || + 'Workflow baslatilamadi.'} + , + { placement: 'top-end' }, + ) + } + }, + }, + }) + + approvalCriteria.forEach((criteria) => { + items.push({ + widget: 'dxButton', + name: `workflowApproval_${criteria.id}`, + location: 'after', + options: { + icon: 'check', + text: criteria.title, + hint: criteria.title, + visible: true, + disabled: true, + onClick: async () => { + const keys = (await Promise.resolve(getSelectedRowKeys() as any)) as unknown[] + if (!keys?.length) { + toast.push( + + {translate('::ListForms.ListForm.SelectRecord')} + , + { placement: 'top-end' }, + ) + return + } + + const selectedRows = ((await Promise.resolve(getSelectedRowsData() as any)) || + []) as Record[] + const activeRows = selectedRows.filter((row) => + isWorkflowApprovalCriteriaActive( + row, + workflowOptions, + criteria.title, + getCurrentUserWorkflowIdentities(config?.currentUser), + ), + ) + + if (activeRows.length !== selectedRows.length) { + toast.push( + + Secili kayit bu onay adiminda veya onay kullanicisinda beklemiyor. + , + { placement: 'top-end' }, + ) + return + } + + setToolbarModalData({ + open: true, + content: ( + <> + setToolbarModalData(undefined)} + onCompleted={() => { + refreshData() + setToolbarModalData(undefined) + }} + /> + + ), + }) + }, + }, + }) + }) + } + // Add Expand All button for TreeList if (layout === layoutTypes.tree && grdOpt.treeOptionDto?.parentIdExpr) { items.push({ @@ -365,4 +495,182 @@ const useToolbar = ({ } } +function isWorkflowApprovalCriteriaActive( + row: Record, + workflowOptions: WorkflowDto, + criteriaTitle: string, + currentUserIdentities: string[] = [], +) { + if (!workflowOptions.approvalStatusFieldName || !criteriaTitle) { + return false + } + + const statusMatches = + normalizeWorkflowValue(row?.[workflowOptions.approvalStatusFieldName]) === + normalizeWorkflowValue(criteriaTitle) + + if (!statusMatches) { + return false + } + + if (!workflowOptions.approvalUserFieldName) { + return true + } + + const approver = normalizeWorkflowValue(row?.[workflowOptions.approvalUserFieldName]) + return currentUserIdentities.some((identity) => normalizeWorkflowValue(identity) === approver) +} + +function normalizeWorkflowValue(value: unknown) { + return String(value ?? '').trim().toLocaleLowerCase('tr-TR') +} + +function isWorkflowNotStarted(row: Record, workflowOptions: WorkflowDto) { + return normalizeWorkflowValue(row?.[workflowOptions.approvalStatusFieldName]) === '' +} + +function getCurrentUserWorkflowIdentities(currentUser?: { + userName?: string + email?: string + name?: string +}) { + return [currentUser?.email, currentUser?.userName, currentUser?.name].filter(Boolean) as string[] +} + +export function updateWorkflowApprovalToolbarItems( + component: any, + workflowOptions: WorkflowDto | undefined, + selectedRowsData: Record[] = [], + currentUser?: { + userName?: string + email?: string + name?: string + }, +) { + const approvalCriteria = workflowOptions?.criteria?.filter((item) => item.kind === 'Approval') ?? [] + if (!component || !workflowOptions?.approvalStatusFieldName || !approvalCriteria.length) { + return + } + + const toolbarOptions = component.option('toolbar') + if (!toolbarOptions?.items || !Array.isArray(toolbarOptions.items)) { + return + } + + const workflowStartItemIndex = toolbarOptions.items + .map((item: any) => item.name) + .indexOf('workflowStart') + + if (workflowStartItemIndex >= 0) { + const startEnabled = + selectedRowsData.length > 0 && + selectedRowsData.every((row) => isWorkflowNotStarted(row, workflowOptions)) + const startOptionPath = `toolbar.items[${workflowStartItemIndex}].options.disabled` + const nextStartDisabled = !startEnabled + if (component.option(startOptionPath) !== nextStartDisabled) { + component.option(startOptionPath, nextStartDisabled) + } + } + + const currentUserIdentities = getCurrentUserWorkflowIdentities(currentUser) + + approvalCriteria.forEach((criteria) => { + const toolbarItemIndex = toolbarOptions.items + .map((item: any) => item.name) + .indexOf(`workflowApproval_${criteria.id}`) + + if (toolbarItemIndex < 0) { + return + } + + const enabled = + selectedRowsData.length > 0 && + selectedRowsData.every((row) => + isWorkflowApprovalCriteriaActive(row, workflowOptions, criteria.title, currentUserIdentities), + ) + + const optionPath = `toolbar.items[${toolbarItemIndex}].options.disabled` + const nextDisabled = !enabled + if (component.option(optionPath) !== nextDisabled) { + component.option(optionPath, nextDisabled) + } + }) +} + +function WorkflowApprovalDecisionDialog({ + criteriaTitle, + keys, + listFormCode, + criteriaId, + onCancel, + onCompleted, +}: { + criteriaTitle: string + keys: unknown[] + listFormCode: string + criteriaId: string + onCancel: () => void + onCompleted: () => void +}) { + const { translate } = useLocalization() + const [note, setNote] = useState('') + const [submitting, setSubmitting] = useState(false) + + const decide = async (approved: boolean) => { + setSubmitting(true) + try { + await Promise.all( + keys.map((key) => + workflowService.decideWorkflow( + listFormCode, + [key], + approved, + note, + criteriaId, + ), + ), + ) + onCompleted() + } catch (error: any) { + toast.push( + + {error?.response?.data?.error?.message || + error?.response?.data?.message || + error?.message || + 'Workflow karari verilemedi.'} + , + { placement: 'top-end' }, + ) + } finally { + setSubmitting(false) + } + } + + return ( + <> +
{criteriaTitle}
+

{keys.length} kayit icin workflow karari verilecek.

+ +