Refine content approval workflow rail

This commit is contained in:
2026-05-04 16:20:32 -04:00
parent 7d3f495472
commit 55d8acef4c
9 changed files with 505 additions and 587 deletions

View File

@@ -861,7 +861,7 @@ export interface paths {
};
get: operations["SocializeApiModulesApprovalsHandlersGetApprovalsHandler"];
put?: never;
post: operations["SocializeApiModulesApprovalsHandlersCreateApprovalRequestHandler"];
post?: never;
delete?: never;
options?: never;
head?: never;
@@ -1540,22 +1540,9 @@ export interface components {
/** Format: date-time */
createdAt?: string;
};
SocializeApiModulesApprovalsHandlersCreateApprovalRequestRequest: {
/** Format: guid */
workspaceId: string;
/** Format: guid */
contentItemId: string;
stage: string;
reviewerName: string;
/** Format: email */
reviewerEmail: string;
/** Format: date-time */
dueAt?: string | null;
};
SocializeApiModulesApprovalsHandlersGetApprovalsRequest: Record<string, never>;
SocializeApiModulesApprovalsHandlersSubmitApprovalDecisionRequest: {
decision: string;
comment?: string | null;
reviewerName?: string | null;
/** Format: email */
reviewerEmail?: string | null;
@@ -3652,46 +3639,6 @@ export interface operations {
};
};
};
SocializeApiModulesApprovalsHandlersCreateApprovalRequestHandler: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["SocializeApiModulesApprovalsHandlersCreateApprovalRequestRequest"];
};
};
responses: {
/** @description Success */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["SocializeApiModulesApprovalsHandlersApprovalRequestDto"];
};
};
/** @description Bad Request */
400: {
headers: {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["FastEndpointsErrorResponse"];
};
};
/** @description Unauthorized */
401: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
SocializeApiModulesApprovalsHandlersSubmitApprovalDecisionHandler: {
parameters: {
query?: never;

View File

@@ -0,0 +1,462 @@
<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}`"
>
<button
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 }}
</button>
<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>
.approval-panel {
@apply relative flex w-11 justify-center self-start;
}
.approval-empty strong,
.popover-heading strong,
.popover-meta strong,
.decision-row strong {
color: #172033;
}
.approval-empty span,
.popover-heading span,
.popover-meta span,
.popover-meta small,
.decision-row span,
.decision-row small {
@apply text-sm leading-6;
color: #526178;
}
.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: #fffdf8;
border-color: rgba(23, 32, 51, 0.16);
color: #526178;
}
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: rgba(23, 32, 51, 0.04);
}
.approval-step.is-approved .step-circle {
background: #0f766e;
border-color: #0f766e;
color: #fffaf2;
}
.approval-step.is-scheduled .step-circle {
background: #b45309;
border-color: #b45309;
color: #fffaf2;
}
.approval-step.is-published .step-circle {
background: #7c3aed;
border-color: #7c3aed;
color: #fffaf2;
}
.approval-step.is-current .step-circle {
background: #172033;
border-color: #172033;
color: #fffaf2;
}
.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: rgba(23, 32, 51, 0.12);
}
.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: rgba(23, 32, 51, 0.08);
}
.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>

View File

@@ -20,7 +20,6 @@ export const useContentItemDetailStore = defineStore('content-item-detail', () =
asset: false,
assetRevision: false,
comment: false,
approval: false,
decision: false,
status: false,
});
@@ -159,26 +158,6 @@ export const useContentItemDetailStore = defineStore('content-item-detail', () =
}
}
async function createApproval(contentItemId, payload) {
actions.approval = true;
try {
const response = await client.post('/api/approvals', {
...payload,
contentItemId,
workspaceId: workspaceStore.activeWorkspaceId,
});
if (response.data) {
approvals.value = [response.data, ...approvals.value];
await fetchContentItem(contentItemId);
await fetchNotifications(contentItemId);
}
return response.data;
} finally {
actions.approval = false;
}
}
async function submitDecision(contentItemId, approvalId, payload) {
actions.decision = true;
@@ -248,7 +227,6 @@ export const useContentItemDetailStore = defineStore('content-item-detail', () =
addAssetRevision,
addComment,
resolveComment,
createApproval,
submitDecision,
updateStatus,
};

View File

@@ -1,10 +1,11 @@
<script setup>
import { computed, onBeforeUnmount, onMounted, reactive, watch } from 'vue';
import { computed, onBeforeUnmount, reactive, watch } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { useSessionStorage } from '@vueuse/core';
import AppAvatar from '@/components/AppAvatar.vue';
import { useChannelsStore } from '@/features/channels/stores/channelsStore.js';
import { useClientsStore } from '@/features/clients/stores/clientsStore.js';
import ContentApprovalPanel from '@/features/content/components/ContentApprovalPanel.vue';
import { useContentItemDetailStore } from '@/features/content/stores/contentItemDetailStore.js';
import { useContentItemsStore } from '@/features/content/stores/contentItemsStore.js';
import { useCampaignsStore } from '@/features/campaigns/stores/campaignsStore.js';
@@ -33,26 +34,10 @@
placements: [],
});
const approvalForm = reactive({
stage: 'Internal',
reviewerName: '',
reviewerEmail: '',
dueAt: '',
});
const commentForm = reactive({
body: '',
});
const decisionForms = reactive({});
const manualStatuses = [
'Draft',
'In production',
'In approval',
'Approved',
'Scheduled',
'Published',
];
const saveError = reactive({
message: '',
});
@@ -88,7 +73,7 @@
new Map(campaignsStore.campaigns.map(campaign => [campaign.id, campaign.name]))
);
const editorKey = computed(() => isCreateMode.value ? `new:${route.query.campaignId ?? 'default'}` : String(route.params.id));
const isMultiLevelApproval = computed(() => workspaceStore.activeWorkspace?.approvalMode === 'Multi-level');
const approvalMode = computed(() => workspaceStore.activeWorkspace?.approvalMode ?? 'Required');
function blankPlacement(channel = null) {
return {
@@ -112,29 +97,6 @@
};
}
function getDecisionForm(approvalId) {
if (!decisionForms[approvalId]) {
decisionForms[approvalId] = {
decision: 'Approved',
comment: '',
reviewerName: '',
reviewerEmail: '',
};
}
return decisionForms[approvalId];
}
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 syncPlacementChannel(placement, value) {
const channel = availableChannels.value.find(candidate => candidate.id === value);
placement.channelId = value;
@@ -353,30 +315,12 @@
}
}
async function submitApproval() {
async function submitDecision(approvalId, payload) {
if (!contentItemId.value) {
return;
}
await detailStore.createApproval(contentItemId.value, {
...approvalForm,
dueAt: approvalForm.dueAt ? new Date(approvalForm.dueAt).toISOString() : null,
});
approvalForm.stage = 'Internal';
approvalForm.reviewerName = '';
approvalForm.reviewerEmail = '';
approvalForm.dueAt = '';
}
async function submitDecision(approvalId) {
if (!contentItemId.value) {
return;
}
const formValue = getDecisionForm(approvalId);
await detailStore.submitDecision(contentItemId.value, approvalId, formValue);
formValue.comment = '';
await detailStore.submitDecision(contentItemId.value, approvalId, payload);
}
async function submitComment() {
@@ -388,22 +332,10 @@
commentForm.body = '';
}
async function moveStatus(status) {
if (!contentItemId.value) {
return;
}
await detailStore.updateStatus(contentItemId.value, status);
}
function formatDateTime(value) {
return value ? new Date(value).toLocaleString() : '';
}
function formatDate(value) {
return value ? new Date(value).toLocaleDateString() : 'No due date';
}
watch(
() => [
isCreateMode.value,
@@ -434,12 +366,6 @@
{ deep: true }
);
onMounted(async () => {
if (!isCreateMode.value) {
await hydrateEditor();
}
});
onBeforeUnmount(() => {
detailStore.reset();
});
@@ -502,165 +428,32 @@
{{ saveError.message }}
</div>
<div
v-if="!isCreateMode && item"
class="quick-actions"
>
<button
v-for="status in manualStatuses"
:key="status"
class="secondary-button"
:disabled="detailStore.actions.status || item.status === status"
@click="moveStatus(status)"
>
{{ status }}
</button>
</div>
<div class="editor-grid">
<aside class="panel side-panel">
<div class="panel-heading">
<strong>Approval</strong>
<span v-if="!isCreateMode">{{ detailStore.approvals.length }} {{ isMultiLevelApproval ? 'steps' : 'requests' }}</span>
</div>
<section class="work-panel">
<ContentApprovalPanel
:approvals="detailStore.approvals"
:approval-mode="approvalMode"
:content-status="item?.status ?? 'Draft'"
:is-create-mode="isCreateMode"
:is-submitting-decision="detailStore.actions.decision"
@submit-decision="submitDecision"
/>
<div
v-if="isCreateMode"
class="empty-note"
>
Save the content first to request approvals.
</div>
<main class="content-panel">
<div class="content-section">
<div class="section-title-row">
<strong>Content</strong>
<span>{{ placementSummary || 'No channels selected yet' }}</span>
</div>
<template v-else>
<div
v-if="isMultiLevelApproval"
class="empty-note"
>
Move this content to In approval to start the configured workflow steps.
</div>
<div
v-else
class="panel-stack"
>
<label class="field">
<span>Stage</span>
<select v-model="approvalForm.stage">
<option value="Internal">Internal</option>
<option value="Client">Client</option>
</select>
</label>
<label class="field">
<span>Reviewer name</span>
<input
v-model="approvalForm.reviewerName"
type="text"
/>
</label>
<label class="field">
<span>Reviewer email</span>
<input
v-model="approvalForm.reviewerEmail"
type="email"
/>
</label>
<label class="field">
<span>Due date</span>
<input
v-model="approvalForm.dueAt"
type="date"
/>
</label>
<button
class="primary-button"
:disabled="detailStore.actions.approval"
@click="submitApproval"
>
{{ detailStore.actions.approval ? 'Sending...' : 'Request approval' }}
</button>
</div>
<div class="card-stack">
<article
v-for="approval in detailStore.approvals"
:key="approval.id"
class="sub-card"
>
<div class="sub-card-header">
<div>
<strong>{{ approval.reviewerName }}</strong>
<span>{{ formatApprovalStepMeta(approval) }}</span>
</div>
<small>{{ formatDate(approval.dueAt) }}</small>
</div>
<div class="timeline-list compact">
<article
v-for="decision in approval.decisions"
:key="decision.id"
class="timeline-row"
>
<div class="identity-row">
<AppAvatar
:name="decision.decidedByName"
:email="decision.decidedByEmail"
:src="decision.decidedByPortraitUrl"
size="sm"
/>
<div>
<strong>{{ decision.decision }}</strong>
<span>{{ decision.comment || decision.decidedByName }}</span>
</div>
</div>
<small>{{ formatDateTime(decision.createdAt) }}</small>
</article>
</div>
<div
v-if="approval.state === 'Pending'"
class="panel-stack subtle"
>
<label class="field">
<span>Decision</span>
<select v-model="getDecisionForm(approval.id).decision">
<option value="Approved">Approved</option>
</select>
</label>
<label class="field">
<span>Comment</span>
<input
v-model="getDecisionForm(approval.id).comment"
type="text"
/>
</label>
<button
class="secondary-button"
:disabled="detailStore.actions.decision"
@click="submitDecision(approval.id)"
>
Record decision
</button>
</div>
</article>
</div>
</template>
</aside>
<main class="panel content-panel">
<div class="content-section">
<div class="section-title-row">
<strong>Content</strong>
<span>{{ placementSummary || 'No channels selected yet' }}</span>
</div>
<div class="form-grid">
<label class="field">
<span>Title</span>
<input
v-model="form.title"
type="text"
/>
</label>
<div class="form-grid">
<label class="field">
<span>Title</span>
<input
v-model="form.title"
type="text"
/>
</label>
<label class="field">
<span>Campaign</span>
@@ -921,8 +714,9 @@
>
Add at least one channel to define where this content will be published.
</div>
</div>
</main>
</div>
</main>
</section>
<aside class="panel side-panel">
<div class="panel-heading">
@@ -1024,7 +818,6 @@
.editor-header p,
.panel-heading span,
.sub-card span,
.timeline-row span,
.timeline-row small,
.empty-note,
@@ -1035,7 +828,6 @@
}
.header-actions,
.quick-actions,
.status-badges {
@apply flex flex-wrap items-center gap-3;
}
@@ -1062,7 +854,13 @@
}
.editor-grid {
@apply grid gap-4 xl:grid-cols-[18rem_minmax(0,1fr)_20rem];
@apply grid gap-4 xl:grid-cols-[minmax(0,1fr)_22rem];
}
.work-panel {
@apply grid min-w-0 grid-cols-[2.75rem_minmax(0,1fr)] items-start gap-4 rounded-[1.75rem] border p-5;
background: rgba(255, 255, 255, 0.9);
border-color: rgba(23, 32, 51, 0.08);
}
.panel {
@@ -1076,20 +874,18 @@
}
.content-panel {
@apply gap-6;
@apply flex min-h-0 flex-col gap-6;
}
.panel-heading,
.section-title-row,
.placement-header,
.sub-card-header {
.placement-header {
@apply flex items-start justify-between gap-3;
}
.panel-heading strong,
.section-title-row strong,
.placement-header strong,
.sub-card strong,
.timeline-row strong {
color: #172033;
}
@@ -1172,7 +968,6 @@
}
.placement-card,
.sub-card,
.media-card {
@apply rounded-[1.25rem] border p-4;
background: #fffaf2;