ListformWorkflow Application Service
This commit is contained in:
parent
9a49f4df0f
commit
85fee9c067
24 changed files with 4007 additions and 3 deletions
|
|
@ -0,0 +1,11 @@
|
|||
using System.Collections.Generic;
|
||||
|
||||
namespace Sozsoft.Platform.ListForms.Workflow;
|
||||
|
||||
public class CompareOutcomeDto
|
||||
{
|
||||
public string Label { get; set; }
|
||||
public string TargetId { get; set; }
|
||||
public List<WorkflowConditionDto> Conditions { get; set; } = [];
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,28 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace Sozsoft.Platform.ListForms.Workflow;
|
||||
|
||||
public class CreateUpdateListFormWorkflowCriteriaDto
|
||||
{
|
||||
public Guid? Id { get; set; }
|
||||
public string ListFormCode { get; set; }
|
||||
public Guid WorkflowItemId { get; set; }
|
||||
public string NodeId { get; set; }
|
||||
public string Kind { get; set; }
|
||||
public string Title { get; set; }
|
||||
public string Column { get; set; }
|
||||
public string Operator { get; set; }
|
||||
public decimal CompareValue { get; set; }
|
||||
public string Approver { get; set; }
|
||||
public string InformPerson { get; set; }
|
||||
public string NextOnStart { get; set; }
|
||||
public string NextOnTrue { get; set; }
|
||||
public string NextOnFalse { get; set; }
|
||||
public string NextOnApprove { get; set; }
|
||||
public string NextOnReject { get; set; }
|
||||
public int PositionX { get; set; }
|
||||
public int PositionY { get; set; }
|
||||
public List<CompareOutcomeDto> CompareOutcomes { get; set; } = [];
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
using System;
|
||||
|
||||
namespace Sozsoft.Platform.ListForms.Workflow;
|
||||
|
||||
public class CreateUpdateListFormWorkflowDto
|
||||
{
|
||||
public string ListFormCode { get; set; }
|
||||
public string Sorumlu { get; set; }
|
||||
public DateTime? Tarih { get; set; }
|
||||
public decimal Amount { get; set; }
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
namespace Sozsoft.Platform.ListForms.Workflow;
|
||||
|
||||
public class DecisionWorkflowDto
|
||||
{
|
||||
public bool Approved { get; set; }
|
||||
public string Note { get; set; }
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,18 @@
|
|||
using System;
|
||||
using System.Threading.Tasks;
|
||||
using Volo.Abp.Application.Services;
|
||||
|
||||
namespace Sozsoft.Platform.ListForms.Workflow;
|
||||
|
||||
public interface IListFormWorkflowAppService : IApplicationService
|
||||
{
|
||||
Task<ListFormWorkflowStateDto> GetStateAsync(string listFormCode = null);
|
||||
Task<ListFormWorkflowDto> CreateWorkflowAsync(CreateUpdateListFormWorkflowDto input);
|
||||
Task<ListFormWorkflowDto> UpdateWorkflowAsync(Guid id, CreateUpdateListFormWorkflowDto input);
|
||||
Task<ListFormWorkflowDto> StartWorkflowAsync(Guid id);
|
||||
Task<ListFormWorkflowDto> DecideWorkflowAsync(Guid id, DecisionWorkflowDto input);
|
||||
Task<ListFormWorkflowCriteriaDto> SaveCriteriaAsync(CreateUpdateListFormWorkflowCriteriaDto input);
|
||||
Task DeleteCriteriaAsync(Guid id);
|
||||
Task<ListFormWorkflowStateDto> ResetDemoAsync(string listFormCode = null);
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,28 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using Volo.Abp.Application.Dtos;
|
||||
|
||||
namespace Sozsoft.Platform.ListForms.Workflow;
|
||||
|
||||
public class ListFormWorkflowCriteriaDto : AuditedEntityDto<Guid>
|
||||
{
|
||||
public string ListFormCode { get; set; }
|
||||
public Guid WorkflowItemId { get; set; }
|
||||
public string NodeId { get; set; }
|
||||
public string Kind { get; set; }
|
||||
public string Title { get; set; }
|
||||
public string Column { get; set; }
|
||||
public string Operator { get; set; }
|
||||
public decimal CompareValue { get; set; }
|
||||
public string Approver { get; set; }
|
||||
public string InformPerson { get; set; }
|
||||
public string NextOnStart { get; set; }
|
||||
public string NextOnTrue { get; set; }
|
||||
public string NextOnFalse { get; set; }
|
||||
public string NextOnApprove { get; set; }
|
||||
public string NextOnReject { get; set; }
|
||||
public int PositionX { get; set; }
|
||||
public int PositionY { get; set; }
|
||||
public List<CompareOutcomeDto> CompareOutcomes { get; set; } = [];
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,22 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using Volo.Abp.Application.Dtos;
|
||||
|
||||
namespace Sozsoft.Platform.ListForms.Workflow;
|
||||
|
||||
public class ListFormWorkflowDto : AuditedEntityDto<Guid>
|
||||
{
|
||||
public string ListFormCode { get; set; }
|
||||
public int OrderNo { get; set; }
|
||||
public string Title { get; set; }
|
||||
public string Sorumlu { get; set; }
|
||||
public DateTime Tarih { get; set; }
|
||||
public string Status { get; set; }
|
||||
public string Durum { get; set; }
|
||||
public decimal Amount { get; set; }
|
||||
public string CurrentNodeId { get; set; }
|
||||
public string AssignedApprover { get; set; }
|
||||
public string InformedPerson { get; set; }
|
||||
public List<WorkflowHistoryDto> History { get; set; } = [];
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
using System.Collections.Generic;
|
||||
|
||||
namespace Sozsoft.Platform.ListForms.Workflow;
|
||||
|
||||
public class ListFormWorkflowStateDto
|
||||
{
|
||||
public List<ListFormWorkflowDto> WorkflowItems { get; set; } = [];
|
||||
public List<ListFormWorkflowCriteriaDto> Criteria { get; set; } = [];
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
namespace Sozsoft.Platform.ListForms.Workflow;
|
||||
|
||||
public class WorkflowConditionDto
|
||||
{
|
||||
public string Column { get; set; }
|
||||
public string Operator { get; set; }
|
||||
public decimal CompareValue { get; set; }
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
using System;
|
||||
|
||||
namespace Sozsoft.Platform.ListForms.Workflow;
|
||||
|
||||
public class WorkflowHistoryDto
|
||||
{
|
||||
public DateTime Time { get; set; }
|
||||
public string Action { get; set; }
|
||||
public string Note { get; set; }
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,595 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Text.Json;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Sozsoft.Platform.Entities;
|
||||
using Volo.Abp;
|
||||
using Volo.Abp.Domain.Repositories;
|
||||
|
||||
namespace Sozsoft.Platform.ListForms.Workflow;
|
||||
|
||||
[Authorize]
|
||||
[Route("api/app/list-form-workflow")]
|
||||
public class ListFormWorkflowAppService : PlatformAppService, IListFormWorkflowAppService
|
||||
{
|
||||
private const string DefaultListFormCode = "workflow";
|
||||
private const string StatusNew = "Yeni";
|
||||
private const string StatusPending = "Onay Bekliyor";
|
||||
private const string StatusFinished = "Bitti";
|
||||
private const string StatusInformed = "Bilgilendirildi";
|
||||
|
||||
private readonly IRepository<ListFormWorkflow, Guid> workflowRepository;
|
||||
private readonly IRepository<ListFormWorkflowCriteria, Guid> criteriaRepository;
|
||||
|
||||
public ListFormWorkflowAppService(
|
||||
IRepository<ListFormWorkflow, Guid> workflowRepository,
|
||||
IRepository<ListFormWorkflowCriteria, Guid> criteriaRepository)
|
||||
{
|
||||
this.workflowRepository = workflowRepository;
|
||||
this.criteriaRepository = criteriaRepository;
|
||||
}
|
||||
|
||||
[HttpGet("state")]
|
||||
public async Task<ListFormWorkflowStateDto> GetStateAsync(string listFormCode = null)
|
||||
{
|
||||
var code = NormalizeListFormCode(listFormCode);
|
||||
var workflows = (await workflowRepository.GetListAsync(x => x.ListFormCode == code))
|
||||
.OrderBy(x => x.OrderNo)
|
||||
.ThenBy(x => x.CreationTime)
|
||||
.ToList();
|
||||
var criteria = (await criteriaRepository.GetListAsync(x => x.ListFormCode == code))
|
||||
.OrderBy(x => x.WorkflowItemId)
|
||||
.ThenBy(x => x.PositionX)
|
||||
.ThenBy(x => x.PositionY)
|
||||
.ToList();
|
||||
|
||||
return new ListFormWorkflowStateDto
|
||||
{
|
||||
WorkflowItems = workflows.Select(MapWorkflow).ToList(),
|
||||
Criteria = criteria.Select(MapCriteria).ToList()
|
||||
};
|
||||
}
|
||||
|
||||
[HttpPost("workflows")]
|
||||
public async Task<ListFormWorkflowDto> CreateWorkflowAsync(CreateUpdateListFormWorkflowDto input)
|
||||
{
|
||||
var code = NormalizeListFormCode(input.ListFormCode);
|
||||
var nextOrderNo = (await workflowRepository.GetListAsync(x => x.ListFormCode == code))
|
||||
.Select(x => x.OrderNo)
|
||||
.DefaultIfEmpty()
|
||||
.Max() + 1;
|
||||
|
||||
var workflow = new ListFormWorkflow(GuidGenerator.Create())
|
||||
{
|
||||
ListFormCode = code,
|
||||
OrderNo = nextOrderNo,
|
||||
Title = NormalizeRequired(input.Sorumlu, "Yeni Workflow"),
|
||||
Status = StatusNew,
|
||||
Amount = input.Amount,
|
||||
CurrentNodeId = string.Empty,
|
||||
AssignedApprover = string.Empty,
|
||||
InformedPerson = string.Empty,
|
||||
HistoryJson = SerializeHistory([])
|
||||
};
|
||||
|
||||
await workflowRepository.InsertAsync(workflow, autoSave: true);
|
||||
|
||||
var start = await CreateDefaultStartCriteriaAsync(workflow);
|
||||
workflow.CurrentNodeId = start.Id.ToString();
|
||||
await workflowRepository.UpdateAsync(workflow, autoSave: true);
|
||||
|
||||
return MapWorkflow(workflow);
|
||||
}
|
||||
|
||||
[HttpPut("workflows/{id}")]
|
||||
public async Task<ListFormWorkflowDto> UpdateWorkflowAsync(Guid id, CreateUpdateListFormWorkflowDto input)
|
||||
{
|
||||
var workflow = await workflowRepository.GetAsync(id);
|
||||
workflow.ListFormCode = NormalizeListFormCode(input.ListFormCode ?? workflow.ListFormCode);
|
||||
workflow.Title = NormalizeRequired(input.Sorumlu, workflow.Title);
|
||||
workflow.Amount = input.Amount;
|
||||
|
||||
await workflowRepository.UpdateAsync(workflow, autoSave: true);
|
||||
return MapWorkflow(workflow);
|
||||
}
|
||||
|
||||
[HttpPost("workflows/{id}/start")]
|
||||
public async Task<ListFormWorkflowDto> StartWorkflowAsync(Guid id)
|
||||
{
|
||||
var workflow = await workflowRepository.GetAsync(id);
|
||||
var criteria = await GetCriteriaForWorkflowAsync(workflow);
|
||||
var current = criteria.FirstOrDefault(x => x.Id.ToString() == workflow.CurrentNodeId)
|
||||
?? criteria.FirstOrDefault(x => x.Kind == "Start")
|
||||
?? throw new UserFriendlyException("Bu workflow için başlangıç adımı bulunamadı.");
|
||||
|
||||
workflow.CurrentNodeId = current.Id.ToString();
|
||||
AddHistory(workflow, "Başlatıldı", "Workflow başlatıldı.");
|
||||
await AdvanceWorkflowAsync(workflow, criteria, current);
|
||||
await workflowRepository.UpdateAsync(workflow, autoSave: true);
|
||||
|
||||
return MapWorkflow(workflow);
|
||||
}
|
||||
|
||||
[HttpPost("workflows/{id}/decision")]
|
||||
public async Task<ListFormWorkflowDto> DecideWorkflowAsync(Guid id, DecisionWorkflowDto input)
|
||||
{
|
||||
var workflow = await workflowRepository.GetAsync(id);
|
||||
var criteria = await GetCriteriaForWorkflowAsync(workflow);
|
||||
var current = criteria.FirstOrDefault(x => x.Id.ToString() == workflow.CurrentNodeId)
|
||||
?? throw new UserFriendlyException("Aktif workflow adımı bulunamadı.");
|
||||
|
||||
if (current.Kind != "Approval")
|
||||
{
|
||||
throw new UserFriendlyException("Workflow aktif olarak onay adımında değil.");
|
||||
}
|
||||
|
||||
var nextId = input.Approved ? current.NextOnApprove : current.NextOnReject;
|
||||
AddHistory(
|
||||
workflow,
|
||||
input.Approved ? "Onaylandı" : "Reddedildi",
|
||||
NormalizeRequired(input.Note, input.Approved ? "Onay verildi." : "Red edildi."));
|
||||
|
||||
var next = criteria.FirstOrDefault(x => x.Id.ToString() == nextId);
|
||||
if (next == null)
|
||||
{
|
||||
workflow.Status = StatusFinished;
|
||||
workflow.CurrentNodeId = current.Id.ToString();
|
||||
}
|
||||
else
|
||||
{
|
||||
await AdvanceWorkflowAsync(workflow, criteria, next);
|
||||
}
|
||||
|
||||
await workflowRepository.UpdateAsync(workflow, autoSave: true);
|
||||
return MapWorkflow(workflow);
|
||||
}
|
||||
|
||||
[HttpPost("criteria")]
|
||||
public async Task<ListFormWorkflowCriteriaDto> SaveCriteriaAsync(CreateUpdateListFormWorkflowCriteriaDto input)
|
||||
{
|
||||
var workflow = await workflowRepository.GetAsync(input.WorkflowItemId);
|
||||
var isNew = !input.Id.HasValue || input.Id.Value == Guid.Empty;
|
||||
var criteria = isNew
|
||||
? new ListFormWorkflowCriteria(GuidGenerator.Create()) { WorkflowItemId = workflow.Id }
|
||||
: await criteriaRepository.GetAsync(input.Id.Value);
|
||||
|
||||
criteria.ListFormCode = NormalizeListFormCode(input.ListFormCode ?? workflow.ListFormCode);
|
||||
criteria.WorkflowItemId = workflow.Id;
|
||||
criteria.NodeId = NormalizeRequired(input.NodeId, criteria.Id.ToString());
|
||||
criteria.Kind = NormalizeRequired(input.Kind, "Compare");
|
||||
criteria.Title = NormalizeRequired(input.Title, criteria.Kind);
|
||||
criteria.Column = NormalizeRequired(input.Column, "Tutar");
|
||||
criteria.Operator = NormalizeRequired(input.Operator, ">");
|
||||
criteria.CompareValue = input.CompareValue;
|
||||
criteria.Approver = input.Approver ?? string.Empty;
|
||||
criteria.InformPerson = input.InformPerson ?? string.Empty;
|
||||
criteria.NextOnStart = input.NextOnStart ?? string.Empty;
|
||||
criteria.NextOnTrue = input.NextOnTrue ?? string.Empty;
|
||||
criteria.NextOnFalse = input.NextOnFalse ?? string.Empty;
|
||||
criteria.NextOnApprove = input.NextOnApprove ?? string.Empty;
|
||||
criteria.NextOnReject = input.NextOnReject ?? string.Empty;
|
||||
criteria.PositionX = input.PositionX <= 0 ? 32 : input.PositionX;
|
||||
criteria.PositionY = input.PositionY <= 0 ? 150 : input.PositionY;
|
||||
criteria.CompareOutcomesJson = SerializeCompareOutcomes(input.CompareOutcomes);
|
||||
|
||||
var outcomes = input.CompareOutcomes ?? [];
|
||||
if (criteria.Kind == "Compare" && outcomes.Count > 0)
|
||||
{
|
||||
criteria.NextOnTrue = outcomes.ElementAtOrDefault(0)?.TargetId ?? criteria.NextOnTrue;
|
||||
criteria.NextOnFalse = outcomes.ElementAtOrDefault(1)?.TargetId ?? criteria.NextOnFalse;
|
||||
}
|
||||
|
||||
if (isNew)
|
||||
{
|
||||
await criteriaRepository.InsertAsync(criteria, autoSave: true);
|
||||
if (workflow.CurrentNodeId.IsNullOrWhiteSpace() || criteria.Kind == "Start")
|
||||
{
|
||||
workflow.CurrentNodeId = criteria.Id.ToString();
|
||||
await workflowRepository.UpdateAsync(workflow, autoSave: true);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
await criteriaRepository.UpdateAsync(criteria, autoSave: true);
|
||||
}
|
||||
|
||||
return MapCriteria(criteria);
|
||||
}
|
||||
|
||||
[HttpDelete("criteria/{id}")]
|
||||
public async Task DeleteCriteriaAsync(Guid id)
|
||||
{
|
||||
var criteria = await criteriaRepository.GetAsync(id);
|
||||
var workflow = await workflowRepository.GetAsync(criteria.WorkflowItemId);
|
||||
await criteriaRepository.DeleteAsync(criteria, autoSave: true);
|
||||
|
||||
var remaining = await GetCriteriaForWorkflowAsync(workflow);
|
||||
foreach (var item in remaining)
|
||||
{
|
||||
var changed = ClearDeletedTarget(item, id.ToString());
|
||||
if (changed)
|
||||
{
|
||||
await criteriaRepository.UpdateAsync(item, autoSave: true);
|
||||
}
|
||||
}
|
||||
|
||||
if (workflow.CurrentNodeId == id.ToString())
|
||||
{
|
||||
workflow.CurrentNodeId = remaining.FirstOrDefault(x => x.Kind == "Start")?.Id.ToString() ?? string.Empty;
|
||||
await workflowRepository.UpdateAsync(workflow, autoSave: true);
|
||||
}
|
||||
}
|
||||
|
||||
[HttpPost("reset-demo")]
|
||||
public async Task<ListFormWorkflowStateDto> ResetDemoAsync(string listFormCode = null)
|
||||
{
|
||||
var code = NormalizeListFormCode(listFormCode);
|
||||
var workflows = await workflowRepository.GetListAsync(x => x.ListFormCode == code);
|
||||
var workflowIds = workflows.Select(x => x.Id).ToList();
|
||||
var criteria = await criteriaRepository.GetListAsync(x => x.ListFormCode == code && workflowIds.Contains(x.WorkflowItemId));
|
||||
foreach (var item in criteria)
|
||||
{
|
||||
await criteriaRepository.DeleteAsync(item, autoSave: true);
|
||||
}
|
||||
|
||||
foreach (var workflow in workflows)
|
||||
{
|
||||
await workflowRepository.DeleteAsync(workflow, autoSave: true);
|
||||
}
|
||||
|
||||
var demo = new ListFormWorkflow(GuidGenerator.Create())
|
||||
{
|
||||
ListFormCode = code,
|
||||
OrderNo = 1,
|
||||
Title = "Üretim Süreci",
|
||||
Status = StatusNew,
|
||||
Amount = 7200,
|
||||
CurrentNodeId = string.Empty,
|
||||
AssignedApprover = string.Empty,
|
||||
InformedPerson = string.Empty,
|
||||
HistoryJson = SerializeHistory([])
|
||||
};
|
||||
await workflowRepository.InsertAsync(demo, autoSave: true);
|
||||
|
||||
var start = await CreateCriteriaAsync(demo, "Start", "İş Akışı Başlat", 80, 150);
|
||||
var compare = await CreateCriteriaAsync(demo, "Compare", "Tutar kontrolü", 330, 130);
|
||||
var approval = await CreateCriteriaAsync(demo, "Approval", "Yönetici Onayı", 590, 60, "ayse.yilmaz");
|
||||
var inform = await CreateCriteriaAsync(demo, "Inform", "Muhasebe Bilgilendirme", 590, 230, "muhasebe");
|
||||
var end = await CreateCriteriaAsync(demo, "End", "Akışı Bitir", 850, 150);
|
||||
|
||||
start.NextOnStart = compare.Id.ToString();
|
||||
compare.NextOnTrue = approval.Id.ToString();
|
||||
compare.NextOnFalse = inform.Id.ToString();
|
||||
compare.CompareOutcomesJson = SerializeCompareOutcomes([
|
||||
new CompareOutcomeDto
|
||||
{
|
||||
Label = "Onay gerekir",
|
||||
TargetId = approval.Id.ToString(),
|
||||
Conditions = [new WorkflowConditionDto { Column = "Tutar", Operator = ">", CompareValue = 5000 }]
|
||||
},
|
||||
new CompareOutcomeDto
|
||||
{
|
||||
Label = "Bilgilendir",
|
||||
TargetId = inform.Id.ToString(),
|
||||
Conditions = [new WorkflowConditionDto { Column = "Tutar", Operator = "<=", CompareValue = 5000 }]
|
||||
}
|
||||
]);
|
||||
approval.NextOnApprove = inform.Id.ToString();
|
||||
approval.NextOnReject = end.Id.ToString();
|
||||
inform.NextOnStart = end.Id.ToString();
|
||||
|
||||
await criteriaRepository.UpdateAsync(start, autoSave: true);
|
||||
await criteriaRepository.UpdateAsync(compare, autoSave: true);
|
||||
await criteriaRepository.UpdateAsync(approval, autoSave: true);
|
||||
await criteriaRepository.UpdateAsync(inform, autoSave: true);
|
||||
|
||||
demo.CurrentNodeId = start.Id.ToString();
|
||||
await workflowRepository.UpdateAsync(demo, autoSave: true);
|
||||
|
||||
return await GetStateAsync(code);
|
||||
}
|
||||
|
||||
private async Task<ListFormWorkflowCriteria> CreateDefaultStartCriteriaAsync(ListFormWorkflow workflow)
|
||||
{
|
||||
return await CreateCriteriaAsync(workflow, "Start", "İş Akışı Başlat", 32, 150);
|
||||
}
|
||||
|
||||
private async Task<ListFormWorkflowCriteria> CreateCriteriaAsync(
|
||||
ListFormWorkflow workflow,
|
||||
string kind,
|
||||
string title,
|
||||
int positionX,
|
||||
int positionY,
|
||||
string approver = "")
|
||||
{
|
||||
var criteria = new ListFormWorkflowCriteria(GuidGenerator.Create())
|
||||
{
|
||||
ListFormCode = workflow.ListFormCode,
|
||||
WorkflowItemId = workflow.Id,
|
||||
NodeId = GuidGenerator.Create().ToString(),
|
||||
Kind = kind,
|
||||
Title = title,
|
||||
Column = "Tutar",
|
||||
Operator = ">",
|
||||
CompareValue = 5000,
|
||||
Approver = approver,
|
||||
InformPerson = approver,
|
||||
NextOnStart = string.Empty,
|
||||
NextOnTrue = string.Empty,
|
||||
NextOnFalse = string.Empty,
|
||||
NextOnApprove = string.Empty,
|
||||
NextOnReject = string.Empty,
|
||||
PositionX = positionX,
|
||||
PositionY = positionY,
|
||||
CompareOutcomesJson = SerializeCompareOutcomes([])
|
||||
};
|
||||
|
||||
await criteriaRepository.InsertAsync(criteria, autoSave: true);
|
||||
return criteria;
|
||||
}
|
||||
|
||||
private async Task<List<ListFormWorkflowCriteria>> GetCriteriaForWorkflowAsync(ListFormWorkflow workflow)
|
||||
{
|
||||
return (await criteriaRepository.GetListAsync(x => x.WorkflowItemId == workflow.Id))
|
||||
.OrderBy(x => x.PositionX)
|
||||
.ThenBy(x => x.PositionY)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
private async Task AdvanceWorkflowAsync(
|
||||
ListFormWorkflow workflow,
|
||||
List<ListFormWorkflowCriteria> criteria,
|
||||
ListFormWorkflowCriteria current)
|
||||
{
|
||||
var visited = new HashSet<Guid>();
|
||||
var step = current;
|
||||
|
||||
while (step != null && visited.Add(step.Id))
|
||||
{
|
||||
workflow.CurrentNodeId = step.Id.ToString();
|
||||
|
||||
if (step.Kind == "Approval")
|
||||
{
|
||||
workflow.Status = StatusPending;
|
||||
workflow.AssignedApprover = step.Approver;
|
||||
workflow.InformedPerson = step.InformPerson;
|
||||
return;
|
||||
}
|
||||
|
||||
if (step.Kind == "End")
|
||||
{
|
||||
workflow.Status = StatusFinished;
|
||||
AddHistory(workflow, "Tamamlandı", "Workflow tamamlandı.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (step.Kind == "Inform")
|
||||
{
|
||||
workflow.Status = StatusInformed;
|
||||
workflow.InformedPerson = step.InformPerson;
|
||||
AddHistory(workflow, "Bilgilendirildi", $"{step.InformPerson} bilgilendirildi.");
|
||||
step = FindNext(criteria, step.NextOnStart);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (step.Kind == "Compare")
|
||||
{
|
||||
step = FindNext(criteria, ResolveCompareTarget(workflow, step));
|
||||
continue;
|
||||
}
|
||||
|
||||
workflow.Status = StatusNew;
|
||||
step = FindNext(criteria, step.NextOnStart);
|
||||
}
|
||||
|
||||
await Task.CompletedTask;
|
||||
}
|
||||
|
||||
private string ResolveCompareTarget(ListFormWorkflow workflow, ListFormWorkflowCriteria criteria)
|
||||
{
|
||||
var outcomes = DeserializeCompareOutcomes(criteria.CompareOutcomesJson);
|
||||
foreach (var outcome in outcomes)
|
||||
{
|
||||
if (outcome.Conditions.Count > 0 && outcome.Conditions.All(condition => EvaluateCondition(workflow, condition)))
|
||||
{
|
||||
return outcome.TargetId;
|
||||
}
|
||||
}
|
||||
|
||||
return EvaluateCondition(workflow, new WorkflowConditionDto
|
||||
{
|
||||
Column = criteria.Column,
|
||||
Operator = criteria.Operator,
|
||||
CompareValue = criteria.CompareValue
|
||||
})
|
||||
? criteria.NextOnTrue
|
||||
: criteria.NextOnFalse;
|
||||
}
|
||||
|
||||
private static bool EvaluateCondition(ListFormWorkflow workflow, WorkflowConditionDto condition)
|
||||
{
|
||||
var value = condition.Column == "Id" ? workflow.OrderNo : workflow.Amount;
|
||||
return condition.Operator switch
|
||||
{
|
||||
">" => value > condition.CompareValue,
|
||||
">=" => value >= condition.CompareValue,
|
||||
"<" => value < condition.CompareValue,
|
||||
"<=" => value <= condition.CompareValue,
|
||||
"=" => value == condition.CompareValue,
|
||||
"!=" => value != condition.CompareValue,
|
||||
_ => false
|
||||
};
|
||||
}
|
||||
|
||||
private static ListFormWorkflowCriteria FindNext(List<ListFormWorkflowCriteria> criteria, string id)
|
||||
{
|
||||
return id.IsNullOrWhiteSpace() ? null : criteria.FirstOrDefault(x => x.Id.ToString() == id);
|
||||
}
|
||||
|
||||
private static bool ClearDeletedTarget(ListFormWorkflowCriteria criteria, string deletedId)
|
||||
{
|
||||
var changed = false;
|
||||
if (criteria.NextOnStart == deletedId)
|
||||
{
|
||||
criteria.NextOnStart = string.Empty;
|
||||
changed = true;
|
||||
}
|
||||
if (criteria.NextOnTrue == deletedId)
|
||||
{
|
||||
criteria.NextOnTrue = string.Empty;
|
||||
changed = true;
|
||||
}
|
||||
if (criteria.NextOnFalse == deletedId)
|
||||
{
|
||||
criteria.NextOnFalse = string.Empty;
|
||||
changed = true;
|
||||
}
|
||||
if (criteria.NextOnApprove == deletedId)
|
||||
{
|
||||
criteria.NextOnApprove = string.Empty;
|
||||
changed = true;
|
||||
}
|
||||
if (criteria.NextOnReject == deletedId)
|
||||
{
|
||||
criteria.NextOnReject = string.Empty;
|
||||
changed = true;
|
||||
}
|
||||
|
||||
var outcomes = DeserializeCompareOutcomes(criteria.CompareOutcomesJson);
|
||||
foreach (var outcome in outcomes.Where(x => x.TargetId == deletedId))
|
||||
{
|
||||
outcome.TargetId = null;
|
||||
changed = true;
|
||||
}
|
||||
|
||||
if (changed)
|
||||
{
|
||||
criteria.CompareOutcomesJson = SerializeCompareOutcomes(outcomes);
|
||||
}
|
||||
|
||||
return changed;
|
||||
}
|
||||
|
||||
private static void AddHistory(ListFormWorkflow workflow, string action, string note)
|
||||
{
|
||||
var history = DeserializeHistory(workflow.HistoryJson);
|
||||
history.Add(new WorkflowHistoryDto
|
||||
{
|
||||
Time = DateTime.Now,
|
||||
Action = action,
|
||||
Note = note
|
||||
});
|
||||
workflow.HistoryJson = SerializeHistory(history);
|
||||
}
|
||||
|
||||
private static ListFormWorkflowDto MapWorkflow(ListFormWorkflow workflow)
|
||||
{
|
||||
return new ListFormWorkflowDto
|
||||
{
|
||||
Id = workflow.Id,
|
||||
CreationTime = workflow.CreationTime,
|
||||
CreatorId = workflow.CreatorId,
|
||||
LastModificationTime = workflow.LastModificationTime,
|
||||
LastModifierId = workflow.LastModifierId,
|
||||
ListFormCode = workflow.ListFormCode,
|
||||
OrderNo = workflow.OrderNo,
|
||||
Title = workflow.Title,
|
||||
Sorumlu = workflow.Title,
|
||||
Tarih = workflow.CreationTime == default ? DateTime.Now : workflow.CreationTime,
|
||||
Status = workflow.Status,
|
||||
Durum = workflow.Status,
|
||||
Amount = workflow.Amount,
|
||||
CurrentNodeId = workflow.CurrentNodeId,
|
||||
AssignedApprover = workflow.AssignedApprover,
|
||||
InformedPerson = workflow.InformedPerson,
|
||||
History = DeserializeHistory(workflow.HistoryJson)
|
||||
};
|
||||
}
|
||||
|
||||
private static ListFormWorkflowCriteriaDto MapCriteria(ListFormWorkflowCriteria criteria)
|
||||
{
|
||||
return new ListFormWorkflowCriteriaDto
|
||||
{
|
||||
Id = criteria.Id,
|
||||
CreationTime = criteria.CreationTime,
|
||||
CreatorId = criteria.CreatorId,
|
||||
LastModificationTime = criteria.LastModificationTime,
|
||||
LastModifierId = criteria.LastModifierId,
|
||||
ListFormCode = criteria.ListFormCode,
|
||||
WorkflowItemId = criteria.WorkflowItemId,
|
||||
NodeId = criteria.NodeId,
|
||||
Kind = criteria.Kind,
|
||||
Title = criteria.Title,
|
||||
Column = criteria.Column,
|
||||
Operator = criteria.Operator,
|
||||
CompareValue = criteria.CompareValue,
|
||||
Approver = criteria.Approver,
|
||||
InformPerson = criteria.InformPerson,
|
||||
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 string NormalizeListFormCode(string listFormCode)
|
||||
{
|
||||
return listFormCode.IsNullOrWhiteSpace() ? DefaultListFormCode : listFormCode.Trim();
|
||||
}
|
||||
|
||||
private static string NormalizeRequired(string value, string fallback)
|
||||
{
|
||||
return value.IsNullOrWhiteSpace() ? fallback : value.Trim();
|
||||
}
|
||||
|
||||
private static string SerializeCompareOutcomes(List<CompareOutcomeDto> outcomes)
|
||||
{
|
||||
return JsonSerializer.Serialize(outcomes ?? []);
|
||||
}
|
||||
|
||||
private static List<CompareOutcomeDto> DeserializeCompareOutcomes(string json)
|
||||
{
|
||||
if (json.IsNullOrWhiteSpace())
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
return JsonSerializer.Deserialize<List<CompareOutcomeDto>>(json) ?? [];
|
||||
}
|
||||
catch
|
||||
{
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
private static string SerializeHistory(List<WorkflowHistoryDto> history)
|
||||
{
|
||||
return JsonSerializer.Serialize(history ?? []);
|
||||
}
|
||||
|
||||
private static List<WorkflowHistoryDto> DeserializeHistory(string json)
|
||||
{
|
||||
if (json.IsNullOrWhiteSpace())
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
return JsonSerializer.Deserialize<List<WorkflowHistoryDto>>(json) ?? [];
|
||||
}
|
||||
catch
|
||||
{
|
||||
return [];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -6,6 +6,14 @@ namespace Sozsoft.Platform.Entities;
|
|||
|
||||
public class ListFormWorkflow : FullAuditedEntity<Guid>
|
||||
{
|
||||
protected ListFormWorkflow()
|
||||
{
|
||||
}
|
||||
|
||||
public ListFormWorkflow(Guid id) : base(id)
|
||||
{
|
||||
}
|
||||
|
||||
public string ListFormCode { get; set; }
|
||||
public int OrderNo { get; set; }
|
||||
public string Title { get; set; }
|
||||
|
|
@ -17,4 +25,4 @@ public class ListFormWorkflow : FullAuditedEntity<Guid>
|
|||
public string HistoryJson { get; set; }
|
||||
|
||||
public ICollection<ListFormWorkflowCriteria> Criteria { get; set; }
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,6 +5,14 @@ namespace Sozsoft.Platform.Entities;
|
|||
|
||||
public class ListFormWorkflowCriteria : FullAuditedEntity<Guid>
|
||||
{
|
||||
protected ListFormWorkflowCriteria()
|
||||
{
|
||||
}
|
||||
|
||||
public ListFormWorkflowCriteria(Guid id) : base(id)
|
||||
{
|
||||
}
|
||||
|
||||
public string ListFormCode { get; set; }
|
||||
public Guid WorkflowItemId { get; set; }
|
||||
public ListFormWorkflow WorkflowItem { get; set; }
|
||||
|
|
@ -24,4 +32,4 @@ public class ListFormWorkflowCriteria : FullAuditedEntity<Guid>
|
|||
public int PositionX { get; set; }
|
||||
public int PositionY { get; set; }
|
||||
public string CompareOutcomesJson { get; set; }
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -93,4 +93,4 @@
|
|||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
|
|||
152
ui/src/services/workflow.service.ts
Normal file
152
ui/src/services/workflow.service.ts
Normal file
|
|
@ -0,0 +1,152 @@
|
|||
import apiService from './api.service'
|
||||
|
||||
export interface WorkflowConditionDto {
|
||||
column: string
|
||||
operator: string
|
||||
compareValue: number
|
||||
}
|
||||
|
||||
export interface CompareOutcomeDto {
|
||||
label: string
|
||||
targetId?: string | null
|
||||
conditions: WorkflowConditionDto[]
|
||||
}
|
||||
|
||||
export interface WorkflowItemDto {
|
||||
id: string
|
||||
listFormCode: string
|
||||
orderNo: number
|
||||
title: string
|
||||
sorumlu: string
|
||||
tarih: string
|
||||
status: string
|
||||
durum: string
|
||||
amount: number
|
||||
currentNodeId: string
|
||||
assignedApprover: string
|
||||
informedPerson: string
|
||||
history: Array<{
|
||||
time: string
|
||||
action: string
|
||||
note: string
|
||||
}>
|
||||
}
|
||||
|
||||
export interface WorkflowCriteriaDto {
|
||||
id: string
|
||||
listFormCode: string
|
||||
workflowItemId: string
|
||||
nodeId: string
|
||||
kind: string
|
||||
title: string
|
||||
column: string
|
||||
operator: string
|
||||
compareValue: number
|
||||
approver: string
|
||||
informPerson: string
|
||||
nextOnStart?: string | null
|
||||
nextOnTrue?: string | null
|
||||
nextOnFalse?: string | null
|
||||
nextOnApprove?: string | null
|
||||
nextOnReject?: string | null
|
||||
positionX: number
|
||||
positionY: number
|
||||
compareOutcomes: CompareOutcomeDto[]
|
||||
}
|
||||
|
||||
export interface WorkflowStateDto {
|
||||
workflowItems: WorkflowItemDto[]
|
||||
criteria: WorkflowCriteriaDto[]
|
||||
}
|
||||
|
||||
export type CreateUpdateWorkflowInput = Partial<WorkflowItemDto> & {
|
||||
listFormCode?: string
|
||||
sorumlu: string
|
||||
amount: number
|
||||
tarih?: string
|
||||
}
|
||||
|
||||
export type SaveCriteriaInput = Partial<WorkflowCriteriaDto> & {
|
||||
workflowItemId: string
|
||||
}
|
||||
|
||||
const baseUrl = '/api/app/list-form-workflow'
|
||||
|
||||
export const workflowService = {
|
||||
async getState(listFormCode?: string) {
|
||||
const response = await apiService.fetchData<WorkflowStateDto>({
|
||||
method: 'GET',
|
||||
url: `${baseUrl}/state`,
|
||||
params: { listFormCode },
|
||||
})
|
||||
|
||||
return response.data
|
||||
},
|
||||
|
||||
async createWorkflow(payload: CreateUpdateWorkflowInput) {
|
||||
const response = await apiService.fetchData<WorkflowItemDto>({
|
||||
method: 'POST',
|
||||
url: `${baseUrl}/workflows`,
|
||||
data: payload,
|
||||
})
|
||||
|
||||
return response.data
|
||||
},
|
||||
|
||||
async updateWorkflow(id: string, payload: CreateUpdateWorkflowInput) {
|
||||
const response = await apiService.fetchData<WorkflowItemDto>({
|
||||
method: 'PUT',
|
||||
url: `${baseUrl}/workflows/${id}`,
|
||||
data: payload,
|
||||
})
|
||||
|
||||
return response.data
|
||||
},
|
||||
|
||||
async startWorkflow(id: string) {
|
||||
const response = await apiService.fetchData<WorkflowItemDto>({
|
||||
method: 'POST',
|
||||
url: `${baseUrl}/workflows/${id}/start`,
|
||||
})
|
||||
|
||||
return response.data
|
||||
},
|
||||
|
||||
async decideWorkflow(id: string, payload: { approved: boolean; note?: string }) {
|
||||
const response = await apiService.fetchData<WorkflowItemDto>({
|
||||
method: 'POST',
|
||||
url: `${baseUrl}/workflows/${id}/decision`,
|
||||
data: payload,
|
||||
})
|
||||
|
||||
return response.data
|
||||
},
|
||||
|
||||
async saveCriteria(payload: SaveCriteriaInput) {
|
||||
const response = await apiService.fetchData<WorkflowCriteriaDto>({
|
||||
method: 'POST',
|
||||
url: `${baseUrl}/criteria`,
|
||||
data: payload,
|
||||
})
|
||||
|
||||
return response.data
|
||||
},
|
||||
|
||||
async deleteCriteria(id: string) {
|
||||
await apiService.fetchData<void>({
|
||||
method: 'DELETE',
|
||||
url: `${baseUrl}/criteria/${id}`,
|
||||
})
|
||||
},
|
||||
|
||||
async resetDemo(listFormCode?: string) {
|
||||
const response = await apiService.fetchData<WorkflowStateDto>({
|
||||
method: 'POST',
|
||||
url: `${baseUrl}/reset-demo`,
|
||||
params: { listFormCode },
|
||||
})
|
||||
|
||||
return response.data
|
||||
},
|
||||
}
|
||||
|
||||
38
ui/src/utils/workflow/workflowConstants.ts
Normal file
38
ui/src/utils/workflow/workflowConstants.ts
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
import { FiBell, FiCheck, FiGitBranch, FiPlay, FiSlash } from "react-icons/fi";
|
||||
|
||||
export const kindOptions = [
|
||||
{ value: "Start", label: "Başlat" },
|
||||
{ value: "Compare", label: "Karşılaştırma" },
|
||||
{ value: "Approval", label: "Onaylanacak kişi" },
|
||||
{ value: "Inform", label: "Bilgilendirme" },
|
||||
{ value: "End", label: "Akışı bitir" },
|
||||
];
|
||||
|
||||
export const operatorOptions = [">", ">=", "<", "<=", "=", "!="].map(
|
||||
(value) => ({
|
||||
value,
|
||||
label: value,
|
||||
}),
|
||||
);
|
||||
|
||||
export const columnOptions = ["Tutar", "Id"].map((value) => ({
|
||||
value,
|
||||
label: value,
|
||||
}));
|
||||
|
||||
export const kindIcon = {
|
||||
Start: FiPlay as any,
|
||||
Compare: FiGitBranch as any,
|
||||
Approval: FiCheck as any,
|
||||
Inform: FiBell as any,
|
||||
End: FiSlash as any,
|
||||
};
|
||||
|
||||
export const nodeSize = {
|
||||
width: 176,
|
||||
height: 128,
|
||||
};
|
||||
|
||||
export function getNodeHeight(item: { kind: string }) {
|
||||
return item?.kind === "Compare" ? 158 : nodeSize.height;
|
||||
}
|
||||
475
ui/src/utils/workflow/workflowHelpers.ts
Normal file
475
ui/src/utils/workflow/workflowHelpers.ts
Normal file
|
|
@ -0,0 +1,475 @@
|
|||
import { getNodeHeight, nodeSize } from "./workflowConstants";
|
||||
|
||||
export function isPendingApproval(item, criteria) {
|
||||
if (!item) return false;
|
||||
|
||||
return criteria.some(
|
||||
(candidate) =>
|
||||
candidate.workflowItemId === item.id &&
|
||||
candidate.id === item.currentNodeId &&
|
||||
candidate.kind === "Approval",
|
||||
);
|
||||
}
|
||||
|
||||
export function buildFitLayout(criteria) {
|
||||
const links = collectLinks(criteria);
|
||||
const rankById = buildTraversalRanks(criteria, links);
|
||||
const groups = new Map<any, any[]>();
|
||||
criteria.forEach((item) => {
|
||||
const column = fitColumn(item);
|
||||
if (!groups.has(column)) groups.set(column, []);
|
||||
groups.get(column).push(item);
|
||||
});
|
||||
|
||||
const sortedColumns = [...groups.keys()].sort((a, b) => a - b);
|
||||
const yGap = 74;
|
||||
const maxGroupHeight = Math.max(
|
||||
1,
|
||||
...[...groups.values()].map(
|
||||
(items) =>
|
||||
items.reduce((sum, item) => sum + getNodeHeight(item), 0) +
|
||||
Math.max(0, items.length - 1) * yGap,
|
||||
),
|
||||
);
|
||||
const top = 72;
|
||||
const left = 72;
|
||||
const xGap = 128;
|
||||
const positions = new Map();
|
||||
|
||||
sortedColumns.forEach((column, columnIndex) => {
|
||||
const items = groups
|
||||
.get(column)
|
||||
.sort((a, b) => compareLayoutNodes(a, b, rankById));
|
||||
const groupHeight =
|
||||
items.reduce((sum, item) => sum + getNodeHeight(item), 0) +
|
||||
Math.max(0, items.length - 1) * yGap;
|
||||
let y = top + Math.max(0, (maxGroupHeight - groupHeight) / 2);
|
||||
|
||||
items.forEach((item) => {
|
||||
positions.set(item.id, {
|
||||
x: left + columnIndex * (nodeSize.width + xGap),
|
||||
y: Math.round(y),
|
||||
});
|
||||
y += getNodeHeight(item) + yGap;
|
||||
});
|
||||
});
|
||||
|
||||
return positions;
|
||||
}
|
||||
|
||||
function fitColumn(item) {
|
||||
const priority = {
|
||||
Start: 0,
|
||||
Compare: 1,
|
||||
Approval: 2,
|
||||
Inform: 3,
|
||||
End: 4,
|
||||
};
|
||||
|
||||
return priority[item.kind] ?? 2;
|
||||
}
|
||||
|
||||
function compareLayoutNodes(a, b, rankById = new Map()) {
|
||||
return (
|
||||
(rankById.get(a.id) ?? 999) - (rankById.get(b.id) ?? 999) ||
|
||||
a.title.localeCompare(b.title, "tr")
|
||||
);
|
||||
}
|
||||
|
||||
function buildTraversalRanks(criteria, links) {
|
||||
const rankById = new Map();
|
||||
const outgoing = new Map<any, any[]>(criteria.map((item) => [item.id, []]));
|
||||
links.forEach((link) => {
|
||||
outgoing.get(link.source.id)?.push(link.target.id);
|
||||
});
|
||||
|
||||
const roots = criteria.filter((item) => item.kind === "Start");
|
||||
const queue = roots.length
|
||||
? roots.map((item) => item.id)
|
||||
: criteria.map((item) => item.id);
|
||||
|
||||
while (queue.length) {
|
||||
const id = queue.shift();
|
||||
if (rankById.has(id)) continue;
|
||||
|
||||
rankById.set(id, rankById.size);
|
||||
(outgoing.get(id) || []).forEach((targetId) => {
|
||||
if (targetId && !rankById.has(targetId)) queue.push(targetId);
|
||||
});
|
||||
}
|
||||
|
||||
criteria.forEach((item) => {
|
||||
if (!rankById.has(item.id)) rankById.set(item.id, rankById.size);
|
||||
});
|
||||
|
||||
return rankById;
|
||||
}
|
||||
|
||||
export function collectLinks(criteria) {
|
||||
const links = [];
|
||||
criteria.forEach((source) => {
|
||||
if (source.kind === "Compare" && source.compareOutcomes?.length) {
|
||||
source.compareOutcomes.forEach((outcome, index) => {
|
||||
addLink(
|
||||
links,
|
||||
criteria,
|
||||
source,
|
||||
outcome.targetId,
|
||||
outcome.label,
|
||||
`compare-${index}`,
|
||||
{
|
||||
index,
|
||||
count: source.compareOutcomes.length,
|
||||
field: `compareOutcomes:${index}`,
|
||||
},
|
||||
);
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
addLink(links, criteria, source, source.nextOnStart, "Sonraki", "next", {
|
||||
index: 0,
|
||||
count: 1,
|
||||
field: "nextOnStart",
|
||||
});
|
||||
addLink(links, criteria, source, source.nextOnTrue, "Doğru", "true", {
|
||||
index: 0,
|
||||
count: 2,
|
||||
field: "nextOnTrue",
|
||||
});
|
||||
addLink(links, criteria, source, source.nextOnFalse, "Yanlış", "false", {
|
||||
index: 1,
|
||||
count: 2,
|
||||
field: "nextOnFalse",
|
||||
});
|
||||
addLink(links, criteria, source, source.nextOnApprove, "Onay", "approve", {
|
||||
index: 0,
|
||||
count: 2,
|
||||
field: "nextOnApprove",
|
||||
});
|
||||
addLink(links, criteria, source, source.nextOnReject, "Red", "reject", {
|
||||
index: 1,
|
||||
count: 2,
|
||||
field: "nextOnReject",
|
||||
});
|
||||
});
|
||||
return assignLinkSlots(links, criteria);
|
||||
}
|
||||
|
||||
export function assignLinkSlots(links, criteria) {
|
||||
const endpointGroups = new Map();
|
||||
const addEndpoint = (nodeId, side, endpoint) => {
|
||||
const key = `${nodeId}:${side}`;
|
||||
if (!endpointGroups.has(key)) endpointGroups.set(key, []);
|
||||
endpointGroups.get(key).push(endpoint);
|
||||
};
|
||||
|
||||
links.forEach((link) => {
|
||||
addEndpoint(link.source.id, sideToward(link.source, link.target), {
|
||||
link,
|
||||
role: "source",
|
||||
});
|
||||
addEndpoint(link.target.id, sideToward(link.target, link.source), {
|
||||
link,
|
||||
role: "target",
|
||||
});
|
||||
});
|
||||
|
||||
endpointGroups.forEach((endpoints) => {
|
||||
endpoints.forEach((endpoint, index) => {
|
||||
if (endpoint.role === "source") {
|
||||
endpoint.link.sourcePort.sourceSlotIndex = index;
|
||||
endpoint.link.sourcePort.sourceSlotCount = endpoints.length;
|
||||
} else {
|
||||
endpoint.link.sourcePort.targetSlotIndex = index;
|
||||
endpoint.link.sourcePort.targetSlotCount = endpoints.length;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
links.forEach((link) => {
|
||||
link.sourcePort.routeIndex = link.sourcePort.targetSlotIndex ?? 0;
|
||||
link.sourcePort.routeCount = link.sourcePort.targetSlotCount ?? 1;
|
||||
});
|
||||
|
||||
return links;
|
||||
}
|
||||
|
||||
function sideToward(from, to) {
|
||||
const fromLeft = Number(from.positionX || 0);
|
||||
const fromTop = Number(from.positionY || 0);
|
||||
const fromCenter = {
|
||||
x: fromLeft + nodeSize.width / 2,
|
||||
y: fromTop + getNodeHeight(from) / 2,
|
||||
};
|
||||
const toCenter = {
|
||||
x: Number(to.positionX || 0) + nodeSize.width / 2,
|
||||
y: Number(to.positionY || 0) + getNodeHeight(to) / 2,
|
||||
};
|
||||
|
||||
const dx = toCenter.x - fromCenter.x;
|
||||
const dy = toCenter.y - fromCenter.y;
|
||||
|
||||
const horizontalDistance = Math.abs(dx) / (nodeSize.width / 2);
|
||||
const verticalDistance = Math.abs(dy) / (getNodeHeight(from) / 2);
|
||||
if (horizontalDistance >= verticalDistance) return dx >= 0 ? "right" : "left";
|
||||
return dy >= 0 ? "bottom" : "top";
|
||||
}
|
||||
|
||||
export function getNodeOutcomes(item) {
|
||||
if (item.kind === "Compare") {
|
||||
const outcomes = item.compareOutcomes?.length
|
||||
? item.compareOutcomes
|
||||
: [
|
||||
{ label: "Doğru", targetId: item.nextOnTrue },
|
||||
{ label: "Yanlış", targetId: item.nextOnFalse },
|
||||
];
|
||||
|
||||
return outcomes.slice(0, 4).map((outcome, index) => ({
|
||||
field: `compareOutcomes:${index}`,
|
||||
label: outcome.label || `Durum ${index + 1}`,
|
||||
targetId: outcome.targetId,
|
||||
}));
|
||||
}
|
||||
|
||||
if (item.kind === "Approval") {
|
||||
return [
|
||||
{ field: "nextOnApprove", label: "Onay", targetId: item.nextOnApprove },
|
||||
{ field: "nextOnReject", label: "Red", targetId: item.nextOnReject },
|
||||
];
|
||||
}
|
||||
|
||||
if (item.kind === "End") return [];
|
||||
|
||||
return [
|
||||
{ field: "nextOnStart", label: "Sonraki", targetId: item.nextOnStart },
|
||||
];
|
||||
}
|
||||
|
||||
export function outcomeLabel(field) {
|
||||
if (field?.startsWith("compareOutcomes:")) return "Karşılaştırma durumu";
|
||||
|
||||
return {
|
||||
nextOnStart: "Sonraki",
|
||||
nextOnTrue: "Doğru",
|
||||
nextOnFalse: "Yanlış",
|
||||
nextOnApprove: "Onay",
|
||||
nextOnReject: "Red",
|
||||
}[field];
|
||||
}
|
||||
|
||||
export function addLink(
|
||||
links,
|
||||
criteria,
|
||||
source,
|
||||
targetId,
|
||||
label,
|
||||
type,
|
||||
sourcePort = null,
|
||||
) {
|
||||
if (!targetId) return;
|
||||
const target = criteria.find((item) => item.id === targetId);
|
||||
if (target) {
|
||||
links.push({
|
||||
key: `${source.id}-${target.id}-${type}`,
|
||||
source,
|
||||
target,
|
||||
label,
|
||||
sourcePort,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export function emptyCriteria(kind = "Compare", workflowItemId = null) {
|
||||
return {
|
||||
id: "",
|
||||
workflowItemId,
|
||||
kind,
|
||||
title: defaultTitle(kind),
|
||||
column: "Tutar",
|
||||
operator: ">",
|
||||
compareValue: 5000,
|
||||
approver: "",
|
||||
informPerson: "",
|
||||
nextOnStart: "",
|
||||
nextOnTrue: "",
|
||||
nextOnFalse: "",
|
||||
nextOnApprove: "",
|
||||
nextOnReject: "",
|
||||
compareOutcomes:
|
||||
kind === "Compare"
|
||||
? [emptyCompareOutcome("Durum 1"), emptyCompareOutcome("Durum 2")]
|
||||
: [],
|
||||
positionX: 32,
|
||||
positionY: 150,
|
||||
};
|
||||
}
|
||||
|
||||
export function toCriteriaForm(item) {
|
||||
const sharedPerson = item.approver || item.informPerson || "";
|
||||
|
||||
return {
|
||||
...emptyCriteria(item.kind),
|
||||
...item,
|
||||
approver: sharedPerson,
|
||||
informPerson: sharedPerson,
|
||||
nextOnStart: item.nextOnStart || "",
|
||||
nextOnTrue: item.nextOnTrue || "",
|
||||
nextOnFalse: item.nextOnFalse || "",
|
||||
nextOnApprove: item.nextOnApprove || "",
|
||||
nextOnReject: item.nextOnReject || "",
|
||||
compareOutcomes: item.compareOutcomes?.length
|
||||
? item.compareOutcomes.map(toCompareOutcomeForm)
|
||||
: emptyCriteria(item.kind).compareOutcomes,
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeCriteria(item) {
|
||||
const sharedPerson = item.approver || item.informPerson || "";
|
||||
|
||||
return {
|
||||
...item,
|
||||
id: item.id || null,
|
||||
workflowItemId: item.workflowItemId || null,
|
||||
compareValue: Number(item.compareValue || 0),
|
||||
approver: sharedPerson,
|
||||
informPerson: sharedPerson,
|
||||
positionX: Number(item.positionX || 32),
|
||||
positionY: Number(item.positionY || 150),
|
||||
compareOutcomes: (item.compareOutcomes || [])
|
||||
.slice(0, 4)
|
||||
.filter((outcome) => outcome.label?.trim())
|
||||
.map(normalizeCompareOutcome),
|
||||
};
|
||||
}
|
||||
|
||||
export function defaultTitle(kind) {
|
||||
return {
|
||||
Start: "İş Akışı Başlat",
|
||||
Compare: "Tutar > 5000 TL",
|
||||
Approval: "Onaylanacak Kişi",
|
||||
Inform: "Bilgilendirme Yapılacak Personel",
|
||||
End: "Akışı Bitir",
|
||||
}[kind];
|
||||
}
|
||||
|
||||
export function emptyCompareOutcome(label = "Durum") {
|
||||
return {
|
||||
label,
|
||||
targetId: "",
|
||||
conditions: [{ column: "Tutar", operator: ">", compareValue: 5000 }],
|
||||
};
|
||||
}
|
||||
|
||||
export function toCompareOutcomeForm(outcome) {
|
||||
const conditions = outcome.conditions?.length
|
||||
? outcome.conditions
|
||||
: [
|
||||
{
|
||||
column: outcome.column || "Tutar",
|
||||
operator: outcome.operator || ">",
|
||||
compareValue: outcome.compareValue || 0,
|
||||
},
|
||||
];
|
||||
|
||||
return {
|
||||
label: outcome.label || "",
|
||||
targetId: outcome.targetId || "",
|
||||
conditions: conditions.map((condition) => ({
|
||||
column: condition.column || "Tutar",
|
||||
operator: condition.operator || ">",
|
||||
compareValue: condition.compareValue ?? 0,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeCompareOutcome(outcome) {
|
||||
const conditions = (
|
||||
outcome.conditions?.length
|
||||
? outcome.conditions
|
||||
: [
|
||||
{
|
||||
column: outcome.column || "Tutar",
|
||||
operator: outcome.operator || ">",
|
||||
compareValue: outcome.compareValue || 0,
|
||||
},
|
||||
]
|
||||
)
|
||||
.filter((condition) => condition.operator && condition.compareValue !== "")
|
||||
.map((condition) => ({
|
||||
column: condition.column || "Tutar",
|
||||
operator: condition.operator || ">",
|
||||
compareValue: Number(condition.compareValue || 0),
|
||||
}));
|
||||
|
||||
return {
|
||||
label: outcome.label.trim(),
|
||||
targetId: outcome.targetId || null,
|
||||
conditions,
|
||||
};
|
||||
}
|
||||
|
||||
export function compareOutcomeRuleText(outcome) {
|
||||
const conditions = outcome.conditions?.length
|
||||
? outcome.conditions
|
||||
: outcome.operator
|
||||
? [
|
||||
{
|
||||
column: outcome.column || "Tutar",
|
||||
operator: outcome.operator,
|
||||
compareValue: outcome.compareValue,
|
||||
},
|
||||
]
|
||||
: [];
|
||||
|
||||
return conditions.length
|
||||
? conditions
|
||||
.map(
|
||||
(condition) =>
|
||||
`${condition.column} ${condition.operator} ${formatCompactValue(condition.compareValue)}`,
|
||||
)
|
||||
.join(" ve ")
|
||||
: "Kural yok";
|
||||
}
|
||||
|
||||
export function formatCompactValue(value) {
|
||||
return new Intl.NumberFormat("tr-TR", {
|
||||
maximumFractionDigits: 2,
|
||||
}).format(Number(value || 0));
|
||||
}
|
||||
|
||||
export function criteriaSummary(item) {
|
||||
if (item.kind === "Compare") {
|
||||
return (
|
||||
(item.compareOutcomes || [])
|
||||
.map(
|
||||
(outcome) => `${outcome.label}: ${compareOutcomeRuleText(outcome)}`,
|
||||
)
|
||||
.join(" / ") || "-"
|
||||
);
|
||||
}
|
||||
if (item.kind === "Approval")
|
||||
return item.approver || item.informPerson || "-";
|
||||
if (item.kind === "Inform") return item.approver || item.informPerson || "-";
|
||||
return item.title;
|
||||
}
|
||||
|
||||
export function targetTitle(criteria, id) {
|
||||
if (!id) return "-";
|
||||
const item = criteria.find((candidate) => candidate.id === id);
|
||||
return item ? `${item.id} - ${item.title}` : id;
|
||||
}
|
||||
|
||||
export function statusClass(status) {
|
||||
if (status === "Onay Bekliyor") return "pending";
|
||||
if (status === "Bitti") return "done";
|
||||
if (status === "Bilgilendirildi") return "info";
|
||||
return "";
|
||||
}
|
||||
|
||||
export function formatMoney(value) {
|
||||
return new Intl.NumberFormat("tr-TR", {
|
||||
style: "currency",
|
||||
currency: "TRY",
|
||||
}).format(value || 0);
|
||||
}
|
||||
151
ui/src/views/admin/listForm/workflow/ApprovalDialog.tsx
Normal file
151
ui/src/views/admin/listForm/workflow/ApprovalDialog.tsx
Normal file
|
|
@ -0,0 +1,151 @@
|
|||
import { formatMoney } from "@/utils/workflow/workflowHelpers";
|
||||
import { useState } from "react";
|
||||
import { FiCheck, FiSlash, FiX } from "react-icons/fi";
|
||||
|
||||
const CloseIcon = FiX as any;
|
||||
const CheckIcon = FiCheck as any;
|
||||
const SlashIcon = FiSlash as any;
|
||||
|
||||
export function ApprovalDialog({ busy, criteria, items, onClose, onDecision }) {
|
||||
if (!items.length) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-50 grid place-items-center bg-slate-900/40 p-[18px]"
|
||||
role="presentation"
|
||||
>
|
||||
<section
|
||||
className="max-h-[calc(100vh-36px)] w-[min(560px,100%)] overflow-auto rounded-lg border border-app-line bg-app-surface p-4 shadow-dialog"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="approval-dialog-title"
|
||||
>
|
||||
<div className="mb-3 flex items-start justify-between gap-4 max-[720px]:flex-col max-[720px]:items-stretch">
|
||||
<div>
|
||||
<h2
|
||||
id="approval-dialog-title"
|
||||
className="m-0 text-lg tracking-normal"
|
||||
>
|
||||
Bekleyen Onaylar
|
||||
</h2>
|
||||
<p className="mb-0 mt-1 text-app-muted">
|
||||
Workflow onay adiminda bekliyor.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="w-[38px] justify-center border-app-primary bg-white p-0 text-app-primary"
|
||||
title="Kapat"
|
||||
onClick={onClose}
|
||||
>
|
||||
<CloseIcon />
|
||||
</button>
|
||||
</div>
|
||||
<PendingApprovals
|
||||
items={items}
|
||||
criteria={criteria}
|
||||
busy={busy}
|
||||
showChrome={false}
|
||||
onDecision={onDecision}
|
||||
/>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PendingApprovals({
|
||||
items,
|
||||
criteria,
|
||||
busy,
|
||||
onDecision,
|
||||
showChrome = true,
|
||||
}) {
|
||||
const [notes, setNotes] = useState({});
|
||||
|
||||
const content = (
|
||||
<>
|
||||
{showChrome && (
|
||||
<div className="mb-3 flex items-center justify-between gap-4 max-[720px]:flex-col max-[720px]:items-stretch">
|
||||
<h2 className="m-0 text-lg tracking-normal">Bekleyen Onaylar</h2>
|
||||
<span className="text-sm text-app-muted">
|
||||
{items.length} bekleyen
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="grid gap-2.5">
|
||||
{items.length === 0 && (
|
||||
<p className="m-0 text-app-muted">Bekleyen onay yok.</p>
|
||||
)}
|
||||
{items.map((item) => {
|
||||
const activeStep = criteria.find(
|
||||
(candidate) =>
|
||||
candidate.workflowItemId === item.id &&
|
||||
candidate.id === item.currentNodeId,
|
||||
);
|
||||
|
||||
return (
|
||||
<article
|
||||
key={item.id}
|
||||
className="grid gap-2.5 rounded-lg border border-app-line p-3"
|
||||
>
|
||||
<div>
|
||||
<strong>
|
||||
#{item.id} {item.sorumlu}
|
||||
</strong>
|
||||
{activeStep?.title && (
|
||||
<span className="mt-1 block text-sm font-bold text-app-text">
|
||||
{activeStep.title}
|
||||
</span>
|
||||
)}
|
||||
<span className="mt-1 block text-app-muted">
|
||||
{formatMoney(item.amount)} - Onaylayacak kişi:{" "}
|
||||
{item.assignedApprover}
|
||||
</span>
|
||||
</div>
|
||||
<textarea
|
||||
rows={2}
|
||||
placeholder="Onay/red notu"
|
||||
value={notes[item.id] || ""}
|
||||
onChange={(event) =>
|
||||
setNotes({ ...notes, [item.id]: event.target.value })
|
||||
}
|
||||
/>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<button
|
||||
type="button"
|
||||
className="border-app-green bg-app-green text-white"
|
||||
disabled={busy}
|
||||
onClick={() =>
|
||||
onDecision(item.id, true, notes[item.id] || "Onay verildi.")
|
||||
}
|
||||
>
|
||||
<CheckIcon />
|
||||
Onayla
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="border-app-red bg-app-red text-white"
|
||||
disabled={busy}
|
||||
onClick={() =>
|
||||
onDecision(item.id, false, notes[item.id] || "Red edildi.")
|
||||
}
|
||||
>
|
||||
<SlashIcon />
|
||||
Reddet
|
||||
</button>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
if (!showChrome) return content;
|
||||
|
||||
return (
|
||||
<section className="min-w-0 rounded-lg border border-app-line bg-app-surface p-4">
|
||||
{content}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
514
ui/src/views/admin/listForm/workflow/CriteriaTable.tsx
Normal file
514
ui/src/views/admin/listForm/workflow/CriteriaTable.tsx
Normal file
|
|
@ -0,0 +1,514 @@
|
|||
import React from "react";
|
||||
import { FiSave, FiTrash2 } from "react-icons/fi";
|
||||
import classNames from "classnames";
|
||||
import {
|
||||
columnOptions,
|
||||
kindIcon,
|
||||
kindOptions,
|
||||
operatorOptions,
|
||||
} from "@/utils/workflow/workflowConstants";
|
||||
import {
|
||||
compareOutcomeRuleText,
|
||||
criteriaSummary,
|
||||
emptyCompareOutcome,
|
||||
targetTitle,
|
||||
} from "@/utils/workflow/workflowHelpers";
|
||||
|
||||
const SaveIcon = FiSave as any;
|
||||
const TrashIcon = FiTrash2 as any;
|
||||
|
||||
export function CriteriaTable({
|
||||
criteria,
|
||||
selectedWorkflow,
|
||||
selectedId,
|
||||
activeNodeId,
|
||||
form,
|
||||
busy,
|
||||
onSelect,
|
||||
onChange,
|
||||
onSubmit,
|
||||
onDelete,
|
||||
onAddCriteria,
|
||||
}) {
|
||||
const setField = (name, value) => onChange({ ...form, [name]: value });
|
||||
const targetOptions = [
|
||||
{ value: "", label: "Bağlantı yok" },
|
||||
...criteria
|
||||
.filter((item) => item.id !== form.id)
|
||||
.map((item) => ({ value: item.id, label: `${item.id} - ${item.title}` })),
|
||||
];
|
||||
const updateCompareOutcome = (index, patch) => {
|
||||
const next = [...(form.compareOutcomes || [])];
|
||||
next[index] = { ...next[index], ...patch };
|
||||
setField("compareOutcomes", next);
|
||||
};
|
||||
const updateCompareCondition = (outcomeIndex, conditionIndex, patch) => {
|
||||
const next = [...(form.compareOutcomes || [])];
|
||||
const conditions = [...(next[outcomeIndex]?.conditions || [])];
|
||||
conditions[conditionIndex] = { ...conditions[conditionIndex], ...patch };
|
||||
next[outcomeIndex] = { ...next[outcomeIndex], conditions };
|
||||
setField("compareOutcomes", next);
|
||||
};
|
||||
const addCompareCondition = (outcomeIndex) => {
|
||||
const next = [...(form.compareOutcomes || [])];
|
||||
next[outcomeIndex] = {
|
||||
...next[outcomeIndex],
|
||||
conditions: [
|
||||
...(next[outcomeIndex]?.conditions || []),
|
||||
{ column: "Tutar", operator: ">", compareValue: 0 },
|
||||
],
|
||||
};
|
||||
setField("compareOutcomes", next);
|
||||
};
|
||||
const removeCompareCondition = (outcomeIndex, conditionIndex) => {
|
||||
const next = [...(form.compareOutcomes || [])];
|
||||
const conditions = (next[outcomeIndex]?.conditions || []).filter(
|
||||
(_, index) => index !== conditionIndex,
|
||||
);
|
||||
next[outcomeIndex] = { ...next[outcomeIndex], conditions };
|
||||
setField("compareOutcomes", next);
|
||||
};
|
||||
const removeCompareOutcome = (index) => {
|
||||
setField(
|
||||
"compareOutcomes",
|
||||
(form.compareOutcomes || []).filter(
|
||||
(_, itemIndex) => itemIndex !== index,
|
||||
),
|
||||
);
|
||||
};
|
||||
const targetSelect = (value, onSelectTarget, required = false) => (
|
||||
<select
|
||||
required={required}
|
||||
value={value || ""}
|
||||
onChange={(event) => onSelectTarget(event.target.value)}
|
||||
>
|
||||
{targetOptions.map((option) => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
);
|
||||
const toggleRow = (id) => onSelect(id === selectedId ? "" : id);
|
||||
|
||||
return (
|
||||
<section className="min-w-0 rounded-lg">
|
||||
<form className="block" onSubmit={onSubmit}>
|
||||
<div className="overflow-auto rounded-md border border-app-line">
|
||||
<table className="[&_button]:text-[13px] [&_input]:text-[13px] [&_select]:text-[13px] [&_td]:text-[13px] [&_th]:text-[13px]">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Id</th>
|
||||
<th>Tip</th>
|
||||
<th>Başlık / Kural</th>
|
||||
<th>Bağlantılar</th>
|
||||
<th>İşlem</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{criteria.map((item) => {
|
||||
const isSelected = item.id === selectedId;
|
||||
const isActive = item.id === activeNodeId;
|
||||
const connectionSummary = criteriaConnectionSummary(
|
||||
item,
|
||||
criteria,
|
||||
);
|
||||
|
||||
return (
|
||||
<React.Fragment key={item.id}>
|
||||
<tr
|
||||
className={classNames({
|
||||
"[&>td]:bg-[#eef5ff]": isSelected,
|
||||
"[&>td]:bg-[#f0fdf4] [&>td]:shadow-[inset_0_1px_0_#bbf7d0,inset_0_-1px_0_#bbf7d0]":
|
||||
isActive,
|
||||
"[&>td]:bg-[#e8f7ff]": isSelected && isActive,
|
||||
})}
|
||||
onClick={() => toggleRow(item.id)}
|
||||
>
|
||||
<td>
|
||||
<strong>{item.id}</strong>
|
||||
{isActive && (
|
||||
<span className="ml-2 inline-flex rounded-full bg-[#dcfce7] px-2 py-0.5 text-[11px] font-bold text-[#166534]">
|
||||
Aktif
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td>
|
||||
{
|
||||
kindOptions.find(
|
||||
(option) => option.value === item.kind,
|
||||
)?.label
|
||||
}
|
||||
</td>
|
||||
<td>{criteriaSummaryContent(item)}</td>
|
||||
<td>{connectionSummary}</td>
|
||||
<td>
|
||||
<button
|
||||
type="button"
|
||||
className="ml-1.5 min-h-8 border-[#cfd6e2] bg-white px-2.5 text-[#344054]"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
toggleRow(item.id);
|
||||
}}
|
||||
>
|
||||
{isSelected ? "Kapat" : "Düzenle"}
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
{isSelected && (
|
||||
<tr className="[&>td]:bg-slate-50 [&>td]:p-3.5">
|
||||
<td colSpan={5}>
|
||||
<div className="grid grid-cols-3 gap-2.5 max-[720px]:grid-cols-1">
|
||||
<Field label="Tip" required>
|
||||
<select
|
||||
value={form.kind}
|
||||
onChange={(event) =>
|
||||
setField("kind", event.target.value)
|
||||
}
|
||||
>
|
||||
{kindOptions.map((option) => (
|
||||
<option
|
||||
key={option.value}
|
||||
value={option.value}
|
||||
>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</Field>
|
||||
<Field label="Başlık" required>
|
||||
<input
|
||||
required
|
||||
value={form.title}
|
||||
onChange={(event) =>
|
||||
setField("title", event.target.value)
|
||||
}
|
||||
/>
|
||||
</Field>
|
||||
<Field
|
||||
label="Onaylayacak Kişi"
|
||||
required={
|
||||
form.kind === "Approval" ||
|
||||
form.kind === "Inform"
|
||||
}
|
||||
>
|
||||
<input
|
||||
required={
|
||||
form.kind === "Approval" ||
|
||||
form.kind === "Inform"
|
||||
}
|
||||
value={form.approver}
|
||||
onChange={(event) =>
|
||||
onChange({
|
||||
...form,
|
||||
approver: event.target.value,
|
||||
informPerson: event.target.value,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</Field>
|
||||
{false && (
|
||||
<Field label="Bilgilendirme personeli">
|
||||
<input
|
||||
value={form.informPerson}
|
||||
onChange={(event) =>
|
||||
onChange({
|
||||
...form,
|
||||
approver: event.target.value,
|
||||
informPerson: event.target.value,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</Field>
|
||||
)}
|
||||
|
||||
{(form.kind === "Start" ||
|
||||
form.kind === "Inform") && (
|
||||
<Field label="Sonraki adım" required>
|
||||
{targetSelect(
|
||||
form.nextOnStart,
|
||||
(value) => setField("nextOnStart", value),
|
||||
true,
|
||||
)}
|
||||
</Field>
|
||||
)}
|
||||
|
||||
{form.kind === "Approval" && (
|
||||
<>
|
||||
<Field label="Onay adımı" required>
|
||||
{targetSelect(
|
||||
form.nextOnApprove,
|
||||
(value) => setField("nextOnApprove", value),
|
||||
true,
|
||||
)}
|
||||
</Field>
|
||||
<Field label="Red adımı" required>
|
||||
{targetSelect(
|
||||
form.nextOnReject,
|
||||
(value) => setField("nextOnReject", value),
|
||||
true,
|
||||
)}
|
||||
</Field>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{form.kind === "Compare" && (
|
||||
<div className="grid gap-2.5 rounded-lg border border-app-line bg-slate-50 p-2.5">
|
||||
<div className="flex items-center justify-between gap-2 text-[13px] font-bold text-[#344054]">
|
||||
<span>Karşılaştırma durumları</span>
|
||||
<button
|
||||
type="button"
|
||||
className="ml-1.5 min-h-8 border-[#cfd6e2] bg-white px-2.5 text-[#344054]"
|
||||
disabled={
|
||||
(form.compareOutcomes || []).length >= 4
|
||||
}
|
||||
onClick={() =>
|
||||
setField("compareOutcomes", [
|
||||
...(form.compareOutcomes || []),
|
||||
emptyCompareOutcome(
|
||||
`Durum ${(form.compareOutcomes || []).length + 1}`,
|
||||
),
|
||||
])
|
||||
}
|
||||
>
|
||||
Ekle
|
||||
</button>
|
||||
</div>
|
||||
{(form.compareOutcomes || []).map(
|
||||
(outcome, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="grid gap-2 border-t border-[#e4e7ec] pt-2 first:border-t-0 first:pt-0"
|
||||
>
|
||||
<div className="grid grid-cols-[minmax(130px,0.8fr)_minmax(200px,1.4fr)_auto] items-center gap-2 max-[720px]:grid-cols-1">
|
||||
<input
|
||||
required
|
||||
value={outcome.label}
|
||||
aria-label="Durum adı zorunlu"
|
||||
onChange={(event) =>
|
||||
updateCompareOutcome(index, {
|
||||
label: event.target.value,
|
||||
})
|
||||
}
|
||||
/>
|
||||
{targetSelect(
|
||||
outcome.targetId,
|
||||
(targetId) =>
|
||||
updateCompareOutcome(index, {
|
||||
targetId,
|
||||
}),
|
||||
true,
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="ml-1.5 min-h-8 border-[#cfd6e2] bg-white px-2.5 text-[#344054]"
|
||||
disabled={
|
||||
(form.compareOutcomes || []).length <=
|
||||
2
|
||||
}
|
||||
onClick={() =>
|
||||
removeCompareOutcome(index)
|
||||
}
|
||||
>
|
||||
Sil
|
||||
</button>
|
||||
</div>
|
||||
<div className="grid gap-2.5">
|
||||
{(outcome.conditions || []).map(
|
||||
(condition, conditionIndex) => (
|
||||
<div
|
||||
key={conditionIndex}
|
||||
className="grid grid-cols-[minmax(100px,0.7fr)_82px_minmax(110px,0.8fr)_auto] items-center gap-1.5 max-[720px]:grid-cols-1"
|
||||
>
|
||||
<select
|
||||
value={condition.column}
|
||||
onChange={(event) =>
|
||||
updateCompareCondition(
|
||||
index,
|
||||
conditionIndex,
|
||||
{
|
||||
column: event.target.value,
|
||||
},
|
||||
)
|
||||
}
|
||||
>
|
||||
{columnOptions.map((option) => (
|
||||
<option
|
||||
key={option.value}
|
||||
value={option.value}
|
||||
>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<select
|
||||
value={condition.operator}
|
||||
onChange={(event) =>
|
||||
updateCompareCondition(
|
||||
index,
|
||||
conditionIndex,
|
||||
{
|
||||
operator:
|
||||
event.target.value,
|
||||
},
|
||||
)
|
||||
}
|
||||
>
|
||||
{operatorOptions.map((option) => (
|
||||
<option
|
||||
key={option.value}
|
||||
value={option.value}
|
||||
>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<input
|
||||
required
|
||||
type="number"
|
||||
step="0.01"
|
||||
value={condition.compareValue}
|
||||
onChange={(event) =>
|
||||
updateCompareCondition(
|
||||
index,
|
||||
conditionIndex,
|
||||
{
|
||||
compareValue:
|
||||
event.target.value,
|
||||
},
|
||||
)
|
||||
}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="ml-1.5 min-h-8 border-[#cfd6e2] bg-white px-2.5 text-[#344054]"
|
||||
disabled={
|
||||
(outcome.conditions || [])
|
||||
.length <= 1
|
||||
}
|
||||
onClick={() =>
|
||||
removeCompareCondition(
|
||||
index,
|
||||
conditionIndex,
|
||||
)
|
||||
}
|
||||
>
|
||||
Koşulu sil
|
||||
</button>
|
||||
</div>
|
||||
),
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="ml-1.5 min-h-8 border-[#cfd6e2] bg-white px-2.5 text-[#344054]"
|
||||
onClick={() =>
|
||||
addCompareCondition(index)
|
||||
}
|
||||
>
|
||||
Koşul ekle
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<button type="submit" disabled={busy}>
|
||||
<SaveIcon />
|
||||
Kaydet
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="border-app-red bg-app-red text-white"
|
||||
disabled={busy || !form.id}
|
||||
onClick={() => onDelete(form.id)}
|
||||
>
|
||||
<TrashIcon />
|
||||
Sil
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function Field({ label, children, required = false }) {
|
||||
return (
|
||||
<label className="grid gap-1.5 text-[12px] text-[#344054]">
|
||||
<span>
|
||||
{label}
|
||||
{required && <span className="font-bold text-app-red"> *</span>}
|
||||
</span>
|
||||
{children}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
function criteriaSummaryContent(item) {
|
||||
if (item.kind === "Compare") {
|
||||
const outcomes = item.compareOutcomes || [];
|
||||
if (!outcomes.length) return "-";
|
||||
|
||||
return (
|
||||
<ul className="m-0 grid gap-1 pl-[18px] [&_li]:pl-0.5">
|
||||
{outcomes.map((outcome, index) => (
|
||||
<li key={`${outcome.label || "outcome"}-${index}`}>
|
||||
<strong>{outcome.label || `Durum ${index + 1}`}:</strong>{" "}
|
||||
{compareOutcomeRuleText(outcome)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
|
||||
return criteriaSummary(item);
|
||||
}
|
||||
|
||||
function criteriaConnectionSummary(item, criteria) {
|
||||
if (item.kind === "Compare") {
|
||||
const outcomes = item.compareOutcomes || [];
|
||||
if (!outcomes.length) return "-";
|
||||
|
||||
return (
|
||||
<ul className="m-0 grid gap-1 pl-[18px] [&_li]:pl-0.5">
|
||||
{outcomes.map((outcome, index) => (
|
||||
<li key={`${outcome.label || "target"}-${index}`}>
|
||||
<strong>{outcome.label || `Durum ${index + 1}`}:</strong>{" "}
|
||||
{targetTitle(criteria, outcome.targetId)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
|
||||
if (item.kind === "Approval") {
|
||||
return (
|
||||
<ul className="m-0 grid gap-1 pl-[18px] [&_li]:pl-0.5">
|
||||
<li>
|
||||
<strong>Onay:</strong> {targetTitle(criteria, item.nextOnApprove)}
|
||||
</li>
|
||||
<li>
|
||||
<strong>Red:</strong> {targetTitle(criteria, item.nextOnReject)}
|
||||
</li>
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
|
||||
if (item.kind === "Start" || item.kind === "Inform") {
|
||||
return targetTitle(criteria, item.nextOnStart);
|
||||
}
|
||||
|
||||
return "-";
|
||||
}
|
||||
428
ui/src/views/admin/listForm/workflow/Dashboard.tsx
Normal file
428
ui/src/views/admin/listForm/workflow/Dashboard.tsx
Normal file
|
|
@ -0,0 +1,428 @@
|
|||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import dayjs from "dayjs";
|
||||
import "dayjs/locale/tr";
|
||||
import {
|
||||
buildFitLayout,
|
||||
emptyCriteria,
|
||||
isPendingApproval,
|
||||
normalizeCriteria,
|
||||
toCriteriaForm,
|
||||
} from "@/utils/workflow/workflowHelpers";
|
||||
import { DashboardShell } from "./DashboardShell";
|
||||
import { workflowService, workflowService } from "@/services/workflow.service";
|
||||
|
||||
dayjs.locale("tr");
|
||||
|
||||
export function Dashboard() {
|
||||
const [workflowItems, setWorkflowItems] = useState([]);
|
||||
const [criteria, setCriteria] = useState([]);
|
||||
const [selectedWorkflowId, setSelectedWorkflowId] = useState(null);
|
||||
const [selectedId, setSelectedId] = useState("");
|
||||
const [pendingLink, setPendingLink] = useState(null);
|
||||
const [workflowForm, setWorkflowForm] = useState({
|
||||
sorumlu: "",
|
||||
amount: 7200,
|
||||
});
|
||||
const [editingWorkflowId, setEditingWorkflowId] = useState(null);
|
||||
const [workflowEditForm, setWorkflowEditForm] = useState({
|
||||
sorumlu: "",
|
||||
tarih: "",
|
||||
amount: 0,
|
||||
});
|
||||
const [criteriaForm, setCriteriaForm] = useState(emptyCriteria());
|
||||
const [dragPreview, setDragPreview] = useState(null);
|
||||
const [canvasZoom, setCanvasZoom] = useState(1);
|
||||
const [designerTab, setDesignerTab] = useState("flow");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [approvalDialogWorkflowId, setApprovalDialogWorkflowId] =
|
||||
useState(null);
|
||||
const canvasRef = useRef(null);
|
||||
|
||||
const currentCriteria = useMemo(
|
||||
() => criteria.filter((item) => item.workflowItemId === selectedWorkflowId),
|
||||
[criteria, selectedWorkflowId],
|
||||
);
|
||||
|
||||
const selectedWorkflow = useMemo(
|
||||
() => workflowItems.find((item) => item.id === selectedWorkflowId),
|
||||
[selectedWorkflowId, workflowItems],
|
||||
);
|
||||
|
||||
const selectedCriteria = useMemo(
|
||||
() => currentCriteria.find((item) => item.id === selectedId) ?? null,
|
||||
[currentCriteria, selectedId],
|
||||
);
|
||||
|
||||
const pendingItems = useMemo(
|
||||
() => workflowItems.filter((item) => isPendingApproval(item, criteria)),
|
||||
[criteria, workflowItems],
|
||||
);
|
||||
|
||||
const dialogPendingItems = useMemo(
|
||||
() => pendingItems.filter((item) => item.id === approvalDialogWorkflowId),
|
||||
[approvalDialogWorkflowId, pendingItems],
|
||||
);
|
||||
|
||||
const loadState = useCallback(async () => {
|
||||
const data = await workflowService.getState();
|
||||
setWorkflowItems(data.workflowItems);
|
||||
setCriteria(data.criteria);
|
||||
setSelectedWorkflowId(
|
||||
(current) => current || data.workflowItems[0]?.id || null,
|
||||
);
|
||||
return data;
|
||||
}, []);
|
||||
|
||||
const runAction = useCallback(
|
||||
async (action) => {
|
||||
setBusy(true);
|
||||
try {
|
||||
await action();
|
||||
await loadState();
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
},
|
||||
[loadState],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
loadState();
|
||||
}, [loadState]);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedCriteria) {
|
||||
setCriteriaForm(toCriteriaForm(selectedCriteria));
|
||||
} else if (selectedWorkflowId) {
|
||||
setCriteriaForm(emptyCriteria("Start", selectedWorkflowId));
|
||||
}
|
||||
}, [selectedCriteria, selectedWorkflowId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedWorkflowId || !selectedId) return;
|
||||
|
||||
const selectedStillBelongs = currentCriteria.some(
|
||||
(item) => item.id === selectedId,
|
||||
);
|
||||
if (!selectedStillBelongs) {
|
||||
setSelectedId("");
|
||||
}
|
||||
}, [currentCriteria, selectedId, selectedWorkflowId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!approvalDialogWorkflowId) return;
|
||||
|
||||
const stillPending = pendingItems.some(
|
||||
(item) => item.id === approvalDialogWorkflowId,
|
||||
);
|
||||
if (!stillPending) {
|
||||
setApprovalDialogWorkflowId(null);
|
||||
}
|
||||
}, [approvalDialogWorkflowId, pendingItems]);
|
||||
|
||||
const createWorkflow = (event) => {
|
||||
event.preventDefault();
|
||||
runAction(async () => {
|
||||
const created = await workflowService.createWorkflow({
|
||||
sorumlu: workflowForm.sorumlu,
|
||||
amount: Number(workflowForm.amount),
|
||||
});
|
||||
setWorkflowForm({ sorumlu: "", amount: 7200 });
|
||||
setSelectedWorkflowId(created.id);
|
||||
setSelectedId("");
|
||||
setCriteriaForm(emptyCriteria("Start", created.id));
|
||||
});
|
||||
};
|
||||
|
||||
const beginWorkflowEdit = (item) => {
|
||||
setSelectedWorkflowId(item.id);
|
||||
setPendingLink(null);
|
||||
setSelectedId("");
|
||||
setEditingWorkflowId(item.id);
|
||||
setWorkflowEditForm({
|
||||
sorumlu: item.sorumlu,
|
||||
tarih: dayjs(item.tarih).format("YYYY-MM-DD"),
|
||||
amount: item.amount,
|
||||
});
|
||||
};
|
||||
|
||||
const cancelWorkflowEdit = () => {
|
||||
setEditingWorkflowId(null);
|
||||
setWorkflowEditForm({ sorumlu: "", tarih: "", amount: 0 });
|
||||
};
|
||||
|
||||
const saveWorkflowEdit = (id) => {
|
||||
runAction(async () => {
|
||||
await workflowService.updateWorkflow(id, {
|
||||
sorumlu: workflowEditForm.sorumlu,
|
||||
tarih: workflowEditForm.tarih,
|
||||
amount: Number(workflowEditForm.amount),
|
||||
});
|
||||
cancelWorkflowEdit();
|
||||
});
|
||||
};
|
||||
|
||||
const startWorkflow = useCallback(
|
||||
(id) => {
|
||||
runAction(async () => {
|
||||
await workflowService.startWorkflow(id);
|
||||
const data = await loadState();
|
||||
const startedWorkflow = data.workflowItems.find(
|
||||
(item) => item.id === id,
|
||||
);
|
||||
|
||||
setApprovalDialogWorkflowId(
|
||||
isPendingApproval(startedWorkflow, data.criteria) ? id : null,
|
||||
);
|
||||
});
|
||||
},
|
||||
[loadState, runAction],
|
||||
);
|
||||
|
||||
const saveCriteria = (event) => {
|
||||
event.preventDefault();
|
||||
runAction(async () => {
|
||||
await workflowService.saveCriteria(normalizeCriteria(criteriaForm));
|
||||
setSelectedId("");
|
||||
});
|
||||
};
|
||||
|
||||
const addCriteria = (kind) => {
|
||||
if (!selectedWorkflowId) return;
|
||||
|
||||
setDesignerTab("flow");
|
||||
runAction(async () => {
|
||||
const saved = await workflowService.saveCriteria({
|
||||
...normalizeCriteria(emptyCriteria(kind, selectedWorkflowId)),
|
||||
positionX: 80 + (currentCriteria.length % 5) * 230,
|
||||
positionY: 220 + Math.floor(currentCriteria.length / 5) * 140,
|
||||
});
|
||||
setSelectedId(saved.id);
|
||||
});
|
||||
};
|
||||
|
||||
const deleteSelectedCriteria = useCallback(
|
||||
(criteriaId = selectedId) => {
|
||||
if (!criteriaId || busy) return;
|
||||
|
||||
runAction(async () => {
|
||||
await workflowService.deleteCriteria(criteriaId);
|
||||
setSelectedId("");
|
||||
});
|
||||
},
|
||||
[busy, runAction, selectedId],
|
||||
);
|
||||
|
||||
const disconnectLink = useCallback(
|
||||
(sourceId, outcome) => {
|
||||
if (!sourceId || !outcome || busy) return;
|
||||
|
||||
const source = currentCriteria.find((item) => item.id === sourceId);
|
||||
if (!source) return;
|
||||
|
||||
const next = { ...source };
|
||||
if (outcome.startsWith("compareOutcomes:")) {
|
||||
const outcomeIndex = Number(outcome.split(":")[1]);
|
||||
next.compareOutcomes = [...(source.compareOutcomes || [])];
|
||||
if (next.compareOutcomes[outcomeIndex]) {
|
||||
next.compareOutcomes[outcomeIndex] = {
|
||||
...next.compareOutcomes[outcomeIndex],
|
||||
targetId: null,
|
||||
};
|
||||
}
|
||||
if (outcomeIndex === 0) next.nextOnTrue = null;
|
||||
if (outcomeIndex === 1) next.nextOnFalse = null;
|
||||
} else {
|
||||
next[outcome] = null;
|
||||
}
|
||||
|
||||
runAction(async () => {
|
||||
await workflowService.saveCriteria(normalizeCriteria(next));
|
||||
setPendingLink(null);
|
||||
setSelectedId(sourceId);
|
||||
});
|
||||
},
|
||||
[busy, currentCriteria, runAction],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const deleteWithKeyboard = (event) => {
|
||||
const activeTag = document.activeElement?.tagName?.toLowerCase();
|
||||
const isEditing =
|
||||
["input", "textarea", "select"].includes(activeTag) ||
|
||||
(document.activeElement instanceof HTMLElement &&
|
||||
document.activeElement.isContentEditable);
|
||||
|
||||
if (event.key !== "Delete" || isEditing) return;
|
||||
|
||||
event.preventDefault();
|
||||
if (pendingLink) {
|
||||
disconnectLink(pendingLink.sourceId, pendingLink.outcome);
|
||||
return;
|
||||
}
|
||||
deleteSelectedCriteria();
|
||||
};
|
||||
|
||||
window.addEventListener("keydown", deleteWithKeyboard);
|
||||
return () => window.removeEventListener("keydown", deleteWithKeyboard);
|
||||
}, [deleteSelectedCriteria, disconnectLink, pendingLink]);
|
||||
|
||||
const updateNodePosition = ({ active, delta }) => {
|
||||
setDragPreview(null);
|
||||
|
||||
const item = currentCriteria.find(
|
||||
(candidate) => candidate.id === active.id,
|
||||
);
|
||||
if (!item) return;
|
||||
|
||||
setSelectedId(item.id);
|
||||
if (delta.x === 0 && delta.y === 0) return;
|
||||
|
||||
const next = {
|
||||
...item,
|
||||
positionX: Math.max(12, Math.round(item.positionX + delta.x)),
|
||||
positionY: Math.max(12, Math.round(item.positionY + delta.y)),
|
||||
};
|
||||
|
||||
runAction(async () => {
|
||||
await workflowService.saveCriteria(next);
|
||||
setSelectedId(next.id);
|
||||
});
|
||||
};
|
||||
|
||||
const connectNodes = (sourceId, outcome, targetId) => {
|
||||
const source = currentCriteria.find((item) => item.id === sourceId);
|
||||
if (!source || source.id === targetId) return;
|
||||
|
||||
const next = { ...source };
|
||||
if (outcome.startsWith("compareOutcomes:")) {
|
||||
const outcomeIndex = Number(outcome.split(":")[1]);
|
||||
next.compareOutcomes = [...(source.compareOutcomes || [])];
|
||||
next.compareOutcomes[outcomeIndex] = {
|
||||
...next.compareOutcomes[outcomeIndex],
|
||||
targetId,
|
||||
};
|
||||
if (outcomeIndex === 0) next.nextOnTrue = targetId;
|
||||
if (outcomeIndex === 1) next.nextOnFalse = targetId;
|
||||
} else {
|
||||
next[outcome] = targetId;
|
||||
}
|
||||
|
||||
setPendingLink(null);
|
||||
runAction(async () => {
|
||||
await workflowService.saveCriteria(normalizeCriteria(next));
|
||||
setSelectedId("");
|
||||
});
|
||||
};
|
||||
|
||||
const fitFlowLayout = () => {
|
||||
if (!currentCriteria.length || busy) return;
|
||||
|
||||
const nextPositions = buildFitLayout(currentCriteria);
|
||||
setDesignerTab("flow");
|
||||
setCanvasZoom(1);
|
||||
|
||||
runAction(async () => {
|
||||
for (const item of currentCriteria) {
|
||||
const position = nextPositions.get(item.id);
|
||||
if (!position) continue;
|
||||
|
||||
await workflowService.saveCriteria({
|
||||
...normalizeCriteria(item),
|
||||
positionX: position.x,
|
||||
positionY: position.y,
|
||||
});
|
||||
}
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
canvasRef.current?.scrollTo({ left: 0, top: 0, behavior: "smooth" });
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const selectWorkflow = (item) => {
|
||||
setSelectedWorkflowId(item.id);
|
||||
setPendingLink(null);
|
||||
setSelectedId("");
|
||||
};
|
||||
|
||||
const openCriteriaDetails = (id) => {
|
||||
setSelectedId(id);
|
||||
setPendingLink(null);
|
||||
setDesignerTab("criteria");
|
||||
};
|
||||
|
||||
const clearCanvasSelection = () => {
|
||||
setPendingLink(null);
|
||||
setSelectedId("");
|
||||
};
|
||||
|
||||
const beginLink = (sourceId, outcome) => {
|
||||
setPendingLink({ sourceId, outcome });
|
||||
setSelectedId(sourceId);
|
||||
};
|
||||
|
||||
return (
|
||||
<DashboardShell
|
||||
busy={busy}
|
||||
canvasRef={canvasRef}
|
||||
canvasZoom={canvasZoom}
|
||||
criteria={criteria}
|
||||
criteriaForm={criteriaForm}
|
||||
currentCriteria={currentCriteria}
|
||||
designerTab={designerTab}
|
||||
dialogPendingItems={dialogPendingItems}
|
||||
dragPreview={dragPreview}
|
||||
editingWorkflowId={editingWorkflowId}
|
||||
pendingLink={pendingLink}
|
||||
selectedId={selectedId}
|
||||
selectedWorkflow={selectedWorkflow}
|
||||
selectedWorkflowId={selectedWorkflowId}
|
||||
showApprovalDialog={Boolean(approvalDialogWorkflowId)}
|
||||
workflowEditForm={workflowEditForm}
|
||||
workflowForm={workflowForm}
|
||||
workflowItems={workflowItems}
|
||||
onAddCriteria={addCriteria}
|
||||
onBeginLink={beginLink}
|
||||
onBeginWorkflowEdit={beginWorkflowEdit}
|
||||
onCancelWorkflowEdit={cancelWorkflowEdit}
|
||||
onChangeCriteriaForm={setCriteriaForm}
|
||||
onClearCanvasSelection={clearCanvasSelection}
|
||||
onCloseApprovalDialog={() => setApprovalDialogWorkflowId(null)}
|
||||
onConnectNodes={connectNodes}
|
||||
onCreateWorkflow={createWorkflow}
|
||||
onDecision={(id, approved, note) =>
|
||||
runAction(() => workflowService.decideWorkflow(id, { approved, note }))
|
||||
}
|
||||
onDeleteSelectedCriteria={deleteSelectedCriteria}
|
||||
onDisconnectLink={disconnectLink}
|
||||
onDragMove={(event) =>
|
||||
setDragPreview(
|
||||
event ? { id: event.active.id, delta: event.delta } : null,
|
||||
)
|
||||
}
|
||||
onFitFlowLayout={fitFlowLayout}
|
||||
onOpenCriteriaDetails={openCriteriaDetails}
|
||||
onResetDemo={() => runAction(workflowService.resetDemo)}
|
||||
onSaveCriteria={saveCriteria}
|
||||
onSaveWorkflowEdit={saveWorkflowEdit}
|
||||
onSelectCriteria={setSelectedId}
|
||||
onSelectWorkflow={selectWorkflow}
|
||||
onSetDesignerTab={setDesignerTab}
|
||||
onStartWorkflow={startWorkflow}
|
||||
onUpdateNodePosition={updateNodePosition}
|
||||
onWorkflowEditFormChange={setWorkflowEditForm}
|
||||
onWorkflowFormChange={setWorkflowForm}
|
||||
onZoomIn={() =>
|
||||
setCanvasZoom((current) =>
|
||||
Math.min(1.5, Number((current + 0.1).toFixed(2))),
|
||||
)
|
||||
}
|
||||
onZoomOut={() =>
|
||||
setCanvasZoom((current) =>
|
||||
Math.max(0.6, Number((current - 0.1).toFixed(2))),
|
||||
)
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
117
ui/src/views/admin/listForm/workflow/DashboardShell.tsx
Normal file
117
ui/src/views/admin/listForm/workflow/DashboardShell.tsx
Normal file
|
|
@ -0,0 +1,117 @@
|
|||
import { ApprovalDialog } from "./ApprovalDialog";
|
||||
import { WorkflowDesigner } from "./WorkflowDesigner";
|
||||
import { WorkflowTable } from "./WorkflowTable";
|
||||
|
||||
export function DashboardShell({
|
||||
busy,
|
||||
canvasRef,
|
||||
canvasZoom,
|
||||
criteria,
|
||||
criteriaForm,
|
||||
currentCriteria,
|
||||
designerTab,
|
||||
dialogPendingItems,
|
||||
dragPreview,
|
||||
editingWorkflowId,
|
||||
pendingLink,
|
||||
selectedId,
|
||||
selectedWorkflow,
|
||||
selectedWorkflowId,
|
||||
showApprovalDialog,
|
||||
workflowEditForm,
|
||||
workflowForm,
|
||||
workflowItems,
|
||||
onAddCriteria,
|
||||
onBeginLink,
|
||||
onBeginWorkflowEdit,
|
||||
onCancelWorkflowEdit,
|
||||
onChangeCriteriaForm,
|
||||
onClearCanvasSelection,
|
||||
onCloseApprovalDialog,
|
||||
onConnectNodes,
|
||||
onCreateWorkflow,
|
||||
onDecision,
|
||||
onDeleteSelectedCriteria,
|
||||
onDisconnectLink,
|
||||
onDragMove,
|
||||
onFitFlowLayout,
|
||||
onOpenCriteriaDetails,
|
||||
onResetDemo,
|
||||
onSaveCriteria,
|
||||
onSaveWorkflowEdit,
|
||||
onSelectCriteria,
|
||||
onSelectWorkflow,
|
||||
onSetDesignerTab,
|
||||
onStartWorkflow,
|
||||
onUpdateNodePosition,
|
||||
onWorkflowEditFormChange,
|
||||
onWorkflowFormChange,
|
||||
onZoomIn,
|
||||
onZoomOut,
|
||||
}) {
|
||||
return (
|
||||
<div className="min-h-screen">
|
||||
<main className="grid gap-[18px] p-[18px]">
|
||||
<section className="grid grid-cols-1 gap-[18px]">
|
||||
<WorkflowTable
|
||||
items={workflowItems}
|
||||
criteria={criteria}
|
||||
selectedWorkflowId={selectedWorkflowId}
|
||||
form={workflowForm}
|
||||
busy={busy}
|
||||
onFormChange={onWorkflowFormChange}
|
||||
onSubmit={onCreateWorkflow}
|
||||
editingId={editingWorkflowId}
|
||||
editForm={workflowEditForm}
|
||||
onEditFormChange={onWorkflowEditFormChange}
|
||||
onEdit={onBeginWorkflowEdit}
|
||||
onCancelEdit={onCancelWorkflowEdit}
|
||||
onSaveEdit={onSaveWorkflowEdit}
|
||||
onSelect={onSelectWorkflow}
|
||||
onStart={onStartWorkflow}
|
||||
onResetDemo={onResetDemo}
|
||||
/>
|
||||
</section>
|
||||
|
||||
<WorkflowDesigner
|
||||
busy={busy}
|
||||
canvasRef={canvasRef}
|
||||
canvasZoom={canvasZoom}
|
||||
criteriaForm={criteriaForm}
|
||||
currentCriteria={currentCriteria}
|
||||
designerTab={designerTab}
|
||||
dragPreview={dragPreview}
|
||||
pendingLink={pendingLink}
|
||||
selectedCriteriaId={selectedId}
|
||||
selectedWorkflow={selectedWorkflow}
|
||||
onAddCriteria={onAddCriteria}
|
||||
onBeginLink={onBeginLink}
|
||||
onChangeCriteriaForm={onChangeCriteriaForm}
|
||||
onClearSelection={onClearCanvasSelection}
|
||||
onConnect={onConnectNodes}
|
||||
onDeleteCriteria={onDeleteSelectedCriteria}
|
||||
onDeleteLink={onDisconnectLink}
|
||||
onDragMove={onDragMove}
|
||||
onFitLayout={onFitFlowLayout}
|
||||
onOpenDetails={onOpenCriteriaDetails}
|
||||
onSaveCriteria={onSaveCriteria}
|
||||
onSelectCriteria={onSelectCriteria}
|
||||
onSetDesignerTab={onSetDesignerTab}
|
||||
onUpdateNodePosition={onUpdateNodePosition}
|
||||
onZoomIn={onZoomIn}
|
||||
onZoomOut={onZoomOut}
|
||||
/>
|
||||
</main>
|
||||
|
||||
{showApprovalDialog && (
|
||||
<ApprovalDialog
|
||||
busy={busy}
|
||||
criteria={criteria}
|
||||
items={dialogPendingItems}
|
||||
onClose={onCloseApprovalDialog}
|
||||
onDecision={onDecision}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
859
ui/src/views/admin/listForm/workflow/FlowCanvas.tsx
Normal file
859
ui/src/views/admin/listForm/workflow/FlowCanvas.tsx
Normal file
|
|
@ -0,0 +1,859 @@
|
|||
import { useMemo } from "react";
|
||||
import { useDraggable } from "@dnd-kit/core";
|
||||
import { CSS } from "@dnd-kit/utilities";
|
||||
import classNames from "classnames";
|
||||
import {
|
||||
getNodeHeight,
|
||||
kindIcon,
|
||||
kindOptions,
|
||||
nodeSize,
|
||||
} from "@/utils/workflow/workflowConstants";
|
||||
import {
|
||||
collectLinks,
|
||||
getNodeOutcomes,
|
||||
outcomeLabel,
|
||||
} from "@/utils/workflow/workflowHelpers";
|
||||
|
||||
export function FlowCanvas({
|
||||
currentCriteria,
|
||||
dragPreview,
|
||||
zoom,
|
||||
activeNodeId,
|
||||
selectedId,
|
||||
pendingLink,
|
||||
canvasRef,
|
||||
onSelect,
|
||||
onOpenDetails,
|
||||
onClearSelection,
|
||||
onDelete,
|
||||
onDeleteLink,
|
||||
onBeginLink,
|
||||
onConnect,
|
||||
}) {
|
||||
const canvasWidth = Math.max(
|
||||
1240,
|
||||
...currentCriteria.map(
|
||||
(item) => Number(item.positionX || 0) + nodeSize.width + 260,
|
||||
),
|
||||
);
|
||||
const canvasHeight = Math.max(
|
||||
620,
|
||||
...currentCriteria.map(
|
||||
(item) => Number(item.positionY || 0) + getNodeHeight(item) + 280,
|
||||
),
|
||||
);
|
||||
const arrowCriteria = useMemo(
|
||||
() =>
|
||||
currentCriteria.map((item) =>
|
||||
dragPreview?.id === item.id
|
||||
? {
|
||||
...item,
|
||||
positionX: Number(item.positionX || 0) + dragPreview.delta.x,
|
||||
positionY: Number(item.positionY || 0) + dragPreview.delta.y,
|
||||
}
|
||||
: item,
|
||||
),
|
||||
[currentCriteria, dragPreview],
|
||||
);
|
||||
const links = useMemo(() => collectLinks(arrowCriteria), [arrowCriteria]);
|
||||
|
||||
const handleKeyDown = (event) => {
|
||||
if (event.key !== "Delete") return;
|
||||
event.preventDefault();
|
||||
if (pendingLink) {
|
||||
onDeleteLink(pendingLink.sourceId, pendingLink.outcome);
|
||||
return;
|
||||
}
|
||||
if (!selectedId) return;
|
||||
onDelete(selectedId);
|
||||
};
|
||||
|
||||
const handleCanvasClick = (event) => {
|
||||
if (event.target.closest("[data-flow-node], [data-flow-link]")) return;
|
||||
onClearSelection();
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={canvasRef}
|
||||
className="relative max-h-[68vh] min-h-[620px] overflow-auto rounded-lg border border-app-line"
|
||||
style={{
|
||||
backgroundImage:
|
||||
"linear-gradient(#edf1f6 1px, transparent 1px), linear-gradient(90deg, #edf1f6 1px, transparent 1px)",
|
||||
backgroundSize: "24px 24px",
|
||||
}}
|
||||
tabIndex={0}
|
||||
onClick={handleCanvasClick}
|
||||
onKeyDown={handleKeyDown}
|
||||
>
|
||||
{pendingLink && (
|
||||
<div className="sticky left-2.5 top-2.5 z-50 m-2.5 inline-flex min-h-[34px] items-center rounded-md border border-[#8bb3f1] bg-[#eff6ff] px-3 text-[13px] text-app-primaryDark shadow-lg">
|
||||
{outcomeLabel(pendingLink.outcome)} çıkışı seçildi. Hedef iş akışı
|
||||
adımına tıklayın.
|
||||
</div>
|
||||
)}
|
||||
{currentCriteria.length === 0 && (
|
||||
<div className="sticky left-[18px] top-[18px] z-30 inline-grid max-w-[360px] gap-1 rounded-lg border border-[#cfd6e2] bg-white/95 p-3.5 text-[#475467] shadow-lg">
|
||||
<strong className="text-app-text">Boş canvas</strong>
|
||||
<span>
|
||||
Üstteki butonlardan adım ekleyin, sonra çıkış etiketleriyle
|
||||
bağlantıları kurun.
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
className="relative min-h-[620px] min-w-full"
|
||||
style={{ width: canvasWidth * zoom, height: canvasHeight * zoom }}
|
||||
>
|
||||
<div
|
||||
className="relative min-h-[620px] min-w-full origin-top-left"
|
||||
style={{
|
||||
width: canvasWidth,
|
||||
height: canvasHeight,
|
||||
transform: `scale(${zoom})`,
|
||||
}}
|
||||
>
|
||||
<svg
|
||||
className="pointer-events-none absolute inset-0 z-20 overflow-visible"
|
||||
width={canvasWidth}
|
||||
height={canvasHeight}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<defs>
|
||||
<marker
|
||||
id="arrow-neutral"
|
||||
viewBox="0 0 10 10"
|
||||
markerWidth="9"
|
||||
markerHeight="9"
|
||||
refX="10"
|
||||
refY="5"
|
||||
orient="auto"
|
||||
markerUnits="userSpaceOnUse"
|
||||
>
|
||||
<path d="M0,1 L10,5 L0,9 Z" fill="#475467" />
|
||||
</marker>
|
||||
<marker
|
||||
id="arrow-next"
|
||||
viewBox="0 0 10 10"
|
||||
markerWidth="9"
|
||||
markerHeight="9"
|
||||
refX="10"
|
||||
refY="5"
|
||||
orient="auto"
|
||||
markerUnits="userSpaceOnUse"
|
||||
>
|
||||
<path d="M0,1 L10,5 L0,9 Z" fill="#2563eb" />
|
||||
</marker>
|
||||
<marker
|
||||
id="arrow-approve"
|
||||
viewBox="0 0 10 10"
|
||||
markerWidth="9"
|
||||
markerHeight="9"
|
||||
refX="10"
|
||||
refY="5"
|
||||
orient="auto"
|
||||
markerUnits="userSpaceOnUse"
|
||||
>
|
||||
<path d="M0,1 L10,5 L0,9 Z" fill="#16a34a" />
|
||||
</marker>
|
||||
<marker
|
||||
id="arrow-reject"
|
||||
viewBox="0 0 10 10"
|
||||
markerWidth="9"
|
||||
markerHeight="9"
|
||||
refX="10"
|
||||
refY="5"
|
||||
orient="auto"
|
||||
markerUnits="userSpaceOnUse"
|
||||
>
|
||||
<path d="M0,1 L10,5 L0,9 Z" fill="#dc2626" />
|
||||
</marker>
|
||||
<marker
|
||||
id="arrow-compare"
|
||||
viewBox="0 0 10 10"
|
||||
markerWidth="9"
|
||||
markerHeight="9"
|
||||
refX="10"
|
||||
refY="5"
|
||||
orient="auto"
|
||||
markerUnits="userSpaceOnUse"
|
||||
>
|
||||
<path d="M0,1 L10,5 L0,9 Z" fill="#b45309" />
|
||||
</marker>
|
||||
</defs>
|
||||
{links.map((link) => (
|
||||
<Arrow
|
||||
key={link.key}
|
||||
link={link}
|
||||
criteria={arrowCriteria}
|
||||
pendingLink={pendingLink}
|
||||
onBeginLink={onBeginLink}
|
||||
/>
|
||||
))}
|
||||
</svg>
|
||||
{currentCriteria.map((item) => (
|
||||
<FlowNode
|
||||
key={item.id}
|
||||
item={item}
|
||||
criteria={currentCriteria}
|
||||
links={links}
|
||||
selected={item.id === selectedId}
|
||||
active={item.id === activeNodeId}
|
||||
pendingLink={pendingLink}
|
||||
onSelect={() => {
|
||||
if (pendingLink && pendingLink.sourceId !== item.id) {
|
||||
onConnect(pendingLink.sourceId, pendingLink.outcome, item.id);
|
||||
return;
|
||||
}
|
||||
onSelect(item.id);
|
||||
}}
|
||||
onOpenDetails={() => onOpenDetails(item.id)}
|
||||
onDelete={() => onDelete(item.id)}
|
||||
onBeginLink={onBeginLink}
|
||||
/>
|
||||
))}
|
||||
<svg
|
||||
className="pointer-events-none absolute inset-0 z-[70] overflow-visible"
|
||||
width={canvasWidth}
|
||||
height={canvasHeight}
|
||||
aria-hidden="true"
|
||||
>
|
||||
{links.map((link) => (
|
||||
<ArrowLabel
|
||||
key={`${link.key}-label`}
|
||||
link={link}
|
||||
criteria={arrowCriteria}
|
||||
pendingLink={pendingLink}
|
||||
/>
|
||||
))}
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FlowNode({
|
||||
item,
|
||||
criteria,
|
||||
links,
|
||||
selected,
|
||||
active,
|
||||
pendingLink,
|
||||
onSelect,
|
||||
onOpenDetails,
|
||||
onDelete,
|
||||
onBeginLink,
|
||||
}) {
|
||||
const { attributes, listeners, setNodeRef, transform } = useDraggable({
|
||||
id: item.id,
|
||||
disabled: Boolean(pendingLink),
|
||||
});
|
||||
const Icon = kindIcon[item.kind];
|
||||
const style = {
|
||||
left: item.positionX,
|
||||
top: item.positionY,
|
||||
transform: CSS.Translate.toString(transform),
|
||||
};
|
||||
|
||||
return (
|
||||
<button
|
||||
ref={setNodeRef}
|
||||
type="button"
|
||||
className={classNames(
|
||||
"absolute z-40 grid h-32 w-44 touch-none content-start justify-items-start gap-1 rounded-lg border-2 border-[#667085] bg-white p-2.5 text-left text-app-text shadow-node",
|
||||
{
|
||||
"border-app-primary outline outline-[3px] outline-app-primary/20":
|
||||
selected,
|
||||
"border-green-600 bg-green-50 shadow-[0_0_0_4px_rgba(22,163,74,0.18),0_10px_24px_rgba(22,101,52,0.14)]":
|
||||
active,
|
||||
"h-[158px] border-app-amber": item.kind === "Compare",
|
||||
"border-app-violet": item.kind === "Approval",
|
||||
"border-app-green": item.kind === "Inform",
|
||||
"border-app-red": item.kind === "End",
|
||||
},
|
||||
)}
|
||||
data-flow-node
|
||||
style={style}
|
||||
{...listeners}
|
||||
{...attributes}
|
||||
onPointerUp={(event) => {
|
||||
if (!pendingLink || pendingLink.sourceId === item.id) return;
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
onSelect();
|
||||
}}
|
||||
onClick={onSelect}
|
||||
onDoubleClick={(event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
onOpenDetails();
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key !== "Delete") return;
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
onSelect();
|
||||
onDelete();
|
||||
}}
|
||||
>
|
||||
<span
|
||||
className={classNames("inline-flex items-center gap-1.5 text-xs", {
|
||||
"text-green-800": active,
|
||||
"text-app-muted": !active,
|
||||
})}
|
||||
>
|
||||
<Icon />
|
||||
{kindOptions.find((option) => option.value === item.kind)?.label}
|
||||
</span>
|
||||
<strong className="break-words text-sm leading-tight [overflow-wrap:anywhere]">
|
||||
{item.title}
|
||||
</strong>
|
||||
<small className={active ? "text-green-800" : "text-app-muted"}>
|
||||
{item.id}
|
||||
</small>
|
||||
<div className="mt-0.5 flex max-w-full flex-wrap gap-[3px]">
|
||||
{getNodeOutcomes(item).map((outcome) => (
|
||||
<span
|
||||
key={outcome.field}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
className={classNames(
|
||||
"inline-flex min-h-[19px] max-w-full cursor-crosshair items-center overflow-hidden rounded-full border border-[#cfd6e2] bg-white px-1.5 py-px text-[10px] leading-tight text-[#344054] [text-overflow:ellipsis] [white-space:nowrap]",
|
||||
{
|
||||
"border-app-primary bg-[#eaf2ff] text-app-primary":
|
||||
pendingLink?.sourceId === item.id &&
|
||||
pendingLink?.outcome === outcome.field,
|
||||
},
|
||||
)}
|
||||
onPointerDown={(event) => event.stopPropagation()}
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
onBeginLink(item.id, outcome.field);
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key !== "Enter" && event.key !== " ") return;
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
onBeginLink(item.id, outcome.field);
|
||||
}}
|
||||
>
|
||||
{outcome.label}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
{getNodeOutcomes(item).map((outcome, index, outcomes) => {
|
||||
const link = links.find(
|
||||
(candidate) =>
|
||||
candidate.source.id === item.id &&
|
||||
candidate.sourcePort?.field === outcome.field,
|
||||
);
|
||||
const target = criteria.find(
|
||||
(candidate) => candidate.id === outcome.targetId,
|
||||
);
|
||||
const side = link
|
||||
? sideToward(link.source, link.target)
|
||||
: target
|
||||
? sideToward(item, target)
|
||||
: "right";
|
||||
const sideIndex = link?.sourcePort?.sourceSlotIndex ?? index;
|
||||
const sideCount = link?.sourcePort?.sourceSlotCount ?? outcomes.length;
|
||||
return (
|
||||
<span
|
||||
key={`${outcome.field}-port`}
|
||||
className={classNames(
|
||||
"absolute z-10 h-1 w-1 rounded-full border border-[#475467] bg-white shadow-[0_0_0_1.5px_rgba(255,255,255,0.95)]",
|
||||
portSideClass(side),
|
||||
{
|
||||
"border-app-primary bg-blue-100":
|
||||
pendingLink?.sourceId === item.id &&
|
||||
pendingLink?.outcome === outcome.field,
|
||||
},
|
||||
)}
|
||||
style={getPortStyle(item, side, sideIndex, sideCount)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
{links
|
||||
.filter((link) => link.target.id === item.id)
|
||||
.map((link) => {
|
||||
const side = sideToward(link.target, link.source);
|
||||
return (
|
||||
<span
|
||||
key={`${link.key}-incoming-port`}
|
||||
className={classNames(
|
||||
"absolute h-1 w-1 rounded-full border border-app-muted bg-slate-50 shadow-[0_0_0_1.5px_rgba(255,255,255,0.95)]",
|
||||
portSideClass(side),
|
||||
)}
|
||||
style={getPortStyle(
|
||||
item,
|
||||
side,
|
||||
link.sourcePort?.targetSlotIndex ?? 0,
|
||||
link.sourcePort?.targetSlotCount ?? 1,
|
||||
)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function Arrow({ link, criteria, pendingLink, onBeginLink }) {
|
||||
const route = buildArrowRoute(
|
||||
link.source,
|
||||
link.target,
|
||||
link.sourcePort,
|
||||
criteria,
|
||||
);
|
||||
const d = roundedPolylinePath(route.points);
|
||||
const tone = linkTone(link);
|
||||
const isActive =
|
||||
pendingLink?.sourceId === link.source.id &&
|
||||
pendingLink?.outcome === link.sourcePort?.field;
|
||||
|
||||
const selectLink = (event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
if (!link.sourcePort?.field) return;
|
||||
onBeginLink(link.source.id, link.sourcePort.field);
|
||||
};
|
||||
|
||||
return (
|
||||
<g
|
||||
className={classNames(
|
||||
"group cursor-pointer outline-none",
|
||||
linkToneClass(tone),
|
||||
)}
|
||||
data-flow-link
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={selectLink}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key !== "Enter" && event.key !== " ") return;
|
||||
selectLink(event);
|
||||
}}
|
||||
>
|
||||
<path
|
||||
className="[pointer-events:stroke] [stroke-linecap:butt] [stroke-linejoin:round] [stroke-width:16] stroke-transparent"
|
||||
d={d}
|
||||
fill="none"
|
||||
/>
|
||||
<path
|
||||
className={classNames(
|
||||
"pointer-events-none stroke-white/95 [stroke-linecap:butt] [stroke-linejoin:round] [stroke-width:7] group-hover:stroke-[var(--link-soft)] group-hover:[stroke-width:10] group-focus-visible:stroke-[var(--link-soft)] group-focus-visible:[stroke-width:10]",
|
||||
{ "stroke-[var(--link-soft)] [stroke-width:10]": isActive },
|
||||
)}
|
||||
d={d}
|
||||
fill="none"
|
||||
/>
|
||||
<path
|
||||
className={classNames(
|
||||
"pointer-events-none stroke-[var(--link-color)] [stroke-linecap:butt] [stroke-linejoin:round] [stroke-width:2] group-focus-visible:[stroke-width:3.2]",
|
||||
{ "[stroke-width:3.2]": isActive },
|
||||
)}
|
||||
d={d}
|
||||
fill="none"
|
||||
markerEnd={`url(#arrow-${tone})`}
|
||||
/>
|
||||
</g>
|
||||
);
|
||||
}
|
||||
|
||||
function ArrowLabel({ link, criteria, pendingLink }) {
|
||||
if (!link.label) return null;
|
||||
|
||||
const route = buildArrowRoute(
|
||||
link.source,
|
||||
link.target,
|
||||
link.sourcePort,
|
||||
criteria,
|
||||
);
|
||||
const labelPoint = route.labelPoint;
|
||||
const tone = linkTone(link);
|
||||
const labelWidth = Math.max(38, (link.label || "").length * 6 + 14);
|
||||
const isActive =
|
||||
pendingLink?.sourceId === link.source.id &&
|
||||
pendingLink?.outcome === link.sourcePort?.field;
|
||||
|
||||
return (
|
||||
<g
|
||||
className={classNames(linkToneClass(tone), {
|
||||
"[&_text]:text-[11px] [&_text]:font-bold [&_rect]:fill-white [&_rect]:[stroke-width:1.5]":
|
||||
isActive,
|
||||
})}
|
||||
data-flow-link
|
||||
>
|
||||
<rect
|
||||
className="fill-white/90 stroke-[var(--link-soft)] [stroke-width:1]"
|
||||
x={labelPoint.x - labelWidth / 2}
|
||||
y={labelPoint.y - 12}
|
||||
width={labelWidth}
|
||||
height="18"
|
||||
rx="9"
|
||||
/>
|
||||
<text
|
||||
className="pointer-events-none fill-[var(--link-color)] text-[10px] font-medium"
|
||||
x={labelPoint.x}
|
||||
y={labelPoint.y}
|
||||
textAnchor="middle"
|
||||
>
|
||||
{link.label}
|
||||
</text>
|
||||
</g>
|
||||
);
|
||||
}
|
||||
|
||||
function linkTone(link) {
|
||||
const field = link.sourcePort?.field || "";
|
||||
const label = link.label || "";
|
||||
if (field === "nextOnReject" || label === "Red") return "reject";
|
||||
if (field === "nextOnApprove" || label === "Onay") return "approve";
|
||||
if (field.startsWith("compareOutcomes:") || link.source.kind === "Compare")
|
||||
return "compare";
|
||||
if (field === "nextOnStart") return "next";
|
||||
return "neutral";
|
||||
}
|
||||
|
||||
function linkToneClass(tone) {
|
||||
if (tone === "next") {
|
||||
return "[--link-color:#2563eb] [--link-soft:rgba(37,99,235,0.14)]";
|
||||
}
|
||||
if (tone === "approve") {
|
||||
return "[--link-color:#16a34a] [--link-soft:rgba(22,163,74,0.14)]";
|
||||
}
|
||||
if (tone === "reject") {
|
||||
return "[--link-color:#dc2626] [--link-soft:rgba(220,38,38,0.14)]";
|
||||
}
|
||||
if (tone === "compare") {
|
||||
return "[--link-color:#b45309] [--link-soft:rgba(180,83,9,0.15)]";
|
||||
}
|
||||
return "[--link-color:#475467] [--link-soft:rgba(71,84,103,0.14)]";
|
||||
}
|
||||
|
||||
function portSideClass(side) {
|
||||
if (side === "left" || side === "right") return "-translate-y-1/2";
|
||||
return "-translate-x-1/2";
|
||||
}
|
||||
|
||||
function buildArrowRoute(source, target, sourcePort = null, criteria = []) {
|
||||
const sourceLeft = Number(source.positionX || 0);
|
||||
const sourceTop = Number(source.positionY || 0);
|
||||
const targetLeft = Number(target.positionX || 0);
|
||||
const targetTop = Number(target.positionY || 0);
|
||||
const sourceCenter = {
|
||||
x: sourceLeft + nodeSize.width / 2,
|
||||
y: sourceTop + getNodeHeight(source) / 2,
|
||||
};
|
||||
const targetCenter = {
|
||||
x: targetLeft + nodeSize.width / 2,
|
||||
y: targetTop + getNodeHeight(target) / 2,
|
||||
};
|
||||
|
||||
if (sourcePort) {
|
||||
const sourceSide = sideToward(source, target);
|
||||
const targetSide = sideToward(target, source);
|
||||
const start = getPortPoint(
|
||||
source,
|
||||
sourceSide,
|
||||
sourcePort.sourceSlotIndex ?? sourcePort.index,
|
||||
sourcePort.sourceSlotCount ?? sourcePort.count,
|
||||
0,
|
||||
);
|
||||
const end = getPortPoint(
|
||||
target,
|
||||
targetSide,
|
||||
sourcePort.targetSlotIndex ?? 0,
|
||||
sourcePort.targetSlotCount ?? 1,
|
||||
0,
|
||||
);
|
||||
return buildSideAwareRoute(start, sourceSide, end, targetSide, {
|
||||
sourceSlotIndex: sourcePort.sourceSlotIndex ?? sourcePort.index,
|
||||
sourceSlotCount: sourcePort.sourceSlotCount ?? sourcePort.count,
|
||||
targetSlotIndex: sourcePort.targetSlotIndex ?? 0,
|
||||
targetSlotCount: sourcePort.targetSlotCount ?? 1,
|
||||
routeIndex: sourcePort.routeIndex ?? 0,
|
||||
routeCount: sourcePort.routeCount ?? 1,
|
||||
});
|
||||
}
|
||||
|
||||
const dx = targetCenter.x - sourceCenter.x;
|
||||
const dy = targetCenter.y - sourceCenter.y;
|
||||
|
||||
if (Math.abs(dy) > 70 && Math.abs(dy) > Math.abs(dx) * 0.45) {
|
||||
const start = {
|
||||
x: sourceCenter.x,
|
||||
y: dy > 0 ? sourceTop + getNodeHeight(source) : sourceTop,
|
||||
};
|
||||
const end = {
|
||||
x: targetCenter.x,
|
||||
y: dy > 0 ? targetTop : targetTop + getNodeHeight(target),
|
||||
};
|
||||
const midY = start.y + (end.y - start.y) / 2;
|
||||
|
||||
return {
|
||||
points: [start, { x: start.x, y: midY }, { x: end.x, y: midY }, end],
|
||||
labelPoint: { x: (start.x + end.x) / 2, y: midY - 10 },
|
||||
};
|
||||
}
|
||||
|
||||
if (targetCenter.x >= sourceCenter.x) {
|
||||
const start = { x: sourceLeft + nodeSize.width, y: sourceCenter.y };
|
||||
const end = { x: targetLeft, y: targetCenter.y };
|
||||
const midX = start.x + Math.max(56, (end.x - start.x) / 2);
|
||||
if (Math.abs(start.y - end.y) < 1) {
|
||||
return {
|
||||
points: [start, end],
|
||||
labelPoint: { x: (start.x + end.x) / 2, y: start.y - 12 },
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
points: [start, { x: midX, y: start.y }, { x: midX, y: end.y }, end],
|
||||
labelPoint: { x: midX, y: Math.min(start.y, end.y) - 12 },
|
||||
};
|
||||
}
|
||||
|
||||
const start = { x: sourceLeft, y: sourceCenter.y };
|
||||
const end = { x: targetLeft + nodeSize.width, y: targetCenter.y };
|
||||
const gutterX = Math.min(start.x, end.x) - 80;
|
||||
if (Math.abs(start.y - end.y) < 1) {
|
||||
return {
|
||||
points: [
|
||||
start,
|
||||
{ x: gutterX, y: start.y },
|
||||
{ x: gutterX, y: start.y + 110 },
|
||||
{ x: end.x, y: start.y + 110 },
|
||||
end,
|
||||
],
|
||||
labelPoint: { x: gutterX, y: start.y + 98 },
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
points: [start, { x: gutterX, y: start.y }, { x: gutterX, y: end.y }, end],
|
||||
labelPoint: { x: gutterX, y: Math.min(start.y, end.y) - 12 },
|
||||
};
|
||||
}
|
||||
|
||||
function sideToward(from, to) {
|
||||
const fromLeft = Number(from.positionX || 0);
|
||||
const fromTop = Number(from.positionY || 0);
|
||||
const fromCenter = {
|
||||
x: fromLeft + nodeSize.width / 2,
|
||||
y: fromTop + getNodeHeight(from) / 2,
|
||||
};
|
||||
const toCenter = {
|
||||
x: Number(to.positionX || 0) + nodeSize.width / 2,
|
||||
y: Number(to.positionY || 0) + getNodeHeight(to) / 2,
|
||||
};
|
||||
|
||||
const dx = toCenter.x - fromCenter.x;
|
||||
const dy = toCenter.y - fromCenter.y;
|
||||
|
||||
const horizontalDistance = Math.abs(dx) / (nodeSize.width / 2);
|
||||
const verticalDistance = Math.abs(dy) / (getNodeHeight(from) / 2);
|
||||
if (horizontalDistance >= verticalDistance) return dx >= 0 ? "right" : "left";
|
||||
return dy >= 0 ? "bottom" : "top";
|
||||
}
|
||||
|
||||
function getPortPoint(item, side, index = 0, count = 1, outward = 0) {
|
||||
const left = Number(item.positionX || 0);
|
||||
const top = Number(item.positionY || 0);
|
||||
const height = getNodeHeight(item);
|
||||
const horizontalOffset = getPortOffsetAlong(nodeSize.width, index, count);
|
||||
const verticalOffset = getPortOffsetAlong(height, index, count);
|
||||
|
||||
if (side === "left") return { x: left - outward, y: top + verticalOffset };
|
||||
if (side === "right")
|
||||
return { x: left + nodeSize.width + outward, y: top + verticalOffset };
|
||||
if (side === "top") return { x: left + horizontalOffset, y: top - outward };
|
||||
return { x: left + horizontalOffset, y: top + height + outward };
|
||||
}
|
||||
|
||||
function getPortStyle(item, side, index, count) {
|
||||
const point = getPortPoint(
|
||||
{ ...item, positionX: 0, positionY: 0 },
|
||||
side,
|
||||
index,
|
||||
count,
|
||||
0,
|
||||
);
|
||||
const edgeOffset = -4;
|
||||
const borderOffset = 2;
|
||||
if (side === "left") return { left: edgeOffset, top: point.y - borderOffset };
|
||||
if (side === "right")
|
||||
return { right: edgeOffset, top: point.y - borderOffset };
|
||||
if (side === "top") return { left: point.x - borderOffset, top: edgeOffset };
|
||||
return { left: point.x - borderOffset, bottom: edgeOffset };
|
||||
}
|
||||
|
||||
function getPortOffsetAlong(length, index, count) {
|
||||
if (count <= 1) return length / 2;
|
||||
const gap = 28;
|
||||
const center = length / 2;
|
||||
const offset = (index - (count - 1) / 2) * gap;
|
||||
return Math.min(length - 18, Math.max(18, center + offset));
|
||||
}
|
||||
|
||||
function buildSideAwareRoute(start, startSide, end, endSide, slots: any = {}) {
|
||||
const routeOffset = slotOffset(slots.routeIndex, slots.routeCount, 46);
|
||||
const exit = extendFromSide(start, startSide, 26);
|
||||
const entry = extendFromSide(end, endSide, 38);
|
||||
const startHorizontal = isHorizontalSide(startSide);
|
||||
const endHorizontal = isHorizontalSide(endSide);
|
||||
const points = [start, exit];
|
||||
|
||||
if (!endHorizontal) {
|
||||
const approachY = entry.y;
|
||||
const finalDirection = Math.sign(end.y - entry.y) || 1;
|
||||
const laneDistance = targetLaneDistance(
|
||||
slots.targetSlotIndex,
|
||||
slots.targetSlotCount,
|
||||
);
|
||||
const laneX = end.x;
|
||||
const corridorY = end.y - finalDirection * laneDistance;
|
||||
if (startHorizontal) {
|
||||
const bendX = laneX + routeOffset;
|
||||
points.push(
|
||||
{ x: bendX, y: exit.y },
|
||||
{ x: bendX, y: corridorY },
|
||||
{ x: laneX, y: corridorY },
|
||||
entry,
|
||||
);
|
||||
} else {
|
||||
const midY = Math.round((exit.y + corridorY) / 2 + routeOffset);
|
||||
points.push(
|
||||
{ x: exit.x, y: midY },
|
||||
{ x: laneX, y: midY },
|
||||
{ x: laneX, y: approachY },
|
||||
entry,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
const laneY = end.y;
|
||||
const finalDirection = Math.sign(end.x - entry.x) || 1;
|
||||
const laneDistance = targetLaneDistance(
|
||||
slots.targetSlotIndex,
|
||||
slots.targetSlotCount,
|
||||
);
|
||||
const approachX = end.x - finalDirection * laneDistance;
|
||||
if (startHorizontal) {
|
||||
points.push(
|
||||
{ x: approachX, y: exit.y },
|
||||
{ x: approachX, y: laneY },
|
||||
entry,
|
||||
);
|
||||
} else {
|
||||
const bendY = laneY + routeOffset;
|
||||
points.push(
|
||||
{ x: exit.x, y: bendY },
|
||||
{ x: approachX, y: bendY },
|
||||
{ x: approachX, y: laneY },
|
||||
entry,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
points.push(end);
|
||||
|
||||
const routePoints = compactRoutePoints(points);
|
||||
|
||||
return {
|
||||
points: routePoints,
|
||||
labelPoint: routeLabelPoint(routePoints, slots),
|
||||
};
|
||||
}
|
||||
|
||||
function routeLabelPoint(points, slots: any = {}) {
|
||||
const segments = [];
|
||||
for (let index = 1; index < points.length - 1; index += 1) {
|
||||
const a = points[index - 1];
|
||||
const b = points[index];
|
||||
const horizontal = Math.abs(a.y - b.y) < 0.5;
|
||||
const vertical = Math.abs(a.x - b.x) < 0.5;
|
||||
if (!horizontal && !vertical) continue;
|
||||
const length = horizontal ? Math.abs(a.x - b.x) : Math.abs(a.y - b.y);
|
||||
if (length < 36) continue;
|
||||
segments.push({ a, b, horizontal, length });
|
||||
}
|
||||
|
||||
const segment = segments.sort((a, b) => b.length - a.length)[0];
|
||||
const labelOffset = slotOffset(
|
||||
slots.targetSlotIndex ?? slots.routeIndex,
|
||||
slots.targetSlotCount ?? slots.routeCount,
|
||||
10,
|
||||
);
|
||||
|
||||
if (!segment) {
|
||||
const first = points[0];
|
||||
const last = points[points.length - 1];
|
||||
return {
|
||||
x: Math.round((first.x + last.x) / 2 + labelOffset),
|
||||
y: Math.round((first.y + last.y) / 2 - 10),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
x: Math.round(
|
||||
(segment.a.x + segment.b.x) / 2 +
|
||||
(segment.horizontal ? 0 : 12 + labelOffset),
|
||||
),
|
||||
y: Math.round(
|
||||
(segment.a.y + segment.b.y) / 2 +
|
||||
(segment.horizontal ? -10 + labelOffset : 0),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
function compactRoutePoints(points) {
|
||||
return points.filter((point, index) => {
|
||||
if (index === 0) return true;
|
||||
const previous = points[index - 1];
|
||||
return (
|
||||
Math.abs(point.x - previous.x) > 0.5 ||
|
||||
Math.abs(point.y - previous.y) > 0.5
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
function slotOffset(index = 0, count = 1, gap = 18) {
|
||||
if (count <= 1) return 0;
|
||||
return (index - (count - 1) / 2) * gap;
|
||||
}
|
||||
|
||||
function targetLaneDistance(index = 0, count = 1) {
|
||||
return 40 + Math.max(0, Math.min(index, count - 1)) * 32;
|
||||
}
|
||||
|
||||
function extendFromSide(point, side, distance) {
|
||||
if (side === "left") return { x: point.x - distance, y: point.y };
|
||||
if (side === "right") return { x: point.x + distance, y: point.y };
|
||||
if (side === "top") return { x: point.x, y: point.y - distance };
|
||||
return { x: point.x, y: point.y + distance };
|
||||
}
|
||||
|
||||
function isHorizontalSide(side) {
|
||||
return side === "left" || side === "right";
|
||||
}
|
||||
|
||||
function roundedPolylinePath(points) {
|
||||
const routePoints = points.filter((point, index) => {
|
||||
if (index === 0) return true;
|
||||
const previous = points[index - 1];
|
||||
return (
|
||||
Math.abs(point.x - previous.x) > 0.5 ||
|
||||
Math.abs(point.y - previous.y) > 0.5
|
||||
);
|
||||
});
|
||||
if (routePoints.length < 2) return "";
|
||||
|
||||
let path = `M ${routePoints[0].x} ${routePoints[0].y}`;
|
||||
|
||||
for (let index = 1; index < routePoints.length; index += 1) {
|
||||
const current = routePoints[index];
|
||||
path += ` L ${current.x} ${current.y}`;
|
||||
}
|
||||
|
||||
return path;
|
||||
}
|
||||
244
ui/src/views/admin/listForm/workflow/WorkflowDesigner.tsx
Normal file
244
ui/src/views/admin/listForm/workflow/WorkflowDesigner.tsx
Normal file
|
|
@ -0,0 +1,244 @@
|
|||
import { DndContext } from '@dnd-kit/core'
|
||||
import classNames from 'classnames'
|
||||
import dayjs from 'dayjs'
|
||||
import { FiMaximize2, FiZoomIn, FiZoomOut } from 'react-icons/fi'
|
||||
import { kindIcon, kindOptions } from '@/utils/workflow/workflowConstants'
|
||||
import { CriteriaTable } from './CriteriaTable'
|
||||
import { FlowCanvas } from './FlowCanvas'
|
||||
|
||||
const MaximizeIcon = FiMaximize2 as any
|
||||
const ZoomInIcon = FiZoomIn as any
|
||||
const ZoomOutIcon = FiZoomOut as any
|
||||
|
||||
export function WorkflowDesigner({
|
||||
busy,
|
||||
canvasRef,
|
||||
canvasZoom,
|
||||
criteriaForm,
|
||||
currentCriteria,
|
||||
designerTab,
|
||||
dragPreview,
|
||||
pendingLink,
|
||||
selectedCriteriaId,
|
||||
selectedWorkflow,
|
||||
onAddCriteria,
|
||||
onBeginLink,
|
||||
onChangeCriteriaForm,
|
||||
onClearSelection,
|
||||
onConnect,
|
||||
onDeleteCriteria,
|
||||
onDeleteLink,
|
||||
onDragMove,
|
||||
onFitLayout,
|
||||
onOpenDetails,
|
||||
onSaveCriteria,
|
||||
onSelectCriteria,
|
||||
onSetDesignerTab,
|
||||
onUpdateNodePosition,
|
||||
onZoomIn,
|
||||
onZoomOut,
|
||||
}) {
|
||||
return (
|
||||
<section className="relative min-w-0 rounded-lg border border-app-line bg-app-surface p-4 max-[1080px]:pr-4">
|
||||
<div className="mb-3.5 flex items-center justify-between gap-4 max-[720px]:flex-col max-[720px]:items-stretch">
|
||||
<DesignerTabs activeTab={designerTab} onChange={onSetDesignerTab} />
|
||||
|
||||
{designerTab === 'flow' && (
|
||||
<DesignerToolbar
|
||||
busy={busy}
|
||||
currentCriteria={currentCriteria}
|
||||
zoom={canvasZoom}
|
||||
onAddCriteria={onAddCriteria}
|
||||
onFitLayout={onFitLayout}
|
||||
onZoomIn={onZoomIn}
|
||||
onZoomOut={onZoomOut}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{designerTab === 'flow' && (
|
||||
<div className="block min-w-0 max-[1080px]:grid-cols-1">
|
||||
<DndContext
|
||||
onDragMove={onDragMove}
|
||||
onDragCancel={() => onDragMove(null)}
|
||||
onDragEnd={onUpdateNodePosition}
|
||||
>
|
||||
<FlowCanvas
|
||||
currentCriteria={currentCriteria}
|
||||
dragPreview={dragPreview}
|
||||
zoom={canvasZoom}
|
||||
activeNodeId={selectedWorkflow?.currentNodeId}
|
||||
selectedId={selectedCriteriaId}
|
||||
pendingLink={pendingLink}
|
||||
canvasRef={canvasRef}
|
||||
onSelect={onSelectCriteria}
|
||||
onOpenDetails={onOpenDetails}
|
||||
onClearSelection={onClearSelection}
|
||||
onDelete={onDeleteCriteria}
|
||||
onDeleteLink={onDeleteLink}
|
||||
onBeginLink={onBeginLink}
|
||||
onConnect={onConnect}
|
||||
/>
|
||||
</DndContext>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{designerTab === 'criteria' && (
|
||||
<CriteriaTable
|
||||
criteria={currentCriteria}
|
||||
selectedWorkflow={selectedWorkflow}
|
||||
selectedId={selectedCriteriaId}
|
||||
activeNodeId={selectedWorkflow?.currentNodeId}
|
||||
form={criteriaForm}
|
||||
busy={busy}
|
||||
onSelect={onSelectCriteria}
|
||||
onChange={onChangeCriteriaForm}
|
||||
onSubmit={onSaveCriteria}
|
||||
onDelete={onDeleteCriteria}
|
||||
onAddCriteria={onAddCriteria}
|
||||
/>
|
||||
)}
|
||||
|
||||
{designerTab === 'history' && <ApprovalHistoryTable selectedWorkflow={selectedWorkflow} />}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
function DesignerToolbar({
|
||||
busy,
|
||||
currentCriteria,
|
||||
zoom,
|
||||
onAddCriteria,
|
||||
onFitLayout,
|
||||
onZoomIn,
|
||||
onZoomOut,
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-wrap justify-end gap-2">
|
||||
<button
|
||||
type="button"
|
||||
className="border-app-primary bg-white text-app-primary"
|
||||
disabled={busy || currentCriteria.length === 0}
|
||||
title="Düğümleri okunabilir şekilde yerleştir"
|
||||
onClick={onFitLayout}
|
||||
>
|
||||
<MaximizeIcon />
|
||||
Fit
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="w-[38px] justify-center border-app-primary bg-white p-0 text-app-primary"
|
||||
title="Yakınlaştır"
|
||||
onClick={onZoomIn}
|
||||
>
|
||||
<ZoomInIcon />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="w-[38px] justify-center border-app-primary bg-white p-0 text-app-primary"
|
||||
title="Uzaklaştır"
|
||||
onClick={onZoomOut}
|
||||
>
|
||||
<ZoomOutIcon />
|
||||
</button>
|
||||
<span className="inline-flex min-w-12 items-center justify-center text-[13px] font-bold text-app-muted">
|
||||
{Math.round(zoom * 100)}%
|
||||
</span>
|
||||
{kindOptions.map((option) => {
|
||||
const Icon = kindIcon[option.value]
|
||||
return (
|
||||
<button
|
||||
key={option.value}
|
||||
type="button"
|
||||
className="border-app-primary bg-white text-app-primary"
|
||||
disabled={busy}
|
||||
onClick={() => onAddCriteria(option.value)}
|
||||
>
|
||||
<Icon />
|
||||
{option.label}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function DesignerTabs({ activeTab, onChange }) {
|
||||
return (
|
||||
<div className="inline-flex gap-1 rounded-lg" role="tablist" aria-label="Akış tasarımı">
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
className={classNames(
|
||||
'min-h-8 rounded-md border px-3 py-1.5 bg-transparent text-[#475467] transition-colors',
|
||||
{
|
||||
'border-[#1d4ed8] bg-[#1d4ed8] text-white shadow-sm': activeTab === 'flow',
|
||||
},
|
||||
)}
|
||||
onClick={() => onChange('flow')}
|
||||
>
|
||||
Akış
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
className={classNames(
|
||||
'min-h-8 rounded-md border px-3 py-1.5 bg-transparent text-[#475467] transition-colors',
|
||||
{
|
||||
'border-[#1d4ed8] bg-[#1d4ed8] text-white shadow-sm': activeTab === 'criteria',
|
||||
},
|
||||
)}
|
||||
onClick={() => onChange('criteria')}
|
||||
>
|
||||
Adımlar
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
className={classNames(
|
||||
'min-h-8 rounded-md border px-3 py-1.5 bg-transparent text-[#475467] transition-colors',
|
||||
{
|
||||
'border-[#1d4ed8] bg-[#1d4ed8] text-white shadow-sm': activeTab === 'history',
|
||||
},
|
||||
)}
|
||||
onClick={() => onChange('history')}
|
||||
>
|
||||
Akış Geçmişi
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ApprovalHistoryTable({ selectedWorkflow }: any) {
|
||||
const history = selectedWorkflow?.history || []
|
||||
|
||||
return (
|
||||
<section className="min-w-0 rounded-lg">
|
||||
<div className="overflow-auto rounded-md border border-app-line">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Tarih</th>
|
||||
<th>İşlem</th>
|
||||
<th>Açıklama</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{history.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={3}>Seçili iş akışı için açıklama kaydı yok.</td>
|
||||
</tr>
|
||||
)}
|
||||
{history.map((item: any, index: number) => (
|
||||
<tr key={`${item.time}-${index}`}>
|
||||
<td>{dayjs(item.time).format('DD MMM YYYY HH:mm')}</td>
|
||||
<td>{item.action}</td>
|
||||
<td>{item.note}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
258
ui/src/views/admin/listForm/workflow/WorkflowTable.tsx
Normal file
258
ui/src/views/admin/listForm/workflow/WorkflowTable.tsx
Normal file
|
|
@ -0,0 +1,258 @@
|
|||
import { FiCheck, FiEdit2, FiPlay, FiPlus, FiRefreshCw, FiX } from 'react-icons/fi'
|
||||
import classNames from 'classnames'
|
||||
import dayjs from 'dayjs'
|
||||
import { formatMoney, statusClass } from '@/utils/workflow/workflowHelpers'
|
||||
|
||||
const CheckIcon = FiCheck as any
|
||||
const EditIcon = FiEdit2 as any
|
||||
const PlayIcon = FiPlay as any
|
||||
const PlusIcon = FiPlus as any
|
||||
const RefreshIcon = FiRefreshCw as any
|
||||
const CloseIcon = FiX as any
|
||||
|
||||
export function WorkflowTable({
|
||||
items,
|
||||
criteria,
|
||||
selectedWorkflowId,
|
||||
form,
|
||||
busy,
|
||||
onFormChange,
|
||||
onSubmit,
|
||||
editingId,
|
||||
editForm,
|
||||
onEditFormChange,
|
||||
onEdit,
|
||||
onCancelEdit,
|
||||
onSaveEdit,
|
||||
onSelect,
|
||||
onStart,
|
||||
onResetDemo,
|
||||
}: any) {
|
||||
return (
|
||||
<section className="min-w-0 rounded-lg border border-app-line bg-app-surface p-4">
|
||||
<div className="mb-3 flex items-start justify-between gap-4 max-[720px]:flex-col max-[720px]:items-stretch">
|
||||
<div className="flex items-center gap-3">
|
||||
<h2 className="m-0 text-lg tracking-normal">WorkflowItems Tablosu</h2>
|
||||
|
||||
<span className="rounded-full bg-blue-50 px-2.5 py-1 text-xs font-medium text-blue-700">
|
||||
{items.length} kayıt
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="min-h-10 rounded-md border-[#c7d7f4] bg-[#f8fbff] px-3.5 text-sm font-bold text-app-primary shadow-[0_1px_2px_rgba(16,24,40,0.06)] transition hover:-translate-y-px hover:border-app-primary hover:bg-white hover:shadow-[0_8px_18px_rgba(37,99,235,0.14)] focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-app-primary/35 disabled:hover:translate-y-0 disabled:hover:border-[#c7d7f4] disabled:hover:bg-[#f8fbff] disabled:hover:shadow-[0_1px_2px_rgba(16,24,40,0.06)]"
|
||||
disabled={busy}
|
||||
onClick={onResetDemo}
|
||||
title="Demo verisini yenile"
|
||||
>
|
||||
<RefreshIcon className={busy ? 'animate-spin' : undefined} />
|
||||
<span>Demo Verisini Yenile</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form
|
||||
className="mb-3 grid grid-cols-[minmax(420px,2fr)_minmax(160px,0.6fr)_max-content] items-end gap-2.5 max-[720px]:grid-cols-1"
|
||||
onSubmit={onSubmit}
|
||||
>
|
||||
<label>
|
||||
Başlık
|
||||
<input
|
||||
required
|
||||
value={form.sorumlu}
|
||||
placeholder="Örn. Üretim Süreci"
|
||||
onChange={(event) => onFormChange({ ...form, sorumlu: event.target.value })}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
Fiyat
|
||||
<input
|
||||
required
|
||||
min="0"
|
||||
step="0.01"
|
||||
type="number"
|
||||
value={form.amount}
|
||||
onChange={(event) => onFormChange({ ...form, amount: event.target.value })}
|
||||
/>
|
||||
</label>
|
||||
<button type="submit" disabled={busy}>
|
||||
<PlusIcon />
|
||||
Yeni Akışı Ekle
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div className="overflow-auto rounded-md border border-app-line">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Id</th>
|
||||
<th>Başlık</th>
|
||||
<th>Tarih</th>
|
||||
<th>Fiyat</th>
|
||||
<th>Durum</th>
|
||||
<th>İşlem</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{items.map((item: any) => {
|
||||
const currentStep = criteria.find(
|
||||
(candidate: any) =>
|
||||
candidate.workflowItemId === item.id && candidate.id === item.currentNodeId,
|
||||
)
|
||||
const statusTitle = currentStep?.title || item.durum
|
||||
const canStart = currentStep?.kind === 'Start' || currentStep?.kind === 'Compare'
|
||||
const isEditing = item.id === editingId
|
||||
|
||||
return (
|
||||
<tr
|
||||
key={item.id}
|
||||
className={classNames({
|
||||
'[&>td]:bg-[#eef5ff]': item.id === selectedWorkflowId,
|
||||
})}
|
||||
onClick={() => {
|
||||
if (!isEditing) onSelect(item)
|
||||
}}
|
||||
>
|
||||
<td>{item.id}</td>
|
||||
<td>
|
||||
{isEditing ? (
|
||||
<input
|
||||
required
|
||||
value={editForm.sorumlu}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
onChange={(event) =>
|
||||
onEditFormChange({
|
||||
...editForm,
|
||||
sorumlu: event.target.value,
|
||||
})
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
item.sorumlu
|
||||
)}
|
||||
</td>
|
||||
<td>
|
||||
{isEditing ? (
|
||||
<input
|
||||
required
|
||||
type="date"
|
||||
value={editForm.tarih}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
onChange={(event) =>
|
||||
onEditFormChange({
|
||||
...editForm,
|
||||
tarih: event.target.value,
|
||||
})
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
dayjs(item.tarih).format('DD MMM YYYY')
|
||||
)}
|
||||
</td>
|
||||
<td>
|
||||
{isEditing ? (
|
||||
<input
|
||||
required
|
||||
min="0"
|
||||
step="0.01"
|
||||
type="number"
|
||||
value={editForm.amount}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
onChange={(event) =>
|
||||
onEditFormChange({
|
||||
...editForm,
|
||||
amount: event.target.value,
|
||||
})
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
formatMoney(item.amount)
|
||||
)}
|
||||
</td>
|
||||
<td>
|
||||
<StatusPill status={statusTitle} />
|
||||
</td>
|
||||
<td>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{isEditing ? (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className="border-app-green bg-app-green text-white"
|
||||
disabled={busy || !editForm.sorumlu?.trim() || !editForm.tarih}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation()
|
||||
onSaveEdit(item.id)
|
||||
}}
|
||||
>
|
||||
<CheckIcon />
|
||||
Kaydet
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="border-app-primary bg-white text-app-primary"
|
||||
disabled={busy}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation()
|
||||
onCancelEdit()
|
||||
}}
|
||||
>
|
||||
<CloseIcon />
|
||||
İptal
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className="border-app-primary bg-white text-app-primary"
|
||||
disabled={busy}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation()
|
||||
onEdit(item)
|
||||
}}
|
||||
>
|
||||
<EditIcon />
|
||||
Edit
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="border-app-primary bg-white text-app-primary"
|
||||
disabled={busy || !canStart}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation()
|
||||
onStart(item.id)
|
||||
}}
|
||||
>
|
||||
<PlayIcon />
|
||||
Başlat
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
function StatusPill({ status }: any) {
|
||||
return (
|
||||
<span
|
||||
className={classNames(
|
||||
'inline-flex min-h-3 items-center whitespace-nowrap rounded-full bg-[#eef2f7] px-1.5 py-0.5 text-[#344054]',
|
||||
{
|
||||
'bg-[#fff4df] text-app-amber': statusClass(status) === 'pending',
|
||||
'bg-[#e8f5ee] text-app-green': statusClass(status) === 'done',
|
||||
'bg-[#e7f7f5] text-app-teal': statusClass(status) === 'info',
|
||||
},
|
||||
)}
|
||||
>
|
||||
{status}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
Loading…
Reference in a new issue