ListFormWorkflow Entegrasyon

This commit is contained in:
Sedat ÖZTÜRK 2026-05-22 14:50:09 +03:00
parent 85fee9c067
commit 49d82d6123
13 changed files with 548 additions and 144 deletions

View file

@ -4320,6 +4320,12 @@
"en": "Widgets",
"tr": "Widget'lar"
},
{
"resourceName": "Platform",
"key": "ListForms.ListFormEdit.TabWorkflow",
"en": "Workflow",
"tr": "İş Akışı"
},
{
"resourceName": "Platform",
"key": "ListForms.ListFormEdit.TabFields",

View file

@ -101,6 +101,7 @@ export const tabVisibilityConfig: Record<string, string[]> = {
'fields',
'editForm',
'widget',
'workflow',
//Chart tabları
'commonSettings',
'series',

View file

@ -66,7 +66,8 @@ export type CreateUpdateWorkflowInput = Partial<WorkflowItemDto> & {
tarih?: string
}
export type SaveCriteriaInput = Partial<WorkflowCriteriaDto> & {
export type SaveCriteriaInput = Omit<Partial<WorkflowCriteriaDto>, 'id'> & {
id?: string | null
workflowItemId: string
}
@ -149,4 +150,3 @@ export const workflowService = {
return response.data
},
}

View file

@ -20,7 +20,7 @@ export const columnOptions = ["Tutar", "Id"].map((value) => ({
label: value,
}));
export const kindIcon = {
export const kindIcon: Record<string, any> = {
Start: FiPlay as any,
Compare: FiGitBranch as any,
Approval: FiCheck as any,

View file

@ -1,6 +1,53 @@
import { getNodeHeight, nodeSize } from "./workflowConstants";
import type {
CompareOutcomeDto,
SaveCriteriaInput,
WorkflowConditionDto,
WorkflowCriteriaDto,
WorkflowItemDto,
} from "@/services/workflow.service";
export function isPendingApproval(item, criteria) {
export type WorkflowCriteriaForm = Partial<WorkflowCriteriaDto> & {
id?: string | null;
workflowItemId: string;
compareOutcomes: CompareOutcomeDto[];
};
export type WorkflowOutcome = {
field: string;
label: string;
targetId?: string | null;
};
export type WorkflowLinkPort = {
field?: string;
index?: number;
count?: number;
sourceSlotIndex?: number;
sourceSlotCount?: number;
targetSlotIndex?: number;
targetSlotCount?: number;
routeIndex?: number;
routeCount?: number;
};
export type WorkflowLink = {
key: string;
source: WorkflowCriteriaDto;
target: WorkflowCriteriaDto;
label: string;
sourcePort: WorkflowLinkPort;
};
type Endpoint = {
link: WorkflowLink;
role: "source" | "target";
};
export function isPendingApproval(
item: WorkflowItemDto | undefined | null,
criteria: WorkflowCriteriaDto[],
) {
if (!item) return false;
return criteria.some(
@ -11,14 +58,14 @@ export function isPendingApproval(item, criteria) {
);
}
export function buildFitLayout(criteria) {
export function buildFitLayout(criteria: WorkflowCriteriaDto[]) {
const links = collectLinks(criteria);
const rankById = buildTraversalRanks(criteria, links);
const groups = new Map<any, any[]>();
const groups = new Map<number, WorkflowCriteriaDto[]>();
criteria.forEach((item) => {
const column = fitColumn(item);
if (!groups.has(column)) groups.set(column, []);
groups.get(column).push(item);
groups.get(column)?.push(item);
});
const sortedColumns = [...groups.keys()].sort((a, b) => a - b);
@ -34,12 +81,12 @@ export function buildFitLayout(criteria) {
const top = 72;
const left = 72;
const xGap = 128;
const positions = new Map();
const positions = new Map<string, { x: number; y: number }>();
sortedColumns.forEach((column, columnIndex) => {
const items = groups
.get(column)
.sort((a, b) => compareLayoutNodes(a, b, rankById));
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;
@ -57,8 +104,8 @@ export function buildFitLayout(criteria) {
return positions;
}
function fitColumn(item) {
const priority = {
function fitColumn(item: WorkflowCriteriaDto) {
const priority: Record<string, number> = {
Start: 0,
Compare: 1,
Approval: 2,
@ -69,16 +116,25 @@ function fitColumn(item) {
return priority[item.kind] ?? 2;
}
function compareLayoutNodes(a, b, rankById = new Map()) {
function compareLayoutNodes(
a: WorkflowCriteriaDto,
b: WorkflowCriteriaDto,
rankById = new Map<string, number>(),
) {
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, []]));
function buildTraversalRanks(
criteria: WorkflowCriteriaDto[],
links: WorkflowLink[],
) {
const rankById = new Map<string, number>();
const outgoing = new Map<string, string[]>(
criteria.map((item) => [item.id, []]),
);
links.forEach((link) => {
outgoing.get(link.source.id)?.push(link.target.id);
});
@ -90,6 +146,7 @@ function buildTraversalRanks(criteria, links) {
while (queue.length) {
const id = queue.shift();
if (!id) continue;
if (rankById.has(id)) continue;
rankById.set(id, rankById.size);
@ -105,8 +162,8 @@ function buildTraversalRanks(criteria, links) {
return rankById;
}
export function collectLinks(criteria) {
const links = [];
export function collectLinks(criteria: WorkflowCriteriaDto[]) {
const links: WorkflowLink[] = [];
criteria.forEach((source) => {
if (source.kind === "Compare" && source.compareOutcomes?.length) {
source.compareOutcomes.forEach((outcome, index) => {
@ -156,12 +213,15 @@ export function collectLinks(criteria) {
return assignLinkSlots(links, criteria);
}
export function assignLinkSlots(links, criteria) {
const endpointGroups = new Map();
const addEndpoint = (nodeId, side, endpoint) => {
export function assignLinkSlots(
links: WorkflowLink[],
criteria: WorkflowCriteriaDto[],
) {
const endpointGroups = new Map<string, Endpoint[]>();
const addEndpoint = (nodeId: string, side: string, endpoint: Endpoint) => {
const key = `${nodeId}:${side}`;
if (!endpointGroups.has(key)) endpointGroups.set(key, []);
endpointGroups.get(key).push(endpoint);
endpointGroups.get(key)?.push(endpoint);
};
links.forEach((link) => {
@ -195,7 +255,7 @@ export function assignLinkSlots(links, criteria) {
return links;
}
function sideToward(from, to) {
function sideToward(from: WorkflowCriteriaDto, to: WorkflowCriteriaDto) {
const fromLeft = Number(from.positionX || 0);
const fromTop = Number(from.positionY || 0);
const fromCenter = {
@ -216,7 +276,7 @@ function sideToward(from, to) {
return dy >= 0 ? "bottom" : "top";
}
export function getNodeOutcomes(item) {
export function getNodeOutcomes(item: WorkflowCriteriaDto): WorkflowOutcome[] {
if (item.kind === "Compare") {
const outcomes = item.compareOutcomes?.length
? item.compareOutcomes
@ -246,26 +306,28 @@ export function getNodeOutcomes(item) {
];
}
export function outcomeLabel(field) {
export function outcomeLabel(field?: string) {
if (field?.startsWith("compareOutcomes:")) return "Karşılaştırma durumu";
return {
const labels: Record<string, string> = {
nextOnStart: "Sonraki",
nextOnTrue: "Doğru",
nextOnFalse: "Yanlış",
nextOnApprove: "Onay",
nextOnReject: "Red",
}[field];
};
return field ? labels[field] : undefined;
}
export function addLink(
links,
criteria,
source,
targetId,
label,
type,
sourcePort = null,
links: WorkflowLink[],
criteria: WorkflowCriteriaDto[],
source: WorkflowCriteriaDto,
targetId: string | null | undefined,
label: string,
type: string,
sourcePort: WorkflowLinkPort = {},
) {
if (!targetId) return;
const target = criteria.find((item) => item.id === targetId);
@ -280,7 +342,10 @@ export function addLink(
}
}
export function emptyCriteria(kind = "Compare", workflowItemId = null) {
export function emptyCriteria(
kind = "Compare",
workflowItemId = "",
): WorkflowCriteriaForm {
return {
id: "",
workflowItemId,
@ -305,7 +370,7 @@ export function emptyCriteria(kind = "Compare", workflowItemId = null) {
};
}
export function toCriteriaForm(item) {
export function toCriteriaForm(item: WorkflowCriteriaDto): WorkflowCriteriaForm {
const sharedPerson = item.approver || item.informPerson || "";
return {
@ -324,13 +389,13 @@ export function toCriteriaForm(item) {
};
}
export function normalizeCriteria(item) {
export function normalizeCriteria(item: WorkflowCriteriaForm): SaveCriteriaInput {
const sharedPerson = item.approver || item.informPerson || "";
return {
...item,
id: item.id || null,
workflowItemId: item.workflowItemId || null,
workflowItemId: item.workflowItemId || "",
compareValue: Number(item.compareValue || 0),
approver: sharedPerson,
informPerson: sharedPerson,
@ -343,17 +408,17 @@ export function normalizeCriteria(item) {
};
}
export function defaultTitle(kind) {
export function defaultTitle(kind: string) {
return {
Start: "İş Akışı Başlat",
Compare: "Tutar > 5000 TL",
Approval: "Onaylanacak Kişi",
Inform: "Bilgilendirme Yapılacak Personel",
End: "Akışı Bitir",
}[kind];
}[kind] ?? "İş Akışı Adımı";
}
export function emptyCompareOutcome(label = "Durum") {
export function emptyCompareOutcome(label = "Durum"): CompareOutcomeDto {
return {
label,
targetId: "",
@ -361,7 +426,12 @@ export function emptyCompareOutcome(label = "Durum") {
};
}
export function toCompareOutcomeForm(outcome) {
export function toCompareOutcomeForm(
outcome: Partial<CompareOutcomeDto> &
Partial<WorkflowConditionDto> & {
conditions?: Partial<WorkflowConditionDto>[];
},
): CompareOutcomeDto {
const conditions = outcome.conditions?.length
? outcome.conditions
: [
@ -383,7 +453,12 @@ export function toCompareOutcomeForm(outcome) {
};
}
export function normalizeCompareOutcome(outcome) {
export function normalizeCompareOutcome(
outcome: Partial<CompareOutcomeDto> &
Partial<WorkflowConditionDto> & {
conditions?: Partial<WorkflowConditionDto>[];
},
): CompareOutcomeDto {
const conditions = (
outcome.conditions?.length
? outcome.conditions
@ -395,7 +470,10 @@ export function normalizeCompareOutcome(outcome) {
},
]
)
.filter((condition) => condition.operator && condition.compareValue !== "")
.filter(
(condition) =>
condition.operator && String(condition.compareValue ?? "") !== "",
)
.map((condition) => ({
column: condition.column || "Tutar",
operator: condition.operator || ">",
@ -403,13 +481,15 @@ export function normalizeCompareOutcome(outcome) {
}));
return {
label: outcome.label.trim(),
label: (outcome.label || "").trim(),
targetId: outcome.targetId || null,
conditions,
};
}
export function compareOutcomeRuleText(outcome) {
export function compareOutcomeRuleText(
outcome: Partial<CompareOutcomeDto> & Partial<WorkflowConditionDto>,
) {
const conditions = outcome.conditions?.length
? outcome.conditions
: outcome.operator
@ -432,13 +512,13 @@ export function compareOutcomeRuleText(outcome) {
: "Kural yok";
}
export function formatCompactValue(value) {
export function formatCompactValue(value: number | string | null | undefined) {
return new Intl.NumberFormat("tr-TR", {
maximumFractionDigits: 2,
}).format(Number(value || 0));
}
export function criteriaSummary(item) {
export function criteriaSummary(item: WorkflowCriteriaDto) {
if (item.kind === "Compare") {
return (
(item.compareOutcomes || [])
@ -454,22 +534,25 @@ export function criteriaSummary(item) {
return item.title;
}
export function targetTitle(criteria, id) {
export function targetTitle(
criteria: WorkflowCriteriaDto[],
id?: string | null,
) {
if (!id) return "-";
const item = criteria.find((candidate) => candidate.id === id);
return item ? `${item.id} - ${item.title}` : id;
}
export function statusClass(status) {
export function statusClass(status?: string) {
if (status === "Onay Bekliyor") return "pending";
if (status === "Bitti") return "done";
if (status === "Bilgilendirildi") return "info";
return "";
}
export function formatMoney(value) {
export function formatMoney(value?: number | string | null) {
return new Intl.NumberFormat("tr-TR", {
style: "currency",
currency: "TRY",
}).format(value || 0);
}).format(Number(value || 0));
}

View file

@ -53,6 +53,7 @@ import FormTabRow from './FormTabRow'
import FormTabGantt from './FormTabGantt'
import FormTabScheduler from './FormTabScheduler'
import { APP_NAME } from '@/constants/app.constant'
import { FormTabWorkflow } from './FormTabWorkflow'
export interface FormEditProps {
onSubmit: (
@ -271,6 +272,9 @@ const FormEdit = () => {
{visibleTabs.includes('widget') && (
<TabNav value="widget">{translate('::ListForms.ListFormEdit.TabWidgets')}</TabNav>
)}
{visibleTabs.includes('workflow') && (
<TabNav value="workflow">{translate('::ListForms.ListFormEdit.TabWorkflow')}</TabNav>
)}
{/* Chart Tabs */}
{visibleTabs.includes('commonSettings') && (
@ -387,6 +391,9 @@ const FormEdit = () => {
<TabContent value="widget" className="px-2">
<FormTabWidgets listFormCode={listFormCode} />
</TabContent>
<TabContent value="workflow" className="px-2">
<FormTabWorkflow listFormCode={listFormCode} />
</TabContent>
<TabContent value="fields" className="px-2">
<FormFields
listFormCode={listFormCode}

View file

@ -1,4 +1,5 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import type { FormEvent } from "react";
import dayjs from "dayjs";
import "dayjs/locale/tr";
import {
@ -7,36 +8,71 @@ import {
isPendingApproval,
normalizeCriteria,
toCriteriaForm,
type WorkflowCriteriaForm,
} from "@/utils/workflow/workflowHelpers";
import { DashboardShell } from "./DashboardShell";
import { workflowService, workflowService } from "@/services/workflow.service";
import {
workflowService,
type WorkflowCriteriaDto,
type WorkflowItemDto,
} from "@/services/workflow.service";
import { DashboardShell } from "../workflow/DashboardShell";
dayjs.locale("tr");
export function Dashboard() {
const [workflowItems, setWorkflowItems] = useState([]);
const [criteria, setCriteria] = useState([]);
const [selectedWorkflowId, setSelectedWorkflowId] = useState(null);
type WorkflowForm = {
sorumlu: string;
amount: number | string;
};
type WorkflowEditForm = WorkflowForm & {
tarih: string;
};
type PendingLink = {
sourceId: string;
outcome: string;
} | null;
type DragPreview = {
id: string;
delta: { x: number; y: number };
} | null;
type DragEndEvent = {
active: { id: string };
delta: { x: number; y: number };
};
export function FormTabWorkflow(props: { listFormCode: string }) {
const [workflowItems, setWorkflowItems] = useState<WorkflowItemDto[]>([]);
const [criteria, setCriteria] = useState<WorkflowCriteriaDto[]>([]);
const [selectedWorkflowId, setSelectedWorkflowId] = useState<string | null>(
null,
);
const [selectedId, setSelectedId] = useState("");
const [pendingLink, setPendingLink] = useState(null);
const [workflowForm, setWorkflowForm] = useState({
const [pendingLink, setPendingLink] = useState<PendingLink>(null);
const [workflowForm, setWorkflowForm] = useState<WorkflowForm>({
sorumlu: "",
amount: 7200,
});
const [editingWorkflowId, setEditingWorkflowId] = useState(null);
const [workflowEditForm, setWorkflowEditForm] = useState({
const [editingWorkflowId, setEditingWorkflowId] = useState<string | null>(
null,
);
const [workflowEditForm, setWorkflowEditForm] = useState<WorkflowEditForm>({
sorumlu: "",
tarih: "",
amount: 0,
});
const [criteriaForm, setCriteriaForm] = useState(emptyCriteria());
const [dragPreview, setDragPreview] = useState(null);
const [criteriaForm, setCriteriaForm] = useState<WorkflowCriteriaForm>(
emptyCriteria(),
);
const [dragPreview, setDragPreview] = useState<DragPreview>(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);
useState<string | null>(null);
const canvasRef = useRef<HTMLDivElement | null>(null);
const currentCriteria = useMemo(
() => criteria.filter((item) => item.workflowItemId === selectedWorkflowId),
@ -74,7 +110,7 @@ export function Dashboard() {
}, []);
const runAction = useCallback(
async (action) => {
async (action: () => Promise<unknown>) => {
setBusy(true);
try {
await action();
@ -120,7 +156,7 @@ export function Dashboard() {
}
}, [approvalDialogWorkflowId, pendingItems]);
const createWorkflow = (event) => {
const createWorkflow = (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
runAction(async () => {
const created = await workflowService.createWorkflow({
@ -134,7 +170,7 @@ export function Dashboard() {
});
};
const beginWorkflowEdit = (item) => {
const beginWorkflowEdit = (item: WorkflowItemDto) => {
setSelectedWorkflowId(item.id);
setPendingLink(null);
setSelectedId("");
@ -151,7 +187,7 @@ export function Dashboard() {
setWorkflowEditForm({ sorumlu: "", tarih: "", amount: 0 });
};
const saveWorkflowEdit = (id) => {
const saveWorkflowEdit = (id: string) => {
runAction(async () => {
await workflowService.updateWorkflow(id, {
sorumlu: workflowEditForm.sorumlu,
@ -163,7 +199,7 @@ export function Dashboard() {
};
const startWorkflow = useCallback(
(id) => {
(id: string) => {
runAction(async () => {
await workflowService.startWorkflow(id);
const data = await loadState();
@ -179,7 +215,7 @@ export function Dashboard() {
[loadState, runAction],
);
const saveCriteria = (event) => {
const saveCriteria = (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
runAction(async () => {
await workflowService.saveCriteria(normalizeCriteria(criteriaForm));
@ -187,7 +223,7 @@ export function Dashboard() {
});
};
const addCriteria = (kind) => {
const addCriteria = (kind: string) => {
if (!selectedWorkflowId) return;
setDesignerTab("flow");
@ -202,7 +238,7 @@ export function Dashboard() {
};
const deleteSelectedCriteria = useCallback(
(criteriaId = selectedId) => {
(criteriaId: string = selectedId) => {
if (!criteriaId || busy) return;
runAction(async () => {
@ -214,17 +250,17 @@ export function Dashboard() {
);
const disconnectLink = useCallback(
(sourceId, outcome) => {
(sourceId: string, outcome: string) => {
if (!sourceId || !outcome || busy) return;
const source = currentCriteria.find((item) => item.id === sourceId);
if (!source) return;
const next = { ...source };
const next: WorkflowCriteriaForm = toCriteriaForm(source);
if (outcome.startsWith("compareOutcomes:")) {
const outcomeIndex = Number(outcome.split(":")[1]);
next.compareOutcomes = [...(source.compareOutcomes || [])];
if (next.compareOutcomes[outcomeIndex]) {
if (next.compareOutcomes?.[outcomeIndex]) {
next.compareOutcomes[outcomeIndex] = {
...next.compareOutcomes[outcomeIndex],
targetId: null,
@ -233,7 +269,7 @@ export function Dashboard() {
if (outcomeIndex === 0) next.nextOnTrue = null;
if (outcomeIndex === 1) next.nextOnFalse = null;
} else {
next[outcome] = null;
(next as Record<string, unknown>)[outcome] = null;
}
runAction(async () => {
@ -246,10 +282,10 @@ export function Dashboard() {
);
useEffect(() => {
const deleteWithKeyboard = (event) => {
const deleteWithKeyboard = (event: globalThis.KeyboardEvent) => {
const activeTag = document.activeElement?.tagName?.toLowerCase();
const isEditing =
["input", "textarea", "select"].includes(activeTag) ||
Boolean(activeTag && ["input", "textarea", "select"].includes(activeTag)) ||
(document.activeElement instanceof HTMLElement &&
document.activeElement.isContentEditable);
@ -267,7 +303,7 @@ export function Dashboard() {
return () => window.removeEventListener("keydown", deleteWithKeyboard);
}, [deleteSelectedCriteria, disconnectLink, pendingLink]);
const updateNodePosition = ({ active, delta }) => {
const updateNodePosition = ({ active, delta }: DragEndEvent) => {
setDragPreview(null);
const item = currentCriteria.find(
@ -290,11 +326,11 @@ export function Dashboard() {
});
};
const connectNodes = (sourceId, outcome, targetId) => {
const connectNodes = (sourceId: string, outcome: string, targetId: string) => {
const source = currentCriteria.find((item) => item.id === sourceId);
if (!source || source.id === targetId) return;
const next = { ...source };
const next: WorkflowCriteriaForm = toCriteriaForm(source);
if (outcome.startsWith("compareOutcomes:")) {
const outcomeIndex = Number(outcome.split(":")[1]);
next.compareOutcomes = [...(source.compareOutcomes || [])];
@ -305,7 +341,7 @@ export function Dashboard() {
if (outcomeIndex === 0) next.nextOnTrue = targetId;
if (outcomeIndex === 1) next.nextOnFalse = targetId;
} else {
next[outcome] = targetId;
(next as Record<string, unknown>)[outcome] = targetId;
}
setPendingLink(null);
@ -340,13 +376,13 @@ export function Dashboard() {
});
};
const selectWorkflow = (item) => {
const selectWorkflow = (item: WorkflowItemDto) => {
setSelectedWorkflowId(item.id);
setPendingLink(null);
setSelectedId("");
};
const openCriteriaDetails = (id) => {
const openCriteriaDetails = (id: string) => {
setSelectedId(id);
setPendingLink(null);
setDesignerTab("criteria");
@ -357,7 +393,7 @@ export function Dashboard() {
setSelectedId("");
};
const beginLink = (sourceId, outcome) => {
const beginLink = (sourceId: string, outcome: string) => {
setPendingLink({ sourceId, outcome });
setSelectedId(sourceId);
};
@ -391,12 +427,12 @@ export function Dashboard() {
onCloseApprovalDialog={() => setApprovalDialogWorkflowId(null)}
onConnectNodes={connectNodes}
onCreateWorkflow={createWorkflow}
onDecision={(id, approved, note) =>
onDecision={(id: string, approved: boolean, note: string) =>
runAction(() => workflowService.decideWorkflow(id, { approved, note }))
}
onDeleteSelectedCriteria={deleteSelectedCriteria}
onDisconnectLink={disconnectLink}
onDragMove={(event) =>
onDragMove={(event: DragEndEvent | null) =>
setDragPreview(
event ? { id: event.active.id, delta: event.delta } : null,
)

View file

@ -1,12 +1,30 @@
import { formatMoney } from "@/utils/workflow/workflowHelpers";
import { useState } from "react";
import { FiCheck, FiSlash, FiX } from "react-icons/fi";
import type {
WorkflowCriteriaDto,
WorkflowItemDto,
} from "@/services/workflow.service";
const CloseIcon = FiX as any;
const CheckIcon = FiCheck as any;
const SlashIcon = FiSlash as any;
export function ApprovalDialog({ busy, criteria, items, onClose, onDecision }) {
type ApprovalDialogProps = {
busy: boolean;
criteria: WorkflowCriteriaDto[];
items: WorkflowItemDto[];
onClose: () => void;
onDecision: (id: string, approved: boolean, note: string) => void;
};
export function ApprovalDialog({
busy,
criteria,
items,
onClose,
onDecision,
}: ApprovalDialogProps) {
if (!items.length) return null;
return (
@ -59,8 +77,8 @@ function PendingApprovals({
busy,
onDecision,
showChrome = true,
}) {
const [notes, setNotes] = useState({});
}: Omit<ApprovalDialogProps, "onClose"> & { showChrome?: boolean }) {
const [notes, setNotes] = useState<Record<string, string>>({});
const content = (
<>

View file

@ -13,10 +13,29 @@ import {
emptyCompareOutcome,
targetTitle,
} from "@/utils/workflow/workflowHelpers";
import type {
CompareOutcomeDto,
WorkflowCriteriaDto,
WorkflowItemDto,
} from "@/services/workflow.service";
const SaveIcon = FiSave as any;
const TrashIcon = FiTrash2 as any;
type CriteriaTableProps = {
criteria: WorkflowCriteriaDto[];
selectedWorkflow?: WorkflowItemDto | null;
selectedId: string;
activeNodeId?: string;
form: any;
busy: boolean;
onSelect: (id: string) => void;
onChange: (form: any) => void;
onSubmit: (event: React.FormEvent<HTMLFormElement>) => void;
onDelete: (id: string) => void;
onAddCriteria?: (kind: string) => void;
};
export function CriteriaTable({
criteria,
selectedWorkflow,
@ -29,27 +48,32 @@ export function CriteriaTable({
onSubmit,
onDelete,
onAddCriteria,
}) {
const setField = (name, value) => onChange({ ...form, [name]: value });
}: CriteriaTableProps) {
const setField = (name: string, value: unknown) =>
onChange({ ...form, [name]: value });
const targetOptions = [
{ value: "", label: "Bağlantı yok" },
...criteria
.filter((item) => item.id !== form.id)
.map((item) => ({ value: item.id, label: `${item.id} - ${item.title}` })),
];
const updateCompareOutcome = (index, patch) => {
const updateCompareOutcome = (index: number, patch: Partial<CompareOutcomeDto>) => {
const next = [...(form.compareOutcomes || [])];
next[index] = { ...next[index], ...patch };
setField("compareOutcomes", next);
};
const updateCompareCondition = (outcomeIndex, conditionIndex, patch) => {
const updateCompareCondition = (
outcomeIndex: number,
conditionIndex: number,
patch: Record<string, unknown>,
) => {
const next = [...(form.compareOutcomes || [])];
const conditions = [...(next[outcomeIndex]?.conditions || [])];
conditions[conditionIndex] = { ...conditions[conditionIndex], ...patch };
next[outcomeIndex] = { ...next[outcomeIndex], conditions };
setField("compareOutcomes", next);
};
const addCompareCondition = (outcomeIndex) => {
const addCompareCondition = (outcomeIndex: number) => {
const next = [...(form.compareOutcomes || [])];
next[outcomeIndex] = {
...next[outcomeIndex],
@ -60,23 +84,27 @@ export function CriteriaTable({
};
setField("compareOutcomes", next);
};
const removeCompareCondition = (outcomeIndex, conditionIndex) => {
const removeCompareCondition = (outcomeIndex: number, conditionIndex: number) => {
const next = [...(form.compareOutcomes || [])];
const conditions = (next[outcomeIndex]?.conditions || []).filter(
(_, index) => index !== conditionIndex,
(_: unknown, index: number) => index !== conditionIndex,
);
next[outcomeIndex] = { ...next[outcomeIndex], conditions };
setField("compareOutcomes", next);
};
const removeCompareOutcome = (index) => {
const removeCompareOutcome = (index: number) => {
setField(
"compareOutcomes",
(form.compareOutcomes || []).filter(
(_, itemIndex) => itemIndex !== index,
(_: unknown, itemIndex: number) => itemIndex !== index,
),
);
};
const targetSelect = (value, onSelectTarget, required = false) => (
const targetSelect = (
value: string | null | undefined,
onSelectTarget: (value: string) => void,
required = false,
) => (
<select
required={required}
value={value || ""}
@ -89,7 +117,7 @@ export function CriteriaTable({
))}
</select>
);
const toggleRow = (id) => onSelect(id === selectedId ? "" : id);
const toggleRow = (id: string) => onSelect(id === selectedId ? "" : id);
return (
<section className="min-w-0 rounded-lg">
@ -276,7 +304,7 @@ export function CriteriaTable({
</button>
</div>
{(form.compareOutcomes || []).map(
(outcome, index) => (
(outcome: CompareOutcomeDto, index: number) => (
<div
key={index}
className="grid gap-2 border-t border-[#e4e7ec] pt-2 first:border-t-0 first:pt-0"
@ -444,7 +472,15 @@ export function CriteriaTable({
);
}
function Field({ label, children, required = false }) {
function Field({
label,
children,
required = false,
}: {
label: string;
children: React.ReactNode;
required?: boolean;
}) {
return (
<label className="grid gap-1.5 text-[12px] text-[#344054]">
<span>
@ -456,14 +492,14 @@ function Field({ label, children, required = false }) {
);
}
function criteriaSummaryContent(item) {
function criteriaSummaryContent(item: WorkflowCriteriaDto) {
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) => (
{outcomes.map((outcome, index: number) => (
<li key={`${outcome.label || "outcome"}-${index}`}>
<strong>{outcome.label || `Durum ${index + 1}`}:</strong>{" "}
{compareOutcomeRuleText(outcome)}
@ -476,14 +512,17 @@ function criteriaSummaryContent(item) {
return criteriaSummary(item);
}
function criteriaConnectionSummary(item, criteria) {
function criteriaConnectionSummary(
item: WorkflowCriteriaDto,
criteria: WorkflowCriteriaDto[],
) {
if (item.kind === "Compare") {
const outcomes = item.compareOutcomes || [];
if (!outcomes.length) return "-";
return (
<ul className="m-0 grid gap-1 pl-[18px] [&_li]:pl-0.5">
{outcomes.map((outcome, index) => (
{outcomes.map((outcome, index: number) => (
<li key={`${outcome.label || "target"}-${index}`}>
<strong>{outcome.label || `Durum ${index + 1}`}:</strong>{" "}
{targetTitle(criteria, outcome.targetId)}

View file

@ -1,6 +1,59 @@
import { ApprovalDialog } from "./ApprovalDialog";
import { WorkflowDesigner } from "./WorkflowDesigner";
import { WorkflowTable } from "./WorkflowTable";
import type { FormEvent, RefObject } from "react";
import type {
WorkflowCriteriaDto,
WorkflowItemDto,
} from "@/services/workflow.service";
type DashboardShellProps = {
busy: boolean;
canvasRef: RefObject<HTMLDivElement>;
canvasZoom: number;
criteria: WorkflowCriteriaDto[];
criteriaForm: any;
currentCriteria: WorkflowCriteriaDto[];
designerTab: string;
dialogPendingItems: WorkflowItemDto[];
dragPreview: any;
editingWorkflowId: string | null;
pendingLink: any;
selectedId: string;
selectedWorkflow?: WorkflowItemDto | null;
selectedWorkflowId: string | null;
showApprovalDialog: boolean;
workflowEditForm: any;
workflowForm: any;
workflowItems: WorkflowItemDto[];
onAddCriteria: (kind: string) => void;
onBeginLink: (sourceId: string, outcome: string) => void;
onBeginWorkflowEdit: (item: WorkflowItemDto) => void;
onCancelWorkflowEdit: () => void;
onChangeCriteriaForm: (form: any) => void;
onClearCanvasSelection: () => void;
onCloseApprovalDialog: () => void;
onConnectNodes: (sourceId: string, outcome: string, targetId: string) => void;
onCreateWorkflow: (event: FormEvent<HTMLFormElement>) => void;
onDecision: (id: string, approved: boolean, note: string) => void;
onDeleteSelectedCriteria: (criteriaId?: string) => void;
onDisconnectLink: (sourceId: string, outcome: string) => void;
onDragMove: (event: any) => void;
onFitFlowLayout: () => void;
onOpenCriteriaDetails: (id: string) => void;
onResetDemo: () => void;
onSaveCriteria: (event: FormEvent<HTMLFormElement>) => void;
onSaveWorkflowEdit: (id: string) => void;
onSelectCriteria: (id: string) => void;
onSelectWorkflow: (item: WorkflowItemDto) => void;
onSetDesignerTab: (tab: string) => void;
onStartWorkflow: (id: string) => void;
onUpdateNodePosition: (event: any) => void;
onWorkflowEditFormChange: (form: any) => void;
onWorkflowFormChange: (form: any) => void;
onZoomIn: () => void;
onZoomOut: () => void;
};
export function DashboardShell({
busy,
@ -48,7 +101,7 @@ export function DashboardShell({
onWorkflowFormChange,
onZoomIn,
onZoomOut,
}) {
}: DashboardShellProps) {
return (
<div className="min-h-screen">
<main className="grid gap-[18px] p-[18px]">

View file

@ -12,7 +12,34 @@ import {
collectLinks,
getNodeOutcomes,
outcomeLabel,
type WorkflowLink,
type WorkflowOutcome,
} from "@/utils/workflow/workflowHelpers";
import type { KeyboardEvent, MouseEvent, RefObject } from "react";
import type { WorkflowCriteriaDto } from "@/services/workflow.service";
type PendingLink = {
sourceId: string;
outcome: string;
} | null;
type DragPreview = {
id: string;
delta: { x: number; y: number };
} | null;
type FlowNodeProps = {
item: WorkflowCriteriaDto;
criteria: WorkflowCriteriaDto[];
links: WorkflowLink[];
selected: boolean;
active: boolean;
pendingLink: PendingLink;
onSelect: () => void;
onOpenDetails: () => void;
onDelete: () => void;
onBeginLink: (sourceId: string, outcome: string) => void;
};
export function FlowCanvas({
currentCriteria,
@ -29,6 +56,21 @@ export function FlowCanvas({
onDeleteLink,
onBeginLink,
onConnect,
}: {
currentCriteria: WorkflowCriteriaDto[];
dragPreview: DragPreview;
zoom: number;
activeNodeId?: string;
selectedId: string;
pendingLink: PendingLink;
canvasRef: RefObject<HTMLDivElement>;
onSelect: (id: string) => void;
onOpenDetails: (id: string) => void;
onClearSelection: () => void;
onDelete: (id: string) => void;
onDeleteLink: (sourceId: string, outcome: string) => void;
onBeginLink: (sourceId: string, outcome: string) => void;
onConnect: (sourceId: string, outcome: string, targetId: string) => void;
}) {
const canvasWidth = Math.max(
1240,
@ -55,9 +97,12 @@ export function FlowCanvas({
),
[currentCriteria, dragPreview],
);
const links = useMemo(() => collectLinks(arrowCriteria), [arrowCriteria]);
const links = useMemo(
() => collectLinks(arrowCriteria) as WorkflowLink[],
[arrowCriteria],
);
const handleKeyDown = (event) => {
const handleKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
if (event.key !== "Delete") return;
event.preventDefault();
if (pendingLink) {
@ -68,8 +113,11 @@ export function FlowCanvas({
onDelete(selectedId);
};
const handleCanvasClick = (event) => {
if (event.target.closest("[data-flow-node], [data-flow-link]")) return;
const handleCanvasClick = (event: MouseEvent<HTMLDivElement>) => {
if (
(event.target as HTMLElement).closest("[data-flow-node], [data-flow-link]")
)
return;
onClearSelection();
};
@ -244,7 +292,7 @@ function FlowNode({
onOpenDetails,
onDelete,
onBeginLink,
}) {
}: FlowNodeProps) {
const { attributes, listeners, setNodeRef, transform } = useDraggable({
id: item.id,
disabled: Boolean(pendingLink),
@ -313,7 +361,7 @@ function FlowNode({
{item.id}
</small>
<div className="mt-0.5 flex max-w-full flex-wrap gap-[3px]">
{getNodeOutcomes(item).map((outcome) => (
{(getNodeOutcomes(item) as WorkflowOutcome[]).map((outcome) => (
<span
key={outcome.field}
role="button"
@ -343,7 +391,7 @@ function FlowNode({
</span>
))}
</div>
{getNodeOutcomes(item).map((outcome, index, outcomes) => {
{(getNodeOutcomes(item) as WorkflowOutcome[]).map((outcome, index, outcomes) => {
const link = links.find(
(candidate) =>
candidate.source.id === item.id &&
@ -399,7 +447,17 @@ function FlowNode({
);
}
function Arrow({ link, criteria, pendingLink, onBeginLink }) {
function Arrow({
link,
criteria,
pendingLink,
onBeginLink,
}: {
link: WorkflowLink;
criteria: WorkflowCriteriaDto[];
pendingLink: PendingLink;
onBeginLink: (sourceId: string, outcome: string) => void;
}) {
const route = buildArrowRoute(
link.source,
link.target,
@ -412,7 +470,9 @@ function Arrow({ link, criteria, pendingLink, onBeginLink }) {
pendingLink?.sourceId === link.source.id &&
pendingLink?.outcome === link.sourcePort?.field;
const selectLink = (event) => {
const selectLink = (
event: MouseEvent<SVGGElement> | KeyboardEvent<SVGGElement>,
) => {
event.preventDefault();
event.stopPropagation();
if (!link.sourcePort?.field) return;
@ -460,7 +520,15 @@ function Arrow({ link, criteria, pendingLink, onBeginLink }) {
);
}
function ArrowLabel({ link, criteria, pendingLink }) {
function ArrowLabel({
link,
criteria,
pendingLink,
}: {
link: WorkflowLink;
criteria: WorkflowCriteriaDto[];
pendingLink: PendingLink;
}) {
if (!link.label) return null;
const route = buildArrowRoute(
@ -504,7 +572,7 @@ function ArrowLabel({ link, criteria, pendingLink }) {
);
}
function linkTone(link) {
function linkTone(link: WorkflowLink) {
const field = link.sourcePort?.field || "";
const label = link.label || "";
if (field === "nextOnReject" || label === "Red") return "reject";
@ -515,7 +583,7 @@ function linkTone(link) {
return "neutral";
}
function linkToneClass(tone) {
function linkToneClass(tone: string) {
if (tone === "next") {
return "[--link-color:#2563eb] [--link-soft:rgba(37,99,235,0.14)]";
}
@ -531,12 +599,17 @@ function linkToneClass(tone) {
return "[--link-color:#475467] [--link-soft:rgba(71,84,103,0.14)]";
}
function portSideClass(side) {
function portSideClass(side: string) {
if (side === "left" || side === "right") return "-translate-y-1/2";
return "-translate-x-1/2";
}
function buildArrowRoute(source, target, sourcePort = null, criteria = []) {
function buildArrowRoute(
source: any,
target: any,
sourcePort: any = null,
criteria: any[] = [],
) {
const sourceLeft = Number(source.positionX || 0);
const sourceTop = Number(source.positionY || 0);
const targetLeft = Number(target.positionX || 0);
@ -636,7 +709,7 @@ function buildArrowRoute(source, target, sourcePort = null, criteria = []) {
};
}
function sideToward(from, to) {
function sideToward(from: WorkflowCriteriaDto, to: WorkflowCriteriaDto) {
const fromLeft = Number(from.positionX || 0);
const fromTop = Number(from.positionY || 0);
const fromCenter = {
@ -657,7 +730,13 @@ function sideToward(from, to) {
return dy >= 0 ? "bottom" : "top";
}
function getPortPoint(item, side, index = 0, count = 1, outward = 0) {
function getPortPoint(
item: any,
side: string,
index = 0,
count = 1,
outward = 0,
) {
const left = Number(item.positionX || 0);
const top = Number(item.positionY || 0);
const height = getNodeHeight(item);
@ -671,7 +750,7 @@ function getPortPoint(item, side, index = 0, count = 1, outward = 0) {
return { x: left + horizontalOffset, y: top + height + outward };
}
function getPortStyle(item, side, index, count) {
function getPortStyle(item: any, side: string, index: number, count: number) {
const point = getPortPoint(
{ ...item, positionX: 0, positionY: 0 },
side,
@ -688,7 +767,7 @@ function getPortStyle(item, side, index, count) {
return { left: point.x - borderOffset, bottom: edgeOffset };
}
function getPortOffsetAlong(length, index, count) {
function getPortOffsetAlong(length: number, index: number, count: number) {
if (count <= 1) return length / 2;
const gap = 28;
const center = length / 2;
@ -696,7 +775,13 @@ function getPortOffsetAlong(length, index, count) {
return Math.min(length - 18, Math.max(18, center + offset));
}
function buildSideAwareRoute(start, startSide, end, endSide, slots: any = {}) {
function buildSideAwareRoute(
start: any,
startSide: string,
end: any,
endSide: string,
slots: any = {},
) {
const routeOffset = slotOffset(slots.routeIndex, slots.routeCount, 46);
const exit = extendFromSide(start, startSide, 26);
const entry = extendFromSide(end, endSide, 38);
@ -765,8 +850,8 @@ function buildSideAwareRoute(start, startSide, end, endSide, slots: any = {}) {
};
}
function routeLabelPoint(points, slots: any = {}) {
const segments = [];
function routeLabelPoint(points: any[], slots: any = {}) {
const segments: any[] = [];
for (let index = 1; index < points.length - 1; index += 1) {
const a = points[index - 1];
const b = points[index];
@ -806,7 +891,7 @@ function routeLabelPoint(points, slots: any = {}) {
};
}
function compactRoutePoints(points) {
function compactRoutePoints(points: any[]) {
return points.filter((point, index) => {
if (index === 0) return true;
const previous = points[index - 1];
@ -826,18 +911,18 @@ function targetLaneDistance(index = 0, count = 1) {
return 40 + Math.max(0, Math.min(index, count - 1)) * 32;
}
function extendFromSide(point, side, distance) {
function extendFromSide(point: any, side: string, distance: number) {
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) {
function isHorizontalSide(side: string) {
return side === "left" || side === "right";
}
function roundedPolylinePath(points) {
function roundedPolylinePath(points: any[]) {
const routePoints = points.filter((point, index) => {
if (index === 0) return true;
const previous = points[index - 1];

View file

@ -5,11 +5,45 @@ import { FiMaximize2, FiZoomIn, FiZoomOut } from 'react-icons/fi'
import { kindIcon, kindOptions } from '@/utils/workflow/workflowConstants'
import { CriteriaTable } from './CriteriaTable'
import { FlowCanvas } from './FlowCanvas'
import type { FormEvent, RefObject } from 'react'
import type {
WorkflowCriteriaDto,
WorkflowItemDto,
} from '@/services/workflow.service'
const MaximizeIcon = FiMaximize2 as any
const ZoomInIcon = FiZoomIn as any
const ZoomOutIcon = FiZoomOut as any
type WorkflowDesignerProps = {
busy: boolean
canvasRef: RefObject<HTMLDivElement>
canvasZoom: number
criteriaForm: any
currentCriteria: WorkflowCriteriaDto[]
designerTab: string
dragPreview: any
pendingLink: any
selectedCriteriaId: string
selectedWorkflow?: WorkflowItemDto | null
onAddCriteria: (kind: string) => void
onBeginLink: (sourceId: string, outcome: string) => void
onChangeCriteriaForm: (form: any) => void
onClearSelection: () => void
onConnect: (sourceId: string, outcome: string, targetId: string) => void
onDeleteCriteria: (criteriaId?: string) => void
onDeleteLink: (sourceId: string, outcome: string) => void
onDragMove: (event: any) => void
onFitLayout: () => void
onOpenDetails: (id: string) => void
onSaveCriteria: (event: FormEvent<HTMLFormElement>) => void
onSelectCriteria: (id: string) => void
onSetDesignerTab: (tab: string) => void
onUpdateNodePosition: (event: any) => void
onZoomIn: () => void
onZoomOut: () => void
}
export function WorkflowDesigner({
busy,
canvasRef,
@ -37,7 +71,7 @@ export function WorkflowDesigner({
onUpdateNodePosition,
onZoomIn,
onZoomOut,
}) {
}: WorkflowDesignerProps) {
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">
@ -112,6 +146,14 @@ function DesignerToolbar({
onFitLayout,
onZoomIn,
onZoomOut,
}: {
busy: boolean
currentCriteria: WorkflowCriteriaDto[]
zoom: number
onAddCriteria: (kind: string) => void
onFitLayout: () => void
onZoomIn: () => void
onZoomOut: () => void
}) {
return (
<div className="flex flex-wrap justify-end gap-2">
@ -163,7 +205,13 @@ function DesignerToolbar({
)
}
function DesignerTabs({ activeTab, onChange }) {
function DesignerTabs({
activeTab,
onChange,
}: {
activeTab: string
onChange: (tab: string) => void
}) {
return (
<div className="inline-flex gap-1 rounded-lg" role="tablist" aria-label="Akış tasarımı">
<button
@ -209,7 +257,11 @@ function DesignerTabs({ activeTab, onChange }) {
)
}
function ApprovalHistoryTable({ selectedWorkflow }: any) {
function ApprovalHistoryTable({
selectedWorkflow,
}: {
selectedWorkflow?: WorkflowItemDto | null
}) {
const history = selectedWorkflow?.history || []
return (
@ -229,7 +281,7 @@ function ApprovalHistoryTable({ selectedWorkflow }: any) {
<td colSpan={3}>Seçili akışı için ıklama kaydı yok.</td>
</tr>
)}
{history.map((item: any, index: number) => (
{history.map((item: WorkflowItemDto['history'][number], index: number) => (
<tr key={`${item.time}-${index}`}>
<td>{dayjs(item.time).format('DD MMM YYYY HH:mm')}</td>
<td>{item.action}</td>

View file

@ -2,6 +2,11 @@ import { FiCheck, FiEdit2, FiPlay, FiPlus, FiRefreshCw, FiX } from 'react-icons/
import classNames from 'classnames'
import dayjs from 'dayjs'
import { formatMoney, statusClass } from '@/utils/workflow/workflowHelpers'
import type { FormEvent } from 'react'
import type {
WorkflowCriteriaDto,
WorkflowItemDto,
} from '@/services/workflow.service'
const CheckIcon = FiCheck as any
const EditIcon = FiEdit2 as any
@ -10,6 +15,25 @@ const PlusIcon = FiPlus as any
const RefreshIcon = FiRefreshCw as any
const CloseIcon = FiX as any
type WorkflowTableProps = {
items: WorkflowItemDto[]
criteria: WorkflowCriteriaDto[]
selectedWorkflowId: string | null
form: { sorumlu: string; amount: number | string }
busy: boolean
onFormChange: (form: { sorumlu: string; amount: number | string }) => void
onSubmit: (event: FormEvent<HTMLFormElement>) => void
editingId: string | null
editForm: { sorumlu: string; tarih: string; amount: number | string }
onEditFormChange: (form: { sorumlu: string; tarih: string; amount: number | string }) => void
onEdit: (item: WorkflowItemDto) => void
onCancelEdit: () => void
onSaveEdit: (id: string) => void
onSelect: (item: WorkflowItemDto) => void
onStart: (id: string) => void
onResetDemo: () => void
}
export function WorkflowTable({
items,
criteria,
@ -27,7 +51,7 @@ export function WorkflowTable({
onSelect,
onStart,
onResetDemo,
}: any) {
}: WorkflowTableProps) {
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">
@ -93,9 +117,9 @@ export function WorkflowTable({
</tr>
</thead>
<tbody>
{items.map((item: any) => {
{items.map((item) => {
const currentStep = criteria.find(
(candidate: any) =>
(candidate) =>
candidate.workflowItemId === item.id && candidate.id === item.currentNodeId,
)
const statusTitle = currentStep?.title || item.durum
@ -240,7 +264,7 @@ export function WorkflowTable({
)
}
function StatusPill({ status }: any) {
function StatusPill({ status }: { status: string }) {
return (
<span
className={classNames(