chore: add missing multi-level editor for approval workflow, rename projects to campaings.

This commit is contained in:
2026-05-01 14:23:37 -04:00
parent 5077f557f4
commit 884ca4b96d
148 changed files with 11567 additions and 1383 deletions

View File

@@ -7,13 +7,13 @@
import { useClientsStore } from '@/features/clients/stores/clientsStore.js';
import { useContentItemDetailStore } from '@/features/content/stores/contentItemDetailStore.js';
import { useContentItemsStore } from '@/features/content/stores/contentItemsStore.js';
import { useProjectsStore } from '@/features/projects/stores/projectsStore.js';
import { useCampaignsStore } from '@/features/campaigns/stores/campaignsStore.js';
import { useWorkspaceStore } from '@/features/workspaces/stores/workspaceStore.js';
const route = useRoute();
const router = useRouter();
const workspaceStore = useWorkspaceStore();
const projectsStore = useProjectsStore();
const campaignsStore = useCampaignsStore();
const clientsStore = useClientsStore();
const channelsStore = useChannelsStore();
const contentItemsStore = useContentItemsStore();
@@ -25,7 +25,7 @@
const form = reactive({
title: '',
projectId: '',
campaignId: '',
dueDate: '',
body: '',
hashtags: '',
@@ -45,6 +45,14 @@
});
const decisionForms = reactive({});
const manualStatuses = [
'Draft',
'In production',
'In approval',
'Approved',
'Scheduled',
'Published',
];
const saveError = reactive({
message: '',
});
@@ -52,7 +60,7 @@
const isCreateMode = computed(() => route.name === 'content-item-create');
const contentItemId = computed(() => isCreateMode.value ? null : route.params.id);
const item = computed(() => detailStore.item);
const availableProjects = computed(() => projectsStore.projects);
const availableCampaigns = computed(() => campaignsStore.campaigns);
const availableChannels = computed(() => channelsStore.channels);
const groupedChannels = computed(() => {
const groups = new Map();
@@ -76,10 +84,11 @@
.join(', ')
);
const operationalClient = computed(() => clientsStore.operationalClient);
const projectNameById = computed(() =>
new Map(projectsStore.projects.map(project => [project.id, project.name]))
const campaignNameById = computed(() =>
new Map(campaignsStore.campaigns.map(campaign => [campaign.id, campaign.name]))
);
const editorKey = computed(() => isCreateMode.value ? `new:${route.query.projectId ?? 'default'}` : String(route.params.id));
const editorKey = computed(() => isCreateMode.value ? `new:${route.query.campaignId ?? 'default'}` : String(route.params.id));
const isMultiLevelApproval = computed(() => workspaceStore.activeWorkspace?.approvalMode === 'Multi-level');
function blankPlacement(channel = null) {
return {
@@ -116,6 +125,16 @@
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;
@@ -162,7 +181,7 @@
function serializeDraft() {
return JSON.parse(JSON.stringify({
title: form.title,
projectId: form.projectId,
campaignId: form.campaignId,
dueDate: form.dueDate,
body: form.body,
hashtags: form.hashtags,
@@ -173,7 +192,7 @@
function restoreDraft(draft) {
form.title = draft.title ?? '';
form.projectId = draft.projectId ?? availableProjects.value[0]?.id ?? '';
form.campaignId = draft.campaignId ?? availableCampaigns.value[0]?.id ?? '';
form.dueDate = draft.dueDate ?? '';
form.body = draft.body ?? '';
form.hashtags = draft.hashtags ?? '';
@@ -196,7 +215,7 @@
}
function buildDraftFromItem() {
const projectId = item.value?.projectId ?? '';
const campaignId = item.value?.campaignId ?? '';
const placements = parseTargets(item.value?.publicationTargets).map(target => {
const channel = availableChannels.value.find(candidate => candidate.name.toLowerCase() === target.toLowerCase());
@@ -214,7 +233,7 @@
restoreDraft({
title: item.value?.title ?? '',
projectId,
campaignId,
dueDate: item.value?.dueDate ? new Date(item.value.dueDate).toISOString().slice(0, 10) : '',
body: item.value?.publicationMessage ?? '',
hashtags: item.value?.hashtags ?? '',
@@ -224,13 +243,13 @@
}
function buildDraftForNew() {
const projectIdFromRoute = typeof route.query.projectId === 'string' ? route.query.projectId : '';
const campaignIdFromRoute = typeof route.query.campaignId === 'string' ? route.query.campaignId : '';
restoreDraft({
title: '',
projectId: availableProjects.value.some(project => project.id === projectIdFromRoute)
? projectIdFromRoute
: availableProjects.value[0]?.id ?? '',
campaignId: availableCampaigns.value.some(campaign => campaign.id === campaignIdFromRoute)
? campaignIdFromRoute
: availableCampaigns.value[0]?.id ?? '',
dueDate: '',
body: '',
hashtags: '',
@@ -283,7 +302,7 @@
async function saveContent() {
saveError.message = '';
if (!form.title.trim() || !form.projectId || !form.placements.length) {
if (!form.title.trim() || !form.campaignId || !form.placements.length) {
saveError.message = 'Title, campaign, and at least one channel are required.';
return;
}
@@ -295,7 +314,7 @@
const payload = {
title: form.title.trim(),
projectId: form.projectId,
campaignId: form.campaignId,
publicationMessage: form.body.trim(),
publicationTargets: placementSummary.value,
hashtags: form.hashtags.trim(),
@@ -389,8 +408,8 @@
() => [
isCreateMode.value,
route.params.id,
route.query.projectId,
availableProjects.value.length,
route.query.campaignId,
availableCampaigns.value.length,
availableChannels.value.length,
],
async () => {
@@ -402,7 +421,7 @@
watch(
() => [
form.title,
form.projectId,
form.campaignId,
form.dueDate,
form.body,
form.hashtags,
@@ -448,7 +467,7 @@
<div class="eyebrow">{{ isCreateMode ? 'New content' : 'Content item' }}</div>
<h1>{{ form.title || 'Untitled content' }}</h1>
<p>
{{ projectNameById.get(form.projectId) || 'Choose a campaign' }}
{{ campaignNameById.get(form.campaignId) || 'Choose a campaign' }}
<template v-if="!isCreateMode && item">
· {{ item.status }}
</template>
@@ -488,33 +507,21 @@
class="quick-actions"
>
<button
v-for="status in manualStatuses"
:key="status"
class="secondary-button"
:disabled="detailStore.actions.status"
@click="moveStatus('Ready to publish')"
:disabled="detailStore.actions.status || item.status === status"
@click="moveStatus(status)"
>
Ready to publish
</button>
<button
class="secondary-button"
:disabled="detailStore.actions.status"
@click="moveStatus('Published')"
>
Published
</button>
<button
class="secondary-button"
:disabled="detailStore.actions.status"
@click="moveStatus('Archived')"
>
Archive
{{ 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 }} requests</span>
<div class="panel-heading">
<strong>Approval</strong>
<span v-if="!isCreateMode">{{ detailStore.approvals.length }} {{ isMultiLevelApproval ? 'steps' : 'requests' }}</span>
</div>
<div
@@ -525,7 +532,17 @@
</div>
<template v-else>
<div class="panel-stack">
<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">
@@ -572,7 +589,7 @@
<div class="sub-card-header">
<div>
<strong>{{ approval.reviewerName }}</strong>
<span>{{ approval.stage }} · {{ approval.state }}</span>
<span>{{ formatApprovalStepMeta(approval) }}</span>
</div>
<small>{{ formatDate(approval.dueAt) }}</small>
</div>
@@ -607,8 +624,6 @@
<span>Decision</span>
<select v-model="getDecisionForm(approval.id).decision">
<option value="Approved">Approved</option>
<option value="Changes requested">Changes requested</option>
<option value="Rejected">Rejected</option>
</select>
</label>
<label class="field">
@@ -649,7 +664,7 @@
<label class="field">
<span>Campaign</span>
<select v-model="form.projectId">
<select v-model="form.campaignId">
<option
disabled
value=""
@@ -657,11 +672,11 @@
Select a campaign
</option>
<option
v-for="project in availableProjects"
:key="project.id"
:value="project.id"
v-for="campaign in availableCampaigns"
:key="campaign.id"
:value="campaign.id"
>
{{ project.name }}
{{ campaign.name }}
</option>
</select>
</label>