ListformWorkflow Application Service

This commit is contained in:
Sedat ÖZTÜRK 2026-05-22 12:40:35 +03:00
parent 9a49f4df0f
commit 85fee9c067
24 changed files with 4007 additions and 3 deletions

View file

@ -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; } = [];
}

View file

@ -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; } = [];
}

View file

@ -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; }
}

View file

@ -0,0 +1,8 @@
namespace Sozsoft.Platform.ListForms.Workflow;
public class DecisionWorkflowDto
{
public bool Approved { get; set; }
public string Note { get; set; }
}

View file

@ -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);
}

View file

@ -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; } = [];
}

View file

@ -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; } = [];
}

View file

@ -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; } = [];
}

View file

@ -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; }
}

View file

@ -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; }
}

View file

@ -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 [];
}
}
}

View file

@ -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; }
}
}

View file

@ -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; }
}
}

View file

@ -93,4 +93,4 @@
]
}
]
}
}

View 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
},
}

View 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;
}

View 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);
}

View 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>
);
}

View 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 "-";
}

View 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))),
)
}
/>
);
}

View 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>
);
}

View 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 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;
}

View 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>ıklama</th>
</tr>
</thead>
<tbody>
{history.length === 0 && (
<tr>
<td colSpan={3}>Seçili akışı için ı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>
)
}

View 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>
)
}