464 lines
14 KiB
Vue
464 lines
14 KiB
Vue
<script setup>
|
|
import { computed } from 'vue';
|
|
import AppAvatar from '@/components/AppAvatar.vue';
|
|
|
|
const props = defineProps({
|
|
approvals: {
|
|
type: Array,
|
|
default: () => [],
|
|
},
|
|
isCreateMode: {
|
|
type: Boolean,
|
|
default: false,
|
|
},
|
|
approvalMode: {
|
|
type: String,
|
|
default: 'Required',
|
|
},
|
|
contentStatus: {
|
|
type: String,
|
|
default: 'Draft',
|
|
},
|
|
isSubmittingDecision: {
|
|
type: Boolean,
|
|
default: false,
|
|
},
|
|
});
|
|
|
|
const emit = defineEmits(['submit-decision']);
|
|
|
|
const isMultiLevelApproval = computed(() => props.approvalMode === 'Multi-level');
|
|
const isApprovalDisabled = computed(() => props.approvalMode === 'None');
|
|
const sortedApprovals = computed(() =>
|
|
[...props.approvals].sort((left, right) => {
|
|
const leftOrder = left.workflowStepSortOrder ?? Number.MAX_SAFE_INTEGER;
|
|
const rightOrder = right.workflowStepSortOrder ?? Number.MAX_SAFE_INTEGER;
|
|
|
|
if (left.workflowInstanceId || right.workflowInstanceId) {
|
|
return leftOrder - rightOrder;
|
|
}
|
|
|
|
return new Date(left.sentAt ?? 0).getTime() - new Date(right.sentAt ?? 0).getTime();
|
|
})
|
|
);
|
|
const currentPendingApprovalId = computed(() =>
|
|
sortedApprovals.value.find(approval => approval.state === 'Pending')?.id ?? null
|
|
);
|
|
const lifecycleRanks = {
|
|
Draft: 0,
|
|
'In production': 1,
|
|
'In approval': 2,
|
|
Approved: 3,
|
|
Scheduled: 4,
|
|
Published: 5,
|
|
};
|
|
const currentLifecycleRank = computed(() => lifecycleRanks[props.contentStatus] ?? 0);
|
|
const hasApprovalSteps = computed(() => sortedApprovals.value.length > 0);
|
|
const railSteps = computed(() => [
|
|
productionStep(),
|
|
...approvalSteps(),
|
|
publicationStep(),
|
|
]);
|
|
|
|
function submitDecision(approvalId) {
|
|
emit('submit-decision', approvalId, {
|
|
decision: 'Approved',
|
|
reviewerName: '',
|
|
reviewerEmail: '',
|
|
});
|
|
}
|
|
|
|
function formatApprovalStepMeta(approval) {
|
|
if (!approval.workflowInstanceId) {
|
|
return `${approval.stage} · ${approval.state}`;
|
|
}
|
|
|
|
const stepNumber = Number(approval.workflowStepSortOrder ?? 0) + 1;
|
|
const requiredCount = approval.workflowStepRequiredApproverCount ?? 1;
|
|
return `Step ${stepNumber} · ${approval.state} · ${requiredCount} required`;
|
|
}
|
|
|
|
function formatTarget(approval) {
|
|
if (!approval.workflowStepTargetType) {
|
|
return approval.reviewerEmail || 'Direct reviewer';
|
|
}
|
|
|
|
return `${approval.workflowStepTargetType}: ${approval.workflowStepTargetValue}`;
|
|
}
|
|
|
|
function formatDate(value) {
|
|
return value ? new Date(value).toLocaleDateString() : 'No due date';
|
|
}
|
|
|
|
function formatDateTime(value) {
|
|
return value ? new Date(value).toLocaleString() : '';
|
|
}
|
|
|
|
function stepStatus(approval) {
|
|
if (approval.state === 'Approved') {
|
|
return 'approved';
|
|
}
|
|
|
|
if (approval.id === currentPendingApprovalId.value) {
|
|
return 'current';
|
|
}
|
|
|
|
return 'pending';
|
|
}
|
|
|
|
function canRecordDecision(approval) {
|
|
if (approval.state !== 'Pending') {
|
|
return false;
|
|
}
|
|
|
|
return !approval.workflowInstanceId || approval.id === currentPendingApprovalId.value;
|
|
}
|
|
|
|
function productionStep() {
|
|
const status = currentLifecycleRank.value > lifecycleRanks['In production']
|
|
? 'approved'
|
|
: 'current';
|
|
|
|
return {
|
|
id: 'production',
|
|
kind: 'production',
|
|
title: 'Production',
|
|
state: props.contentStatus === 'Draft' ? 'Draft' : 'In production',
|
|
status,
|
|
meta: props.contentStatus === 'Draft'
|
|
? 'Content is being drafted.'
|
|
: 'Content is being prepared before approval.',
|
|
action: '',
|
|
};
|
|
}
|
|
|
|
function approvalSteps() {
|
|
if (hasApprovalSteps.value) {
|
|
return sortedApprovals.value.map((approval, index) => ({
|
|
id: approval.id,
|
|
kind: 'approval',
|
|
approval,
|
|
title: approval.stage || approval.reviewerName || `Approval ${index + 1}`,
|
|
state: approval.state,
|
|
status: stepStatus(approval),
|
|
meta: formatApprovalStepMeta(approval),
|
|
action: canRecordDecision(approval) ? 'Click the circle to approve.' : '',
|
|
}));
|
|
}
|
|
|
|
return [{
|
|
id: 'approval',
|
|
kind: 'approval-empty',
|
|
title: 'Approval',
|
|
state: approvalStateLabel(),
|
|
status: approvalSyntheticStatus(),
|
|
meta: approvalEmptyMeta(),
|
|
action: '',
|
|
}];
|
|
}
|
|
|
|
function publicationStep() {
|
|
const status = props.contentStatus === 'Published'
|
|
? 'published'
|
|
: props.contentStatus === 'Scheduled'
|
|
? 'scheduled'
|
|
: 'pending';
|
|
|
|
return {
|
|
id: 'publication',
|
|
kind: 'publication',
|
|
title: props.contentStatus === 'Published' ? 'Published' : 'Publication',
|
|
state: props.contentStatus === 'Published'
|
|
? 'Published'
|
|
: props.contentStatus === 'Scheduled'
|
|
? 'Scheduled'
|
|
: 'Pending',
|
|
status,
|
|
meta: props.contentStatus === 'Published'
|
|
? 'Content has been published.'
|
|
: props.contentStatus === 'Scheduled'
|
|
? 'Content is scheduled for publishing.'
|
|
: 'Content is not scheduled or published yet.',
|
|
action: '',
|
|
};
|
|
}
|
|
|
|
function approvalSyntheticStatus() {
|
|
if (isApprovalDisabled.value || currentLifecycleRank.value > lifecycleRanks['In approval']) {
|
|
return 'approved';
|
|
}
|
|
|
|
if (props.contentStatus === 'In approval') {
|
|
return 'current';
|
|
}
|
|
|
|
return 'pending';
|
|
}
|
|
|
|
function approvalStateLabel() {
|
|
if (isApprovalDisabled.value) {
|
|
return 'Skipped';
|
|
}
|
|
|
|
if (currentLifecycleRank.value > lifecycleRanks['In approval']) {
|
|
return 'Approved';
|
|
}
|
|
|
|
if (props.contentStatus === 'In approval') {
|
|
return 'In approval';
|
|
}
|
|
|
|
return 'Pending';
|
|
}
|
|
|
|
function approvalEmptyMeta() {
|
|
if (isApprovalDisabled.value) {
|
|
return 'Approval is disabled for this workspace.';
|
|
}
|
|
|
|
if (isMultiLevelApproval.value) {
|
|
return 'Move this content to In approval to start the configured workflow steps.';
|
|
}
|
|
|
|
return 'No approval activity yet.';
|
|
}
|
|
|
|
function stepLabel(step, index) {
|
|
const action = step.action ? ` ${step.action}` : '';
|
|
return `${step.title}. ${step.state}. ${step.meta}${action || ''}`.trim();
|
|
}
|
|
</script>
|
|
|
|
<template>
|
|
<aside
|
|
class="approval-panel"
|
|
aria-label="Approval workflow"
|
|
>
|
|
<div
|
|
v-if="isCreateMode"
|
|
class="approval-empty"
|
|
>
|
|
<span class="step-circle is-muted">1</span>
|
|
<div class="step-popover">
|
|
<strong>Approval</strong>
|
|
<span>Save the content first to see workflow steps.</span>
|
|
</div>
|
|
</div>
|
|
|
|
<ol
|
|
v-else
|
|
class="approval-stepper"
|
|
>
|
|
<li
|
|
v-for="(step, index) in railSteps"
|
|
:key="step.id"
|
|
class="approval-step"
|
|
:class="`is-${step.status}`"
|
|
>
|
|
<v-btn variant="text" :ripple="false"
|
|
class="step-circle"
|
|
type="button"
|
|
:disabled="step.kind !== 'approval' || !canRecordDecision(step.approval) || isSubmittingDecision"
|
|
:aria-label="stepLabel(step, index)"
|
|
@click="submitDecision(step.approval.id)"
|
|
>
|
|
{{ index + 1 }}
|
|
</v-btn>
|
|
|
|
<div class="step-popover">
|
|
<div class="popover-heading">
|
|
<strong>{{ step.title }}</strong>
|
|
<span>{{ step.state }}</span>
|
|
</div>
|
|
|
|
<div class="popover-meta">
|
|
<span>Workflow</span>
|
|
<strong>{{ step.meta }}</strong>
|
|
</div>
|
|
|
|
<div class="popover-meta">
|
|
<template v-if="step.kind === 'approval'">
|
|
<span>Approver</span>
|
|
<strong>{{ step.approval.reviewerName || formatTarget(step.approval) }}</strong>
|
|
<small>{{ formatTarget(step.approval) }}</small>
|
|
</template>
|
|
<template v-else>
|
|
<span>Status</span>
|
|
<strong>{{ step.state }}</strong>
|
|
</template>
|
|
</div>
|
|
|
|
<div
|
|
v-if="step.kind === 'approval'"
|
|
class="popover-meta"
|
|
>
|
|
<span>Due</span>
|
|
<strong>{{ formatDate(step.approval.dueAt) }}</strong>
|
|
</div>
|
|
|
|
<div
|
|
v-if="step.kind === 'approval' && step.approval.decisions?.length"
|
|
class="decision-list"
|
|
>
|
|
<article
|
|
v-for="decision in step.approval.decisions"
|
|
:key="decision.id"
|
|
class="decision-row"
|
|
>
|
|
<AppAvatar
|
|
:name="decision.decidedByName"
|
|
:email="decision.decidedByEmail"
|
|
:src="decision.decidedByPortraitUrl"
|
|
size="sm"
|
|
/>
|
|
<div>
|
|
<strong>{{ decision.decidedByName }}</strong>
|
|
<span>{{ decision.decision }} · {{ formatDateTime(decision.createdAt) }}</span>
|
|
<small v-if="decision.comment">{{ decision.comment }}</small>
|
|
</div>
|
|
</article>
|
|
</div>
|
|
|
|
<div
|
|
v-else
|
|
class="popover-meta"
|
|
>
|
|
<span>Action</span>
|
|
<strong>{{ step.action || 'No action available' }}</strong>
|
|
</div>
|
|
</div>
|
|
</li>
|
|
</ol>
|
|
</aside>
|
|
</template>
|
|
|
|
<style scoped>
|
|
@reference "@/assets/main.css";
|
|
.approval-panel {
|
|
@apply relative flex w-11 justify-center self-start;
|
|
}
|
|
|
|
.approval-empty strong,
|
|
.popover-heading strong,
|
|
.popover-meta strong,
|
|
.decision-row strong {
|
|
color: var(--app-color-on-surface);
|
|
}
|
|
|
|
.approval-empty span,
|
|
.popover-heading span,
|
|
.popover-meta span,
|
|
.popover-meta small,
|
|
.decision-row span,
|
|
.decision-row small {
|
|
@apply text-sm leading-6;
|
|
color: var(--app-text-muted);
|
|
}
|
|
|
|
.approval-stepper,
|
|
.decision-list {
|
|
@apply flex flex-col gap-14;
|
|
}
|
|
|
|
.approval-step {
|
|
@apply relative flex justify-center;
|
|
}
|
|
|
|
.approval-step:not(:last-child)::after {
|
|
@apply absolute bottom-[-3.5rem] top-10 border-l-2 border-dashed;
|
|
content: '';
|
|
left: 50%;
|
|
transform: translateX(-50%);
|
|
border-color: rgba(23, 32, 51, 0.18);
|
|
}
|
|
|
|
.approval-empty {
|
|
@apply relative flex justify-center;
|
|
}
|
|
|
|
.step-circle {
|
|
@apply relative z-10 flex h-9 w-9 items-center justify-center rounded-full border text-xs font-black transition;
|
|
background: var(--app-color-surface);
|
|
border-color: rgba(23, 32, 51, 0.16);
|
|
color: var(--app-text-muted);
|
|
}
|
|
|
|
button.step-circle:not(:disabled) {
|
|
@apply cursor-pointer shadow-sm;
|
|
}
|
|
|
|
button.step-circle:not(:disabled):hover,
|
|
button.step-circle:not(:disabled):focus-visible {
|
|
transform: scale(1.06);
|
|
}
|
|
|
|
button.step-circle:disabled {
|
|
cursor: default;
|
|
}
|
|
|
|
.step-circle.is-muted {
|
|
background: var(--app-control-subtle);
|
|
}
|
|
|
|
.approval-step.is-approved .step-circle {
|
|
background: var(--app-color-on-tertiary);
|
|
border-color: var(--app-color-on-tertiary);
|
|
color: var(--app-color-on-primary);
|
|
}
|
|
|
|
.approval-step.is-scheduled .step-circle {
|
|
background: #b45309;
|
|
border-color: #b45309;
|
|
color: var(--app-color-on-primary);
|
|
}
|
|
|
|
.approval-step.is-published .step-circle {
|
|
background: #7c3aed;
|
|
border-color: #7c3aed;
|
|
color: var(--app-color-on-primary);
|
|
}
|
|
|
|
.approval-step.is-current .step-circle {
|
|
background: var(--app-color-on-surface);
|
|
border-color: var(--app-color-on-surface);
|
|
color: var(--app-color-on-primary);
|
|
}
|
|
|
|
.step-popover {
|
|
@apply pointer-events-none absolute left-[calc(100%+0.75rem)] top-0 z-20 flex w-[18rem] translate-y-2 flex-col gap-3 rounded-[1rem] border p-4 opacity-0 shadow-xl transition;
|
|
background: #ffffff;
|
|
border-color: var(--app-border-subtle);
|
|
}
|
|
|
|
.approval-step:hover .step-popover,
|
|
.approval-step:focus-within .step-popover,
|
|
.approval-empty:hover .step-popover {
|
|
@apply pointer-events-auto translate-y-0 opacity-100;
|
|
}
|
|
|
|
.popover-heading {
|
|
@apply flex items-center justify-between gap-3;
|
|
}
|
|
|
|
.popover-meta {
|
|
@apply flex flex-col gap-1;
|
|
}
|
|
|
|
.decision-row {
|
|
@apply flex items-start gap-3 rounded-[0.875rem] border p-3;
|
|
background: #f8fafc;
|
|
border-color: var(--app-border-subtle);
|
|
}
|
|
|
|
.decision-row div {
|
|
@apply flex min-w-0 flex-col gap-1;
|
|
}
|
|
|
|
@media (max-width: 639px) {
|
|
.step-popover {
|
|
width: min(18rem, calc(100vw - 5rem));
|
|
}
|
|
}
|
|
|
|
</style>
|