Untitled
Take this noob daiunknown
typescript
10 months ago
49 kB
6
Indexable
import { MoreVertical } from 'lucide-react';
import { useCallback, useMemo } from 'react';
import {
AccessManager,
APPROVAL_CONTROLLER_ROUTE,
ApprovalListingType,
ApprovalStatusTypeEnum,
ApprovalStepTypeEnum,
FetchData,
FormatCurrency,
FormatDisplayDate,
GetObjectFromArray,
GetUniqueObjectsFromArray,
IndexOfObjectInArray,
IsEmptyArray,
IsUndefinedOrNull,
ObjectDto,
PRODUCT_IDENTIFIER,
SortArrayObjectBy,
toastBackendError,
useApp,
useGetEmployeeId,
useUserHook,
VerificationStatusTypeEnum,
} from '@finnoto/core';
import { EmployeeAdvanceApprovalController } from '@finnoto/core/src/backend/ap/employee/controllers/employee.advance.approval.controller';
import { ExpenseApprovalController } from '@finnoto/core/src/backend/ap/expense/controllers/expense.approval.controller';
import { PurchaseOrderApprovalController } from '@finnoto/core/src/backend/ap/purchase/controllers/purchase.order.approval.controller';
import { PurchaseRequestApprovalController } from '@finnoto/core/src/backend/ap/purchase/controllers/purchase.request.approval.controller';
import { ProjectApprovalController } from '@finnoto/core/src/backend/ap/workflow/controllers';
import {
Avatar,
Button,
cn,
Collapse,
ConfirmUtil,
DropdownMenu,
FormatDisplayDateStyled,
handleActivityTimeline,
Icon,
NoDataFound,
SlidingPane,
Toast,
} from '@finnoto/design-system';
import { EmployeeIconImage } from '@Components/BusinessImage/businessImage.component';
import {
handleNavigationEmployeeDetail,
openOverrideApproval,
openTransferApproval,
} from '@Utils/functions.utils';
import { ApprovalNonCheckedSvgIcon, VerifiedTickSvgIcon } from 'assets';
interface InvoiceApprovalWorkflowProps {
data: ObjectDto;
workflows: ObjectDto[];
currentActiveWorkflow?: ObjectDto;
isAnyRejected?: boolean;
canApproveInvoice?: boolean;
active_approval?: ObjectDto;
type?: ApprovalListingType;
openEdit?: () => void;
callback?: () => void;
}
const InvoiceApprovalWorkflow = ({
data,
workflows,
currentActiveWorkflow,
isAnyRejected,
canApproveInvoice,
active_approval,
type = 'expense',
openEdit = () => {},
callback = () => {},
}: InvoiceApprovalWorkflowProps) => {
const { id: document_id, party_status_id } = data || {};
const { user } = useUserHook();
const isCreatorIsCurrentUser = useMemo(() => {
if (!data?.created_by) return false;
return data?.created_by === user?.id;
}, [data?.created_by, user?.id]);
const className = APPROVAL_CONTROLLER_ROUTE[type];
const { product_id } = useApp();
const isFinopsPortal = useMemo(
() => product_id === PRODUCT_IDENTIFIER.FINOPS,
[product_id]
);
const getStageLevels = useCallback(() => {
return SortArrayObjectBy(
GetUniqueObjectsFromArray(
workflows?.map((workflow) => workflow.stage_level),
'id'
),
'level',
'desc'
).filter(Boolean);
}, [workflows]);
const availableApprovals = useMemo<
{
label: string;
stage_level_id?: number;
visible: boolean;
}[]
>(() => {
const stageLevels = getStageLevels();
return stageLevels.map((stageLevel) => ({
label: stageLevel.name,
stage_level_id: stageLevel.id,
visible: true,
}));
}, [getStageLevels]);
const skipLevel = async (id: number, next = () => {}) => {
const { success, response } = await FetchData({
className,
method: 'skipLevel',
methodParams: { expenseId: document_id, id },
});
if (!success) {
Toast.error({
description: response.message || 'Something went wrong!',
});
return next();
}
Toast.success({ description: 'Successfully skipped level!' });
callback();
next();
};
const dropSkipLevel = async (id: number, next = () => {}) => {
const { success, response } = await FetchData({
className,
method: 'dropSkipLevel',
methodParams: { expenseId: document_id, id },
});
if (!success) {
Toast.error({
description: response.message || 'Something went wrong!',
});
return next();
}
Toast.success({ description: 'Successfully skipped drop!' });
callback();
next();
};
const dropTransfer = async (id: number) => {
const { success, response } = await FetchData({
className,
method: 'dropTransfer',
methodParams: { expenseId: document_id, id },
});
if (!success) {
Toast.error({
description: response.message || 'Something went wrong!',
});
return;
}
Toast.success({ description: 'Successfully Transfer Dropped.' });
callback();
};
const getWorkflows = useCallback(
(level_id: number) => {
return (
workflows?.filter(
(workflow) => workflow.stage_level_id === level_id
) || []
);
},
[workflows]
);
const getWorkflowStatus = useCallback((approval_type: number) => {
// TODO: Add other statuses
return false;
}, []);
const shouldShowReset = useMemo(() => {
if (isAnyRejected || !currentActiveWorkflow) return false;
if (IsEmptyArray(workflows)) return true;
if (getWorkflowStatus(currentActiveWorkflow?.stage_level_id))
return false;
const currentWorkflows = getWorkflows(
currentActiveWorkflow?.stage_level_id
);
if (IsEmptyArray(currentWorkflows)) return true;
if (currentWorkflows[currentWorkflows.length - 1].activity_datetime)
return true;
return false;
}, [
workflows,
isAnyRejected,
currentActiveWorkflow,
getWorkflowStatus,
getWorkflows,
]);
const party_rejected =
party_status_id === VerificationStatusTypeEnum.REJECTED;
// if (IsEmptyArray(workflows)) {
// return (
// <div className='flex items-center justify-center w-full h-full'>
// <NoDataFound />
// </div>
// );
// }
return (
<div id='approval-workflows' className='w-full h-full gap-4 col-flex'>
<div className='flex items-center justify-between'>
<div className='font-medium text-base-primary'>
Approval Workflows
</div>
</div>
<div className='gap-4 pb-4 overflow-auto col-flex'>
{availableApprovals.map((approval) => {
if (!approval.visible) return null;
const workflows = getWorkflows(approval.stage_level_id);
const workflow_name = (() => {
const workflow = workflows.find((workflow) => {
return workflow.rule_id > 10;
});
return workflow?.rule?.name;
})();
return (
<div
key={approval.stage_level_id}
className='gap-4 p-4 border rounded-t bg-base-200 col-flex border-base-300'
id={`approval-${approval.stage_level_id}`}
aria-label={approval.label}
>
<div className='justify-between approval-workflow-header row-flex'>
<div className='items-center gap-1 row-flex'>
{getWorkflowStatus(
approval.stage_level_id
) && (
<Icon
source={VerifiedTickSvgIcon}
iconColor='text-success'
isSvg
/>
)}
<h2 className='text-sm font-medium approval-workflow-title'>
{approval.label}
</h2>
{!!workflow_name && (
<h3 className='text-sm font-medium approval-workflow-title'>
({workflow_name})
</h3>
)}
</div>
</div>
<div className='gap-3 col-flex'>
{workflows.map((workflow, index) => {
const isApproved =
workflow.activity_id ===
ApprovalStatusTypeEnum.APPROVED;
const isRejected =
workflow.activity_id ===
ApprovalStatusTypeEnum.REJECTED;
const isSkipped =
!!workflow.is_skipped && !isApproved;
const isInProgress =
workflow.id === active_approval?.id;
const canSkip = (() => {
let skip = true;
if (isSkipped || isApproved)
return false;
if (workflows.length - 1 === index)
return false;
for (
let i = workflows.length - 1;
i >= 0;
i--
) {
if (!skip) break;
if (index < i) {
skip =
workflows[i].activity_id !==
ApprovalStatusTypeEnum.APPROVED;
}
}
return skip;
})();
const groupTransfers = (
approvalList: ObjectDto[]
) => {
const newList = [];
approvalList.forEach((approval) => {
if (!approval.parent_id)
return newList.push(approval);
const parentIndex =
IndexOfObjectInArray(
approvalList,
'id',
approval.parent_id
);
if (parentIndex > -1) {
const parent =
approvalList[parentIndex];
if (
!IsEmptyArray(
approval.transferredApproval
)
) {
if (
IsEmptyArray(
parent.transferredApproval
)
) {
parent.transferredApproval =
[
...approval.transferredApproval,
];
return;
}
parent.transferredApproval.push(
...approval.transferredApproval
);
}
}
});
return newList;
};
const approvals = (() => {
const approvalList = [];
SortArrayObjectBy(
(workflow.workflow_users as any[]) ||
[],
'id',
'desc'
).forEach((approval, _, approvals) => {
if (!approval?.user) return;
if (approval.parent_id) {
let approvalIndex =
IndexOfObjectInArray(
approvalList,
'id',
approval.parent_id
);
if (approvalIndex > -1) {
approvalList[
approvalIndex
].transferredApproval = [
approval,
];
return;
}
approvalList.push({
...GetObjectFromArray(
approvals,
'id',
approval.parent_id
),
transferredApproval: [
approval,
],
});
return;
}
if (
IndexOfObjectInArray(
approvalList,
'id',
approval.id
) === -1
) {
approvalList.push(approval);
}
});
return groupTransfers(approvalList);
})();
const userGroupStatus = (() => {
if (
isApproved ||
isRejected ||
isSkipped ||
workflow.approval_type_id !==
ApprovalStepTypeEnum.USER_GROUP
) {
return null;
}
const { property, users } =
workflow?.attributes || {};
const approvedCount = approvals.filter(
(approval) =>
!approval.parent_id &&
approval.activity_id ===
ApprovalStatusTypeEnum.APPROVED
).length;
let toApproveCount = 1;
if (property.all) {
toApproveCount = users;
}
if (property.percentage) {
toApproveCount = Math.ceil(
(users * property.percentage) /
100
);
}
return {
total: toApproveCount,
progress: approvedCount,
};
})();
const canTransfer = (() => {
let transfer = true;
if (isApproved || isAnyRejected)
return false;
for (
let i = workflows.length - 1;
i >= 0;
i--
) {
if (!transfer) break;
if (index < i) {
transfer =
workflows[i].activity_id !==
ApprovalStatusTypeEnum.APPROVED;
}
}
return transfer;
})();
const getIcon = () => {
if (isRejected) return 'close';
if (isApproved) return 'check';
if (isSkipped) return 'skip_next';
return ApprovalNonCheckedSvgIcon;
};
const getIconAppearance = () => {
if (isSkipped)
return 'bg-info text-info-content';
if (isRejected)
return 'bg-error text-error-content';
if (isApproved)
return 'bg-success text-success-content';
return 'text-base-secondary';
};
return (
<Collapse
key={workflow.id}
title={
<div className='items-center justify-between row-flex'>
<div className='items-center gap-6 row-flex'>
<div className='items-center gap-2 row-flex'>
<div
className={cn(
'flex rounded-full bg-base-300 text-base-100 p-[2px] relative',
getIconAppearance()
)}
>
<Icon
source={getIcon()}
size={14}
className='z-[1]'
isSvg
/>
{isInProgress &&
!isSkipped &&
!party_rejected && (
<span className='absolute top-0 left-0 w-full h-full rounded-full opacity-50 animate-ping bg-primary'></span>
)}
</div>
<span className='level-number'>
Level{' '}
{index + 1}
</span>
</div>
<span className='level-name'>
{workflow.name}
</span>
{userGroupStatus && (
<span className='level-progress-remaining'>
{`${userGroupStatus.progress} / ${userGroupStatus.total} Remaining`}
</span>
)}
</div>
<div className='gap-2 row-flex'>
{!isAnyRejected &&
!isApproved &&
canSkip &&
AccessManager.hasRoleIdentifier(
'ua_approval_overrider'
) &&
workflows.length >
1 && (
<Button
className='opacity-0 group-hover/title:opacity-100'
appearance='success'
size='xs'
progress
onClick={(
next,
e
) => {
e.preventDefault();
e.stopPropagation();
skipLevel(
workflow.id,
next
);
}}
>
Skip
</Button>
)}
{!isAnyRejected &&
isSkipped &&
!isApproved &&
!workflow
?.attributes
?.no_edit &&
AccessManager.hasRoleIdentifier(
'ua_approval_overrider'
) && (
<Button
className='opacity-0 group-hover/title:opacity-100'
appearance='error'
size='xs'
progress
onClick={(
next,
e
) => {
e.preventDefault();
e.stopPropagation();
dropSkipLevel(
workflow.id,
next
);
}}
>
Drop Skip
</Button>
)}
{/* {isApproved && (
<Badge
size='md'
appearance='success'
label='Approved'
/>
)}
{isRejected && (
<Badge
size='md'
appearance='error'
label='Rejected'
/>
)} */}
{/* {isSkipped && (
<Badge
size='md'
appearance='info'
label='Skipped'
/>
)} */}
{/* {isInProgress && (
<Badge
size='md'
appearance='warning'
label='In Progress'
/>
)} */}
{workflow.activity_datetime &&
FormatDisplayDateStyled(
{
value: workflow.activity_datetime,
}
)}
</div>
</div>
}
headerClassName='bg-base-100 p-3 px-4 gap-2 group/title workflow-level-header rounded'
titleClassName='text-sm font-normal text-base-primary'
defaultExpand={
isInProgress ||
(currentActiveWorkflow?.stage_level_id ===
approval.stage_level_id &&
index === 0)
}
>
<ApprovalLevelUsers
{...{
workflow,
approvals,
document_id,
type,
canTransfer,
callback,
dropTransfer,
party_rejected,
isFinopsPortal,
isInProgress,
isCreatorIsCurrentUser,
}}
/>
</Collapse>
);
})}
</div>
</div>
);
})}
</div>
{shouldShowReset && !IsEmptyArray(workflows) && (
<div className='col-flex'>
<div className='text-sm'>
<a
className='link link-hover'
onClick={() =>
openOverrideApproval(data, type, callback)
}
>
Click here
</a>{' '}
to override the approval workflow
</div>
</div>
)}
{shouldShowReset && IsEmptyArray(workflows) && (
<div className='col-flex'>
{AccessManager.canEditExpense({
product_id,
canApproveDocument: canApproveInvoice,
data,
}) ? (
<div className='text-sm'>
<a className='link link-hover' onClick={openEdit}>
Click here
</a>{' '}
to add poc to generate approval workflow.
</div>
) : (
<div className='text-sm'>
Contact your expense manager to add correct poc to
create approval workflow
</div>
)}
</div>
)}
{!shouldShowReset && IsEmptyArray(workflows) && (
<NoDataFound title='No workflows found' />
)}
</div>
);
};
const ApprovalLevelUsers = ({
approvals,
workflow,
document_id,
type,
canTransfer,
party_rejected,
dropTransfer,
callback,
isFinopsPortal,
isInProgress,
isCreatorIsCurrentUser,
}: {
document_id: number;
type: ApprovalListingType;
workflow: ObjectDto;
approvals: ObjectDto[];
canTransfer?: boolean;
party_rejected?: boolean;
dropTransfer?: (id: number) => void;
callback?: Function;
isFinopsPortal?: boolean;
isInProgress?: boolean;
isCreatorIsCurrentUser?: boolean;
}) => {
return (
<div className='border col-flex approvals'>
{approvals.map((approval) => {
const transferredApprovalLength =
approval?.transferredApproval?.length;
return (
<div
key={approval.id}
className='p-4 border-b rounded-b approval col-flex last:border-b-0 bg-base-100'
>
<div className='gap-4 col-flex'>
<ApprovalUserCard
{...{
document_id,
type,
workflow,
approval,
canTransfer,
party_rejected,
dropTransfer,
isTransferred: !IsEmptyArray(
approval.transferredApproval
),
isFinopsPortal,
callback,
isInProgress,
isCreatorIsCurrentUser,
transferId: !IsEmptyArray(
approval.transferredApproval
)
? approval?.transferredApproval[
transferredApprovalLength - 1
]?.id
: approval.id,
}}
/>
{!IsEmptyArray(approval.transferredApproval) ? (
<>
{approval.transferredApproval.map(
(
tApproval: ObjectDto,
index: number,
tArray: ObjectDto[]
) => {
return (
<ApprovalUserCard
key={index}
{...{
document_id,
type,
workflow,
approval: tApproval,
canTransfer,
party_rejected,
dropTransfer,
isTransferred:
tArray.length - 1 >
index,
callback,
isFinopsPortal,
isInProgress,
isCreatorIsCurrentUser,
}}
/>
);
}
)}
</>
) : null}
</div>
{approval.attributes?.reason || approval.comments ? (
<div className='w-full my-2 border-b border-dashed' />
) : null}
{approval.attributes?.reason ? (
<div className='text-sm text-base-secondary'>
Reason :{' '}
<span className='text-base-primary'>
{approval.attributes?.reason}
</span>
</div>
) : null}
{approval.comments ? (
<div className='text-sm text-base-secondary'>
Comments :{' '}
<span className='text-base-primary'>
{approval.comments}
</span>
</div>
) : null}
</div>
);
})}
</div>
);
};
const ApprovalUserCard = ({
document_id,
type,
workflow,
approval,
canTransfer,
party_rejected,
isTransferred,
dropTransfer,
callback,
isFinopsPortal,
isInProgress,
isCreatorIsCurrentUser,
transferId,
}: {
document_id: number;
type: ApprovalListingType;
workflow: ObjectDto;
approval: ObjectDto;
canTransfer?: boolean;
party_rejected?: boolean;
isTransferred?: boolean;
dropTransfer?: (id: number) => void;
callback?: Function;
isFinopsPortal?: boolean;
isInProgress?: boolean;
isCreatorIsCurrentUser?: boolean;
transferId?: number;
}) => {
const { user, employee } = approval;
const { image_url } = user || {};
const { isSuccess: handleSuccessEmployeNavigation } = useGetEmployeeId();
const actions = [
{
name: 'Transfer',
visible:
!!canTransfer &&
!approval.parent_id &&
IsEmptyArray(approval.transferredApproval) &&
approval?.activity_id !== ApprovalStatusTypeEnum.APPROVED &&
AccessManager.hasRoleIdentifier('ua_approval_overrider'),
action: () => {
openTransferApproval({
document_id,
type,
level_id: approval.id,
current_user_id: approval.user_id,
callback: () => {
SlidingPane.close();
callback();
},
});
},
},
{
name: 'Delete Transfer',
visible:
!!canTransfer &&
!IsEmptyArray(approval.transferredApproval) &&
approval.transferredApproval.length === 1 &&
AccessManager.hasRoleIdentifier('ua_approval_overrider'),
action: () => dropTransfer(approval.transferredApproval[0].id),
},
{
name: 'Send Notification',
action: () => {
ConfirmUtil({
message: 'Are you sure you want to send notification?',
title: 'Send Notification',
cancelAppearance: 'error',
appearance: 'success',
onConfirmPress: () => {
SendNotification({
typeId: document_id,
type,
approvalId: transferId ?? approval.id,
});
},
});
},
visible: isInProgress && isCreatorIsCurrentUser,
},
];
return (
<div
key={approval?.id}
className={cn(
'approval-user text-sm approve-card col-flex flex-1',
handleActivityTimeline(approval?.activity_id)
)}
>
<div className='grid grid-cols-3 gap-4'>
<div
className={cn(' gap-3 row-flex', {
'items-center': image_url,
})}
>
{image_url ? (
<Avatar
source={image_url}
alt={employee?.name || user?.name}
size='32'
shape='rounded'
color='vendor'
/>
) : (
<EmployeeIconImage className='w-8 h-8' size={18} />
)}
<div className='col-flex'>
<button
onClick={(e) => {
if (!isFinopsPortal) return;
handleNavigationEmployeeDetail(
handleSuccessEmployeNavigation,
user.id
);
}}
className={cn(
'flex items-center gap-2 font-medium approval-user-name ',
isFinopsPortal && 'table-link'
)}
>
{employee?.name || user?.name}
</button>
<span className='text-xs approval-user-email'>
{user?.email}
</span>
</div>
</div>
<div className='text-center approval-status'>
{!isTransferred && approval.activity_datetime && (
<>
{approval?.activity_id ===
ApprovalStatusTypeEnum.APPROVED && (
<div className='text-success'>Approved</div>
)}
{approval?.activity_id ===
ApprovalStatusTypeEnum.REJECTED && (
<div className='text-error'>Rejected</div>
)}
</>
)}
{!isTransferred && !approval.activity_datetime && (
<>
{!party_rejected &&
!workflow.is_skipped &&
!workflow.activity_datetime && (
<div className={'text-warning'}>
Pending Approval
</div>
)}
{!!workflow.is_skipped && (
<div className='text-info'>Skipped</div>
)}
</>
)}
{isTransferred && (
<div className={'text-info'}>Transferred</div>
)}
</div>
<div className='justify-between gap-2 row-flex'>
<div className='flex-1 gap-1 text-right col-flex'>
{approval.activity_datetime ? (
<span className='text-xs text-base-primary'>
{FormatDisplayDate(
approval.activity_datetime,
true
)}
</span>
) : null}
{!IsUndefinedOrNull(approval.attributes?.limit) ? (
<div className={'text-xs text-base-secondary'}>
Threshold :{' '}
<span className='text-base-primary'>
{FormatCurrency({
amount: approval.attributes?.limit || 0,
})}{' '}
& Above
</span>
</div>
) : null}
</div>
{!approval.parent_id &&
![
ApprovalStatusTypeEnum.APPROVED,
ApprovalStatusTypeEnum.REJECTED,
].includes(approval?.activity_id) &&
actions.some((action) => action.visible !== false) && (
<div>
<DropdownMenu actions={actions} align='end'>
<div className='p-2 border rounded cursor-pointer select-none hover:bg-base-200 approval-actions'>
<MoreVertical size={16} />
</div>
</DropdownMenu>
</div>
)}
</div>
</div>
</div>
);
};
export default InvoiceApprovalWorkflow;
const SendNotification = async ({
typeId,
type,
approvalId,
}: {
typeId: number;
type: string;
approvalId: number;
}) => {
const controllers = {
expense: ExpenseApprovalController,
advance: EmployeeAdvanceApprovalController,
purchase_request: PurchaseRequestApprovalController,
purchase_order: PurchaseOrderApprovalController,
project: ProjectApprovalController,
};
const className = controllers[type];
const { success, response } = await FetchData({
className,
methodParams: {
expenseId: typeId,
id: approvalId,
},
method: 'notify',
});
if (!success) return toastBackendError(response, 'Something went wrong!');
Toast.success({
description: 'Notification sent successfully',
});
};
Editor is loading...
Leave a Comment