Refine content approval workflow rail
This commit is contained in:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user