From c204eef7555ce71f62a9119591893b825ca91f44 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sedat=20=C3=96zt=C3=BCrk?= Date: Sun, 7 Jun 2026 01:22:35 +0300 Subject: [PATCH] =?UTF-8?q?Workflow=20Listlerinin=20Note=20=C3=96zelli?= =?UTF-8?q?=C4=9Fi=20eklendi?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ListForms/ListFormWizardAppService.cs | 57 ++- .../ListForms/ListFormWorkflowAppService.cs | 188 ++++++++- .../Seeds/LanguagesData.json | 6 +- .../Seeds/SqlData/Sal_T_Approval.sql | 25 -- .../WizardData/202606062252_Approval.json | 384 ------------------ .../Seeds/WizardDataSeeder.cs | 57 ++- .../WizardConsts.cs | 2 +- .../Entities/Tenant/Saas/Note.cs | 8 + .../EntityFrameworkCore/PlatformDbContext.cs | 1 + ....cs => 20260606212623_Initial.Designer.cs} | 5 +- ...2_Initial.cs => 20260606212623_Initial.cs} | 6 + .../PlatformDbContextModelSnapshot.cs | 3 + ui/src/utils/workflow/workflowHelpers.ts | 85 ++++ .../admin/listForm/edit/FormTabWorkflow.tsx | 13 +- .../admin/listForm/wizard/WizardStep6.tsx | 21 +- ui/src/views/form/notes/NoteList.tsx | 7 +- ui/src/views/form/notes/NoteModal.tsx | 1 - ui/src/views/list/Grid.tsx | 31 ++ ui/src/views/list/Tree.tsx | 31 ++ ui/src/views/list/useListFormColumns.ts | 33 +- 20 files changed, 532 insertions(+), 432 deletions(-) delete mode 100644 api/src/Sozsoft.Platform.DbMigrator/Seeds/SqlData/Sal_T_Approval.sql delete mode 100644 api/src/Sozsoft.Platform.DbMigrator/Seeds/WizardData/202606062252_Approval.json rename api/src/Sozsoft.Platform.EntityFrameworkCore/Migrations/{20260602070242_Initial.Designer.cs => 20260606212623_Initial.Designer.cs} (99%) rename api/src/Sozsoft.Platform.EntityFrameworkCore/Migrations/{20260602070242_Initial.cs => 20260606212623_Initial.cs} (99%) diff --git a/api/src/Sozsoft.Platform.Application/ListForms/ListFormWizardAppService.cs b/api/src/Sozsoft.Platform.Application/ListForms/ListFormWizardAppService.cs index a4b8d15..8529036 100644 --- a/api/src/Sozsoft.Platform.Application/ListForms/ListFormWizardAppService.cs +++ b/api/src/Sozsoft.Platform.Application/ListForms/ListFormWizardAppService.cs @@ -278,6 +278,7 @@ public class ListFormWizardAppService( var isCreated = tableColumns.Contains("CreatorId"); input.Workflow ??= new WorkflowDto(); input.Workflow.Criteria = input.WorkflowCriteria; + EnsureUniqueWorkflowCriteriaTitles(input.WorkflowCriteria); var listForm = await repoListForm.InsertAsync(new ListForm { @@ -285,7 +286,7 @@ public class ListFormWizardAppService( PageSize = 10, ExportJson = WizardConsts.DefaultExportJson, IsSubForm = false, - ShowNote = input.SubForms.Count > 0, + ShowNote = input.SubForms.Count > 0 || input.WorkflowCriteria.Count > 0, LayoutJson = WizardConsts.DefaultLayoutJson(input.DefaultLayout, input.Grid, input.Pivot, input.Tree, input.Chart, input.Gantt, input.Scheduler), CultureName = LanguageCodes.En, ListFormCode = input.ListFormCode, @@ -423,6 +424,60 @@ public class ListFormWizardAppService( ); } + private static void EnsureUniqueWorkflowCriteriaTitles(List criteria) + { + if (criteria == null || criteria.Count == 0) + { + return; + } + + var baseTitles = criteria + .Select(x => string.IsNullOrWhiteSpace(x.Title) ? NormalizeWorkflowTitleFallback(x.Kind) : x.Title.Trim()) + .ToList(); + var duplicateTitles = baseTitles + .GroupBy(x => x, StringComparer.OrdinalIgnoreCase) + .Where(x => x.Count() > 1) + .Select(x => x.Key) + .ToHashSet(StringComparer.OrdinalIgnoreCase); + var titleCounts = new Dictionary(StringComparer.OrdinalIgnoreCase); + var usedTitles = new HashSet(StringComparer.OrdinalIgnoreCase); + + foreach (var item in criteria) + { + var baseTitle = string.IsNullOrWhiteSpace(item.Title) ? NormalizeWorkflowTitleFallback(item.Kind) : item.Title.Trim(); + var title = baseTitle; + + if (duplicateTitles.Contains(baseTitle)) + { + titleCounts.TryGetValue(baseTitle, out var count); + count++; + titleCounts[baseTitle] = count; + title = $"{baseTitle}{count}"; + } + + if (usedTitles.Contains(title)) + { + var index = 1; + var candidate = $"{title}{index}"; + while (usedTitles.Contains(candidate)) + { + index++; + candidate = $"{title}{index}"; + } + + title = candidate; + } + + item.Title = title; + usedTitles.Add(title); + } + } + + private static string NormalizeWorkflowTitleFallback(string kind) + { + return string.IsNullOrWhiteSpace(kind) ? "Step" : kind.Trim(); + } + /// /// Wizard konfigürasyonunu JSON dosyası olarak kaydeder. /// Önce ContentRootPath'ten yukarı çıkarak Sozsoft.Platform.DbMigrator/Seeds/WizardData dizinini arar. diff --git a/api/src/Sozsoft.Platform.Application/ListForms/ListFormWorkflowAppService.cs b/api/src/Sozsoft.Platform.Application/ListForms/ListFormWorkflowAppService.cs index 33d4edf..6c9d8be 100644 --- a/api/src/Sozsoft.Platform.Application/ListForms/ListFormWorkflowAppService.cs +++ b/api/src/Sozsoft.Platform.Application/ListForms/ListFormWorkflowAppService.cs @@ -29,6 +29,7 @@ public class ListFormWorkflowAppService : PlatformAppService, IListFormWorkflowA private const string SystemApprovalDescription = "Sistem tarafından otomatik olarak onaylandı."; private readonly IRepository criteriaRepository; + private readonly IRepository noteRepository; private readonly IListFormManager listFormManager; private readonly IListFormAuthorizationManager authManager; private readonly IListFormSelectAppService listFormSelectAppService; @@ -39,6 +40,7 @@ public class ListFormWorkflowAppService : PlatformAppService, IListFormWorkflowA public ListFormWorkflowAppService( IRepository criteriaRepository, + IRepository noteRepository, IListFormManager listFormManager, IListFormAuthorizationManager authManager, IListFormSelectAppService listFormSelectAppService, @@ -48,6 +50,7 @@ public class ListFormWorkflowAppService : PlatformAppService, IListFormWorkflowA ISettingProvider settingProvider) { this.criteriaRepository = criteriaRepository; + this.noteRepository = noteRepository; this.listFormManager = listFormManager; this.authManager = authManager; this.listFormSelectAppService = listFormSelectAppService; @@ -188,7 +191,7 @@ public class ListFormWorkflowAppService : PlatformAppService, IListFormWorkflowA criteria.ListFormCode = code; criteria.Kind = NormalizeRequired(input.Kind, "Compare"); - criteria.Title = NormalizeRequired(input.Title, criteria.Kind); + criteria.Title = await NormalizeUniqueTitleAsync(code, criteria.Id, input.Kind, input.Title); criteria.CompareColumn = NormalizeRequired(input.CompareColumn, "Price"); criteria.CompareOperator = NormalizeRequired(input.CompareOperator, ">"); criteria.CompareValue = input.CompareValue; @@ -365,6 +368,7 @@ public class ListFormWorkflowAppService : PlatformAppService, IListFormWorkflowA } var next = FindNextCriteria(context.Criteria, input.Approved ? current.NextOnApprove : current.NextOnReject); + await LogWorkflowDecisionAsync(context, current, next, input.Approved, input.Note); return await RunUntilWaitAsync(context, next); } @@ -512,13 +516,16 @@ public class ListFormWorkflowAppService : PlatformAppService, IListFormWorkflowA await UpdateRowAsync(context, update); MergeRowValues(context.Row, update); + string informedRecipient = null; if (node.Kind == "Inform") { - await SendInformEmailAsync(context, node); + informedRecipient = await SendInformEmailAsync(context, node); } + + await LogWorkflowNodeAsync(context, node, informedRecipient); } - private async Task SendInformEmailAsync(WorkflowRunContext context, ListFormWorkflow node) + private async Task SendInformEmailAsync(WorkflowRunContext context, ListFormWorkflow node) { var recipientEmail = await ResolveApproverEmailAsync(node.Approver); var senderName = await settingProvider.GetOrNullAsync(SeedConsts.AbpSettings.Mailing.Default.DefaultFromDisplayName); @@ -542,6 +549,8 @@ public class ListFormWorkflowAppService : PlatformAppService, IListFormWorkflowA { throw new UserFriendlyException($"Bilgilendirme maili gonderilemedi: {result.ErrorMessage}"); } + + return recipientEmail; } private async Task ResolveApproverEmailAsync(string approver) @@ -581,6 +590,149 @@ public class ListFormWorkflowAppService : PlatformAppService, IListFormWorkflowA """; } + private async Task LogWorkflowDecisionAsync( + WorkflowRunContext context, + ListFormWorkflow current, + ListFormWorkflow next, + bool approved, + string description) + { + var action = approved ? "Approved" : "Rejected"; + var subject = $"Workflow {action}: {current.Title}"; + var rows = new List<(string Label, string Value)>(); + + rows.Add(("Description", description ?? string.Empty)); + rows.Add(("Next Step", FormatNode(next))); + + await InsertWorkflowNoteAsync(context, subject, BuildWorkflowNoteContent(rows)); + } + + private async Task LogWorkflowNodeAsync( + WorkflowRunContext context, + ListFormWorkflow node, + string informedRecipient) + { + var action = node.Kind switch + { + "Start" => "Started", + "Compare" => "Evaluated", + "Approval" => "Waiting Approval", + "Inform" => "Informed", + "End" => "Completed", + _ => "Processed" + }; + + var subject = $"Workflow {action}: {node.Title}"; + var rows = new List<(string Label, string Value)>(); + + if (!node.Approver.IsNullOrWhiteSpace()) + { + rows.Add((node.Kind == "Inform" ? "Inform Target" : "Approver", node.Approver)); + } + if (!informedRecipient.IsNullOrWhiteSpace()) + { + rows.Add(("Informed Email", informedRecipient)); + } + + await InsertWorkflowNoteAsync(context, subject, BuildWorkflowNoteContent(rows)); + } + + private async Task InsertWorkflowNoteAsync( + WorkflowRunContext context, + string subject, + string content) + { + var key = context.Keys?.FirstOrDefault(); + if (key == null) + { + return; + } + + var note = new Note(GuidGenerator.Create()) + { + TenantId = CurrentTenant.Id, + EntityName = context.ListFormCode, + EntityId = key.ToString(), + Type = "workflow", + Subject = subject, + Content = content, + FilesJson = "[]" + }; + + await noteRepository.InsertAsync(note, autoSave: true); + } + + private static void AddWorkflowFieldRow( + List<(string Label, string Value)> rows, + WorkflowRunContext context, + string label, + string fieldName) + { + if (fieldName.IsNullOrWhiteSpace()) + { + return; + } + + rows.Add((label, FormatRowValue(GetRowValue(context.Row, fieldName)))); + } + + private static string BuildWorkflowNoteContent(List<(string Label, string Value)> rows) + { + var tableRows = rows + .Where(row => !row.Value.IsNullOrWhiteSpace()) + .Select(row => + $"{Encode(row.Label)}{Encode(row.Value)}"); + + return $"{string.Join(string.Empty, tableRows)}
"; + } + + private string ResolveCurrentUserDisplayName() + { + return CurrentUser.UserName + ?? CurrentUser.Name + ?? CurrentUser.Id?.ToString() + ?? "System"; + } + + private static string FormatNode(ListFormWorkflow node) + { + if (node == null) + { + return string.Empty; + } + + var title = node.Title ?? string.Empty; + var kind = node.Kind ?? string.Empty; + return $"{title} ({kind} - {node.Id})"; + } + + private static string FormatRowValue(object value) + { + if (value == null || value == DBNull.Value) + { + return string.Empty; + } + + return value is DateTime dateTime + ? dateTime.ToString("yyyy-MM-dd HH:mm:ss") + : value.ToString(); + } + + private static string Encode(string value) + { + return WebUtility.HtmlEncode(value ?? string.Empty); + } + + private static string Truncate(string value, int maxLength) + { + if (value == null || value.Length <= maxLength) + { + return value; + } + + return value[..maxLength]; + } + private async Task UpdateRowAsync(WorkflowRunContext context, Dictionary data) { await queryManager.GenerateAndRunQueryAsync( @@ -906,6 +1058,36 @@ public class ListFormWorkflowAppService : PlatformAppService, IListFormWorkflowA return value.IsNullOrWhiteSpace() ? fallback : value.Trim(); } + private async Task NormalizeUniqueTitleAsync( + string listFormCode, + string criteriaId, + string kind, + string title) + { + var baseTitle = NormalizeRequired(title, kind); + var existingTitles = (await criteriaRepository.GetListAsync(x => + x.ListFormCode == listFormCode && + x.Id != criteriaId)) + .Select(x => x.Title?.Trim()) + .Where(x => !x.IsNullOrWhiteSpace()) + .ToHashSet(StringComparer.OrdinalIgnoreCase); + + if (!existingTitles.Contains(baseTitle)) + { + return baseTitle; + } + + var index = 1; + var candidate = $"{baseTitle}{index}"; + while (existingTitles.Contains(candidate)) + { + index++; + candidate = $"{baseTitle}{index}"; + } + + return candidate; + } + private static string SerializeCompareOutcomes(List outcomes) { return JsonSerializer.Serialize(outcomes ?? []); diff --git a/api/src/Sozsoft.Platform.DbMigrator/Seeds/LanguagesData.json b/api/src/Sozsoft.Platform.DbMigrator/Seeds/LanguagesData.json index 0a95c32..c7ec54e 100644 --- a/api/src/Sozsoft.Platform.DbMigrator/Seeds/LanguagesData.json +++ b/api/src/Sozsoft.Platform.DbMigrator/Seeds/LanguagesData.json @@ -4154,9 +4154,9 @@ }, { "resourceName": "Platform", - "key": "ListForms.ListForm.NoteModal.Type.Activity", - "en": "Activity", - "tr": "Aktivite" + "key": "ListForms.ListForm.NoteModal.Type.Workflow", + "en": "Workflow", + "tr": "Akış" }, { "resourceName": "Platform", diff --git a/api/src/Sozsoft.Platform.DbMigrator/Seeds/SqlData/Sal_T_Approval.sql b/api/src/Sozsoft.Platform.DbMigrator/Seeds/SqlData/Sal_T_Approval.sql deleted file mode 100644 index eff1953..0000000 --- a/api/src/Sozsoft.Platform.DbMigrator/Seeds/SqlData/Sal_T_Approval.sql +++ /dev/null @@ -1,25 +0,0 @@ -IF OBJECT_ID(N'[dbo].[Sal_T_Approval]', 'U') IS NULL -BEGIN - CREATE TABLE [dbo].[Sal_T_Approval] - ( - [Id] uniqueidentifier NOT NULL DEFAULT NEWID(), - [CreationTime] datetime2 NOT NULL DEFAULT GETUTCDATE(), - [CreatorId] uniqueidentifier NULL, - [LastModificationTime] datetime2 NULL, - [LastModifierId] uniqueidentifier NULL, - [IsDeleted] bit NOT NULL DEFAULT 0, - [DeletionTime] datetime2 NULL, - [DeleterId] uniqueidentifier NULL, - [TenantId] uniqueidentifier NULL, - [ApprovalUserName] nvarchar(256) NULL, - [ApprovalStatus] nvarchar(50) NULL, - [ApprovalDate] datetime NULL, - [ApprovalDescription] nvarchar(200) NULL, - [Name] nvarchar(100) NULL, - CONSTRAINT [PK_Sal_T_Approval] PRIMARY KEY NONCLUSTERED - ( - [Id] ASC - )WITH (PAD_INDEX = OFF, STATISTICS_NORECOMPUTE = OFF, IGNORE_DUP_KEY = OFF, ALLOW_ROW_LOCKS = ON, ALLOW_PAGE_LOCKS = ON, OPTIMIZE_FOR_SEQUENTIAL_KEY = OFF) ON [PRIMARY] - ) ON [PRIMARY] -END -GO \ No newline at end of file diff --git a/api/src/Sozsoft.Platform.DbMigrator/Seeds/WizardData/202606062252_Approval.json b/api/src/Sozsoft.Platform.DbMigrator/Seeds/WizardData/202606062252_Approval.json deleted file mode 100644 index fd907ef..0000000 --- a/api/src/Sozsoft.Platform.DbMigrator/Seeds/WizardData/202606062252_Approval.json +++ /dev/null @@ -1,384 +0,0 @@ -{ - "Wizard": { - "WizardName": "Approval", - "ListFormCode": "App.Wizard.Approval", - "MenuCode": "App.Wizard.Approval", - "IsTenant": true, - "IsBranch": false, - "IsOrganizationUnit": false, - "AllowAdding": true, - "AllowUpdating": true, - "AllowDeleting": true, - "AllowDetail": false, - "ConfirmDelete": true, - "DefaultLayout": "grid", - "Grid": true, - "Pivot": true, - "Tree": true, - "Chart": true, - "Gantt": true, - "Scheduler": true, - "LanguageTextMenuEn": "Approval", - "LanguageTextMenuTr": "Approval", - "LanguageTextTitleEn": "Approval", - "LanguageTextTitleTr": "Approval", - "LanguageTextDescEn": "Approval", - "LanguageTextDescTr": "Approval", - "LanguageTextMenuParentEn": "", - "LanguageTextMenuParentTr": "", - "PermissionGroupName": "App.Wizard.Sales", - "MenuParentCode": "App.Wizard.Sales", - "MenuParentIcon": "FcAssistant", - "MenuIcon": "FcBrokenLink", - "DataSourceCode": "Default", - "DataSourceConnectionString": "", - "SelectCommandType": 1, - "SelectCommand": "Sal_T_Approval", - "KeyFieldName": "Id", - "KeyFieldDbSourceType": 9, - "TreeKeyExpr": "", - "TreeParentIdExpr": "", - "TreeAutoExpandAll": false, - "GanttKeyExpr": "", - "GanttParentIdExpr": "", - "GanttAutoExpandAll": false, - "GanttTitleExpr": "", - "GanttStartExpr": "", - "GanttEndExpr": "", - "GanttProgressExpr": "", - "SchedulerTextExpr": "", - "SchedulerStartDateExpr": "", - "SchedulerEndDateExpr": "", - "Groups": [ - { - "Caption": "", - "ColCount": 1, - "Items": [ - { - "DataField": "Id", - "CaptionName": "App.Listform.ListformField.Id", - "EditorType": "dxTextBox", - "EditorOptions": "", - "EditorScript": "", - "ColSpan": 1, - "IsRequired": true, - "IncludeInEditingForm": true, - "DbSourceType": 9, - "TurkishCaption": "Id", - "EnglishCaption": "Id", - "LookupDataSourceType": 1, - "ValueExpr": "Key", - "DisplayExpr": "Name", - "LookupQuery": "" - }, - { - "DataField": "Name", - "CaptionName": "App.Listform.ListformField.Name", - "EditorType": "dxTextBox", - "EditorOptions": "", - "EditorScript": "", - "ColSpan": 1, - "IsRequired": false, - "IncludeInEditingForm": true, - "DbSourceType": 16, - "TurkishCaption": "Name", - "EnglishCaption": "Name", - "LookupDataSourceType": 1, - "ValueExpr": "Key", - "DisplayExpr": "Name", - "LookupQuery": "" - }, - { - "DataField": "ApprovalUserName", - "CaptionName": "App.Listform.ListformField.ApprovalUserName", - "EditorType": "dxTextBox", - "EditorOptions": "", - "EditorScript": "", - "ColSpan": 1, - "IsRequired": false, - "IncludeInEditingForm": false, - "DbSourceType": 16, - "TurkishCaption": "Approval User Name", - "EnglishCaption": "Approval User Name", - "LookupDataSourceType": 1, - "ValueExpr": "Key", - "DisplayExpr": "Name", - "LookupQuery": "" - }, - { - "DataField": "ApprovalStatus", - "CaptionName": "App.Listform.ListformField.ApprovalStatus", - "EditorType": "dxTextBox", - "EditorOptions": "", - "EditorScript": "", - "ColSpan": 1, - "IsRequired": false, - "IncludeInEditingForm": false, - "DbSourceType": 16, - "TurkishCaption": "Approval Status", - "EnglishCaption": "Approval Status", - "LookupDataSourceType": 1, - "ValueExpr": "Key", - "DisplayExpr": "Name", - "LookupQuery": "" - }, - { - "DataField": "ApprovalDate", - "CaptionName": "App.Listform.ListformField.ApprovalDate", - "EditorType": "dxDateBox", - "EditorOptions": "", - "EditorScript": "", - "ColSpan": 1, - "IsRequired": false, - "IncludeInEditingForm": false, - "DbSourceType": 6, - "TurkishCaption": "Approval Date", - "EnglishCaption": "Approval Date", - "LookupDataSourceType": 1, - "ValueExpr": "Key", - "DisplayExpr": "Name", - "LookupQuery": "" - }, - { - "DataField": "ApprovalDescription", - "CaptionName": "App.Listform.ListformField.ApprovalDescription", - "EditorType": "dxTextBox", - "EditorOptions": "", - "EditorScript": "", - "ColSpan": 1, - "IsRequired": false, - "IncludeInEditingForm": false, - "DbSourceType": 16, - "TurkishCaption": "Approval Description", - "EnglishCaption": "Approval Description", - "LookupDataSourceType": 1, - "ValueExpr": "Key", - "DisplayExpr": "Name", - "LookupQuery": "" - } - ] - } - ], - "SubForms": [], - "Widgets": [], - "Workflow": { - "ApprovalUserFieldName": "ApprovalUserName", - "ApprovalDateFieldName": "ApprovalDate", - "ApprovalStatusFieldName": "ApprovalStatus", - "ApprovalDescriptionFieldName": "ApprovalDescription", - "Criteria": [ - { - "ListFormCode": "App.Wizard.Approval", - "Kind": "Start", - "Title": "\u0130\u015F Ak\u0131\u015F\u0131 Ba\u015Flat", - "CompareColumn": "Price", - "CompareOperator": "\u003E", - "CompareValue": 5000, - "Approver": "", - "NextOnStart": "WF1780768059929317", - "NextOnTrue": "", - "NextOnFalse": "", - "NextOnApprove": "", - "NextOnReject": "", - "PositionX": 53, - "PositionY": 42, - "CompareOutcomes": [], - "Id": "WF1780768057089996" - }, - { - "ListFormCode": "App.Wizard.Approval", - "Kind": "Approval", - "Title": "Onay1", - "CompareColumn": "Price", - "CompareOperator": "\u003E", - "CompareValue": 5000, - "Approver": "admin@sozsoft.com", - "NextOnStart": "", - "NextOnTrue": "", - "NextOnFalse": "", - "NextOnApprove": "WF1780768062793138", - "NextOnReject": "WF1780768065817524", - "PositionX": 356, - "PositionY": 41, - "CompareOutcomes": [], - "Id": "WF1780768059929317" - }, - { - "ListFormCode": "App.Wizard.Approval", - "Kind": "Approval", - "Title": "Onay2", - "CompareColumn": "Price", - "CompareOperator": "\u003E", - "CompareValue": 5000, - "Approver": "demo@sozsoft.com", - "NextOnStart": "", - "NextOnTrue": "", - "NextOnFalse": "", - "NextOnApprove": "WF1780768065817524", - "NextOnReject": "WF1780768065817524", - "PositionX": 632, - "PositionY": 38, - "CompareOutcomes": [], - "Id": "WF1780768062793138" - }, - { - "ListFormCode": "App.Wizard.Approval", - "Kind": "Inform", - "Title": "Bilgilendirme", - "CompareColumn": "Price", - "CompareOperator": "\u003E", - "CompareValue": 5000, - "Approver": "system@sozsoft.com", - "NextOnStart": "WF1780768071444394", - "NextOnTrue": "", - "NextOnFalse": "", - "NextOnApprove": "", - "NextOnReject": "", - "PositionX": 472, - "PositionY": 417, - "CompareOutcomes": [], - "Id": "WF1780768065817524" - }, - { - "ListFormCode": "App.Wizard.Approval", - "Kind": "End", - "Title": "\u0130\u015F Ak\u0131\u015F\u0131 Bitir", - "CompareColumn": "Price", - "CompareOperator": "\u003E", - "CompareValue": 5000, - "Approver": "", - "NextOnStart": "", - "NextOnTrue": "", - "NextOnFalse": "", - "NextOnApprove": "", - "NextOnReject": "", - "PositionX": 850, - "PositionY": 417, - "CompareOutcomes": [], - "Id": "WF1780768071444394" - } - ] - }, - "WorkflowCriteria": [ - { - "ListFormCode": "App.Wizard.Approval", - "Kind": "Start", - "Title": "\u0130\u015F Ak\u0131\u015F\u0131 Ba\u015Flat", - "CompareColumn": "Price", - "CompareOperator": "\u003E", - "CompareValue": 5000, - "Approver": "", - "NextOnStart": "WF1780768059929317", - "NextOnTrue": "", - "NextOnFalse": "", - "NextOnApprove": "", - "NextOnReject": "", - "PositionX": 53, - "PositionY": 42, - "CompareOutcomes": [], - "Id": "WF1780768057089996" - }, - { - "ListFormCode": "App.Wizard.Approval", - "Kind": "Approval", - "Title": "Onay1", - "CompareColumn": "Price", - "CompareOperator": "\u003E", - "CompareValue": 5000, - "Approver": "admin@sozsoft.com", - "NextOnStart": "", - "NextOnTrue": "", - "NextOnFalse": "", - "NextOnApprove": "WF1780768062793138", - "NextOnReject": "WF1780768065817524", - "PositionX": 356, - "PositionY": 41, - "CompareOutcomes": [], - "Id": "WF1780768059929317" - }, - { - "ListFormCode": "App.Wizard.Approval", - "Kind": "Approval", - "Title": "Onay2", - "CompareColumn": "Price", - "CompareOperator": "\u003E", - "CompareValue": 5000, - "Approver": "demo@sozsoft.com", - "NextOnStart": "", - "NextOnTrue": "", - "NextOnFalse": "", - "NextOnApprove": "WF1780768065817524", - "NextOnReject": "WF1780768065817524", - "PositionX": 632, - "PositionY": 38, - "CompareOutcomes": [], - "Id": "WF1780768062793138" - }, - { - "ListFormCode": "App.Wizard.Approval", - "Kind": "Inform", - "Title": "Bilgilendirme", - "CompareColumn": "Price", - "CompareOperator": "\u003E", - "CompareValue": 5000, - "Approver": "system@sozsoft.com", - "NextOnStart": "WF1780768071444394", - "NextOnTrue": "", - "NextOnFalse": "", - "NextOnApprove": "", - "NextOnReject": "", - "PositionX": 472, - "PositionY": 417, - "CompareOutcomes": [], - "Id": "WF1780768065817524" - }, - { - "ListFormCode": "App.Wizard.Approval", - "Kind": "End", - "Title": "\u0130\u015F Ak\u0131\u015F\u0131 Bitir", - "CompareColumn": "Price", - "CompareOperator": "\u003E", - "CompareValue": 5000, - "Approver": "", - "NextOnStart": "", - "NextOnTrue": "", - "NextOnFalse": "", - "NextOnApprove": "", - "NextOnReject": "", - "PositionX": 850, - "PositionY": 417, - "CompareOutcomes": [], - "Id": "WF1780768071444394" - } - ] - }, - "IsDeletedField": true, - "IsCreatedField": true, - "InsertedRecords": { - "LanguageKeys": [ - "App.Wizard.Approval", - "App.Wizard.Approval.Title", - "App.Wizard.Approval.Desc", - "App.Listform.ListformField.ApprovalUserName", - "App.Listform.ListformField.ApprovalStatus", - "App.Listform.ListformField.ApprovalDate", - "App.Listform.ListformField.ApprovalDescription" - ], - "PermissionGroupNames": [ - "App.Wizard.Sales" - ], - "PermissionNames": [ - "App.Wizard.Approval", - "App.Wizard.Approval.Create", - "App.Wizard.Approval.Update", - "App.Wizard.Approval.Delete", - "App.Wizard.Approval.Export", - "App.Wizard.Approval.Import", - "App.Wizard.Approval.Note" - ], - "MenuCodes": [ - "App.Wizard.Approval" - ], - "DataSourceCodes": [] - } -} \ No newline at end of file diff --git a/api/src/Sozsoft.Platform.DbMigrator/Seeds/WizardDataSeeder.cs b/api/src/Sozsoft.Platform.DbMigrator/Seeds/WizardDataSeeder.cs index 9ccbb18..a51e043 100644 --- a/api/src/Sozsoft.Platform.DbMigrator/Seeds/WizardDataSeeder.cs +++ b/api/src/Sozsoft.Platform.DbMigrator/Seeds/WizardDataSeeder.cs @@ -135,6 +135,7 @@ public class WizardDataSeeder : IDataSeedContributor, ITransientDependency input.Workflow ??= new WorkflowDto(); input.WorkflowCriteria ??= new List(); input.Workflow.Criteria = input.WorkflowCriteria; + EnsureUniqueWorkflowCriteriaTitles(input.WorkflowCriteria); var wizardName = input.WizardName.Trim(); var code = string.IsNullOrWhiteSpace(input.MenuCode) @@ -332,7 +333,7 @@ public class WizardDataSeeder : IDataSeedContributor, ITransientDependency PageSize = 10, ExportJson = WizardConsts.DefaultExportJson, IsSubForm = false, - ShowNote = input.SubForms.Count > 0, + ShowNote = input.SubForms.Count > 0 || input.WorkflowCriteria.Count > 0, LayoutJson = WizardConsts.DefaultLayoutJson(input.DefaultLayout, input.Grid, input.Pivot, input.Tree, input.Chart, input.Gantt, input.Scheduler), CultureName = LanguageCodes.En, ListFormCode = input.ListFormCode, @@ -415,6 +416,60 @@ public class WizardDataSeeder : IDataSeedContributor, ITransientDependency ); } + private static void EnsureUniqueWorkflowCriteriaTitles(List criteria) + { + if (criteria == null || criteria.Count == 0) + { + return; + } + + var baseTitles = criteria + .Select(x => string.IsNullOrWhiteSpace(x.Title) ? NormalizeWorkflowTitleFallback(x.Kind) : x.Title.Trim()) + .ToList(); + var duplicateTitles = baseTitles + .GroupBy(x => x, StringComparer.OrdinalIgnoreCase) + .Where(x => x.Count() > 1) + .Select(x => x.Key) + .ToHashSet(StringComparer.OrdinalIgnoreCase); + var titleCounts = new Dictionary(StringComparer.OrdinalIgnoreCase); + var usedTitles = new HashSet(StringComparer.OrdinalIgnoreCase); + + foreach (var item in criteria) + { + var baseTitle = string.IsNullOrWhiteSpace(item.Title) ? NormalizeWorkflowTitleFallback(item.Kind) : item.Title.Trim(); + var title = baseTitle; + + if (duplicateTitles.Contains(baseTitle)) + { + titleCounts.TryGetValue(baseTitle, out var count); + count++; + titleCounts[baseTitle] = count; + title = $"{baseTitle}{count}"; + } + + if (usedTitles.Contains(title)) + { + var index = 1; + var candidate = $"{title}{index}"; + while (usedTitles.Contains(candidate)) + { + index++; + candidate = $"{title}{index}"; + } + + title = candidate; + } + + item.Title = title; + usedTitles.Add(title); + } + } + + private static string NormalizeWorkflowTitleFallback(string kind) + { + return string.IsNullOrWhiteSpace(kind) ? "Step" : kind.Trim(); + } + private async Task CreateLangKeyAsync(string key, string textEn, string textTr) { if (string.IsNullOrWhiteSpace(key)) return; diff --git a/api/src/Sozsoft.Platform.Domain.Shared/WizardConsts.cs b/api/src/Sozsoft.Platform.Domain.Shared/WizardConsts.cs index 41d5e40..294ae9a 100644 --- a/api/src/Sozsoft.Platform.Domain.Shared/WizardConsts.cs +++ b/api/src/Sozsoft.Platform.Domain.Shared/WizardConsts.cs @@ -134,7 +134,7 @@ public static class WizardConsts R = permissionName, U = permissionName + ".Update", E = true, - I = false, + I = true, Deny = false }); } diff --git a/api/src/Sozsoft.Platform.Domain/Entities/Tenant/Saas/Note.cs b/api/src/Sozsoft.Platform.Domain/Entities/Tenant/Saas/Note.cs index 3cbc136..0ff4111 100644 --- a/api/src/Sozsoft.Platform.Domain/Entities/Tenant/Saas/Note.cs +++ b/api/src/Sozsoft.Platform.Domain/Entities/Tenant/Saas/Note.cs @@ -6,6 +6,14 @@ namespace Sozsoft.Platform.Entities; public class Note : FullAuditedEntity, IMultiTenant { + public Note() + { + } + + public Note(Guid id) : base(id) + { + } + public Guid? TenantId { get; set; } public string EntityName { get; set; } public string EntityId { get; set; } diff --git a/api/src/Sozsoft.Platform.EntityFrameworkCore/EntityFrameworkCore/PlatformDbContext.cs b/api/src/Sozsoft.Platform.EntityFrameworkCore/EntityFrameworkCore/PlatformDbContext.cs index f96d409..aa7d82c 100644 --- a/api/src/Sozsoft.Platform.EntityFrameworkCore/EntityFrameworkCore/PlatformDbContext.cs +++ b/api/src/Sozsoft.Platform.EntityFrameworkCore/EntityFrameworkCore/PlatformDbContext.cs @@ -507,6 +507,7 @@ public class PlatformDbContext : b.Property(x => x.PositionX).IsRequired(); b.Property(x => x.PositionY).IsRequired(); b.Property(x => x.CompareOutcomesJson).HasColumnType("text"); + b.HasIndex(x => new { x.ListFormCode, x.Title }).IsUnique(); }); builder.Entity(b => diff --git a/api/src/Sozsoft.Platform.EntityFrameworkCore/Migrations/20260602070242_Initial.Designer.cs b/api/src/Sozsoft.Platform.EntityFrameworkCore/Migrations/20260606212623_Initial.Designer.cs similarity index 99% rename from api/src/Sozsoft.Platform.EntityFrameworkCore/Migrations/20260602070242_Initial.Designer.cs rename to api/src/Sozsoft.Platform.EntityFrameworkCore/Migrations/20260606212623_Initial.Designer.cs index b0f10d6..0b60a87 100644 --- a/api/src/Sozsoft.Platform.EntityFrameworkCore/Migrations/20260602070242_Initial.Designer.cs +++ b/api/src/Sozsoft.Platform.EntityFrameworkCore/Migrations/20260606212623_Initial.Designer.cs @@ -13,7 +13,7 @@ using Volo.Abp.EntityFrameworkCore; namespace Sozsoft.Platform.Migrations { [DbContext(typeof(PlatformDbContext))] - [Migration("20260602070242_Initial")] + [Migration("20260606212623_Initial")] partial class Initial { /// @@ -3486,6 +3486,9 @@ namespace Sozsoft.Platform.Migrations b.HasKey("Id"); + b.HasIndex("ListFormCode", "Title") + .IsUnique(); + b.ToTable("Sas_H_ListFormWorkflow", (string)null); }); diff --git a/api/src/Sozsoft.Platform.EntityFrameworkCore/Migrations/20260602070242_Initial.cs b/api/src/Sozsoft.Platform.EntityFrameworkCore/Migrations/20260606212623_Initial.cs similarity index 99% rename from api/src/Sozsoft.Platform.EntityFrameworkCore/Migrations/20260602070242_Initial.cs rename to api/src/Sozsoft.Platform.EntityFrameworkCore/Migrations/20260606212623_Initial.cs index 40ec417..d304248 100644 --- a/api/src/Sozsoft.Platform.EntityFrameworkCore/Migrations/20260602070242_Initial.cs +++ b/api/src/Sozsoft.Platform.EntityFrameworkCore/Migrations/20260606212623_Initial.cs @@ -3905,6 +3905,12 @@ namespace Sozsoft.Platform.Migrations table: "Sas_H_ListFormImportLog", column: "ImportId"); + migrationBuilder.CreateIndex( + name: "IX_Sas_H_ListFormWorkflow_ListFormCode_Title", + table: "Sas_H_ListFormWorkflow", + columns: new[] { "ListFormCode", "Title" }, + unique: true); + migrationBuilder.CreateIndex( name: "IX_Sas_H_Menu_Code", table: "Sas_H_Menu", diff --git a/api/src/Sozsoft.Platform.EntityFrameworkCore/Migrations/PlatformDbContextModelSnapshot.cs b/api/src/Sozsoft.Platform.EntityFrameworkCore/Migrations/PlatformDbContextModelSnapshot.cs index b9c07cc..2500ecf 100644 --- a/api/src/Sozsoft.Platform.EntityFrameworkCore/Migrations/PlatformDbContextModelSnapshot.cs +++ b/api/src/Sozsoft.Platform.EntityFrameworkCore/Migrations/PlatformDbContextModelSnapshot.cs @@ -3483,6 +3483,9 @@ namespace Sozsoft.Platform.Migrations b.HasKey("Id"); + b.HasIndex("ListFormCode", "Title") + .IsUnique(); + b.ToTable("Sas_H_ListFormWorkflow", (string)null); }); diff --git a/ui/src/utils/workflow/workflowHelpers.ts b/ui/src/utils/workflow/workflowHelpers.ts index 1e3d0da..fbd49d2 100644 --- a/ui/src/utils/workflow/workflowHelpers.ts +++ b/ui/src/utils/workflow/workflowHelpers.ts @@ -328,6 +328,91 @@ export function emptyCriteria(kind = 'Compare', listFormCode = ''): WorkflowCrit } } +export function uniqueCriteriaTitle( + kind: string, + criteria: Array>, + currentId?: string | null, + preferredTitle?: string | null, +) { + const hasPreferredTitle = Boolean(preferredTitle?.trim()) + const baseTitle = (preferredTitle || defaultTitle(kind)).trim() + const usedTitles = new Set( + criteria + .filter((item) => !currentId || item.id !== currentId) + .map((item) => (item.title || '').trim().toLocaleLowerCase('tr-TR')) + .filter(Boolean), + ) + + if (!hasPreferredTitle) { + const sameKindCount = criteria.filter( + (item) => + (!currentId || item.id !== currentId) && + item.kind === kind && + isDefaultTitleVariant(item.title, baseTitle), + ).length + let index = sameKindCount + 1 + let candidate = `${baseTitle}${index}` + while (usedTitles.has(candidate.toLocaleLowerCase('tr-TR'))) { + index += 1 + candidate = `${baseTitle}${index}` + } + + return candidate + } + + if (!usedTitles.has(baseTitle.toLocaleLowerCase('tr-TR'))) { + return baseTitle + } + + let index = 1 + let candidate = `${baseTitle}${index}` + while (usedTitles.has(candidate.toLocaleLowerCase('tr-TR'))) { + index += 1 + candidate = `${baseTitle}${index}` + } + + return candidate +} + +export function uniqueCriteriaId( + criteria: Array>, + reservedIds: string[] = [], +) { + const usedIds = new Set( + [...criteria.map((item) => item.id), ...reservedIds] + .map((id) => (id || '').trim().toLocaleLowerCase('tr-TR')) + .filter(Boolean), + ) + const maxNumber = [...usedIds].reduce((max, id) => Math.max(max, parseCriteriaIdNumber(id)), 0) + let nextNumber = maxNumber + 1 + let candidate = formatCriteriaId(nextNumber) + + while (usedIds.has(candidate.toLocaleLowerCase('tr-TR'))) { + nextNumber += 1 + candidate = formatCriteriaId(nextNumber) + } + + return candidate +} + +function parseCriteriaIdNumber(id: string) { + const match = id.match(/^(?:n)?(\d+)$/iu) + return match ? Number(match[1]) : 0 +} + +function formatCriteriaId(number: number) { + return `N${String(number).padStart(3, '0')}`.slice(-4) +} + +function isDefaultTitleVariant(title: string | null | undefined, baseTitle: string) { + const normalized = (title || '').trim() + return normalized === baseTitle || new RegExp(`^${escapeRegExp(baseTitle)}\\d+$`, 'u').test(normalized) +} + +function escapeRegExp(value: string) { + return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') +} + export function toCriteriaForm(item: WorkflowCriteriaDto): WorkflowCriteriaForm { const sharedPerson = item.approver || '' diff --git a/ui/src/views/admin/listForm/edit/FormTabWorkflow.tsx b/ui/src/views/admin/listForm/edit/FormTabWorkflow.tsx index b74df42..288475f 100644 --- a/ui/src/views/admin/listForm/edit/FormTabWorkflow.tsx +++ b/ui/src/views/admin/listForm/edit/FormTabWorkflow.tsx @@ -5,6 +5,7 @@ import { emptyCriteria, normalizeCriteria, toCriteriaForm, + uniqueCriteriaTitle, type WorkflowCriteriaForm, } from '@/utils/workflow/workflowHelpers' import { workflowService, type WorkflowCriteriaDto } from '@/services/workflow.service' @@ -112,9 +113,17 @@ export function FormTabWorkflow( const saveCriteria = (event: FormEvent) => { event.preventDefault() runAction(async () => { + const normalized = normalizeCriteria(criteriaForm) + const preferredTitle = criteriaForm.title || normalized.title await workflowService.saveCriteria({ - ...normalizeCriteria(criteriaForm), + ...normalized, listFormCode: props.listFormCode, + title: uniqueCriteriaTitle( + normalized.kind || '', + currentCriteria, + normalized.id, + preferredTitle, + ), }) setSelectedId('') }) @@ -123,9 +132,11 @@ export function FormTabWorkflow( const addCriteria = (kind: string) => { setDesignerTab('flow') runAction(async () => { + const nextTitle = uniqueCriteriaTitle(kind, currentCriteria) const saved = await workflowService.saveCriteria({ ...normalizeCriteria(emptyCriteria(kind, props.listFormCode)), listFormCode: props.listFormCode, + title: nextTitle, positionX: 80 + (currentCriteria.length % 5) * 230, positionY: 220 + Math.floor(currentCriteria.length / 5) * 140, }) diff --git a/ui/src/views/admin/listForm/wizard/WizardStep6.tsx b/ui/src/views/admin/listForm/wizard/WizardStep6.tsx index dd7ab27..3a9a4cd 100644 --- a/ui/src/views/admin/listForm/wizard/WizardStep6.tsx +++ b/ui/src/views/admin/listForm/wizard/WizardStep6.tsx @@ -9,6 +9,8 @@ import { emptyCriteria, normalizeCriteria, toCriteriaForm, + uniqueCriteriaId, + uniqueCriteriaTitle, type WorkflowCriteriaForm, } from '@/utils/workflow/workflowHelpers' import { Field, FieldProps, Form, Formik } from 'formik' @@ -45,8 +47,6 @@ const toDesignerCriteria = (items: ListFormWorkflowCriteriaDto[]): WorkflowCrite const toWizardCriteria = (items: WorkflowCriteriaDto[]): ListFormWorkflowCriteriaDto[] => items.map(({ nodeId: _nodeId, ...item }) => item) -const nextId = () => `WF${Date.now()}${Math.floor(Math.random() * 1000)}` - function WizardStep6({ listFormCode, workflow, @@ -96,9 +96,10 @@ function WizardStep6({ const saveCriteria = (event: FormEvent) => { event.preventDefault() const normalized = normalizeCriteria({ ...criteriaForm, listFormCode }) - const id = normalized.id || nextId() - const nextItem = { ...normalized, id, nodeId: id } as WorkflowCriteriaDto + const id = normalized.id || uniqueCriteriaId(currentCriteria) const exists = currentCriteria.some((item) => item.id === id) + const title = uniqueCriteriaTitle(normalized.kind || '', currentCriteria, id, normalized.title) + const nextItem = { ...normalized, id, nodeId: id, title } as WorkflowCriteriaDto updateCriteria( exists ? currentCriteria.map((item) => (item.id === id ? nextItem : item)) @@ -109,11 +110,12 @@ function WizardStep6({ } const addCriteria = (kind: string) => { - const id = nextId() + const id = uniqueCriteriaId(currentCriteria) const nextItem = { ...normalizeCriteria(emptyCriteria(kind, listFormCode)), id, nodeId: id, + title: uniqueCriteriaTitle(kind, currentCriteria), positionX: 80 + (currentCriteria.length % 5) * 230, positionY: 220 + Math.floor(currentCriteria.length / 5) * 140, } as WorkflowCriteriaDto @@ -193,14 +195,15 @@ function WizardStep6({ } const resetDemo = () => { - const startId = nextId() - const approvalId = nextId() - const endId = nextId() + const startId = uniqueCriteriaId([]) + const approvalId = uniqueCriteriaId([], [startId]) + const endId = uniqueCriteriaId([], [startId, approvalId]) updateCriteria([ { ...normalizeCriteria(emptyCriteria('Start', listFormCode)), id: startId, nodeId: startId, + title: uniqueCriteriaTitle('Start', []), nextOnStart: approvalId, positionX: 72, positionY: 160, @@ -209,6 +212,7 @@ function WizardStep6({ ...normalizeCriteria(emptyCriteria('Approval', listFormCode)), id: approvalId, nodeId: approvalId, + title: uniqueCriteriaTitle('Approval', []), nextOnApprove: endId, positionX: 360, positionY: 160, @@ -217,6 +221,7 @@ function WizardStep6({ ...normalizeCriteria(emptyCriteria('End', listFormCode)), id: endId, nodeId: endId, + title: uniqueCriteriaTitle('End', []), positionX: 650, positionY: 160, } as WorkflowCriteriaDto, diff --git a/ui/src/views/form/notes/NoteList.tsx b/ui/src/views/form/notes/NoteList.tsx index 30378d7..677e504 100644 --- a/ui/src/views/form/notes/NoteList.tsx +++ b/ui/src/views/form/notes/NoteList.tsx @@ -73,6 +73,11 @@ export const NoteList: React.FC = ({ icon: , border: 'border-green-400', } + case 'workflow': + return { + icon: , + border: 'border-purple-400', + } default: return { icon: , @@ -435,7 +440,7 @@ export const NoteList: React.FC = ({ {/* Sil butonu */} - {user?.id === note.creatorId && ( + {user?.id === note.creatorId && note.type !== 'workflow' && (