This commit is contained in:
2026-05-01 14:23:37 -04:00
parent 5077f557f4
commit df0409d7f6
47 changed files with 7800 additions and 194 deletions

View File

@@ -436,6 +436,38 @@ export interface paths {
patch?: never;
trace?: never;
};
"/api/feedback/{id}/comments": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
post: operations["SocializeApiModulesFeedbackHandlersAddDeveloperFeedbackCommentHandler"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/my-feedback/{id}/comments": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
post: operations["SocializeApiModulesFeedbackHandlersAddMyFeedbackCommentHandler"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/my-feedback/{id}/screenshot": {
parameters: {
query?: never;
@@ -484,6 +516,22 @@ export interface paths {
patch: operations["SocializeApiModulesFeedbackHandlersUpdateDeveloperFeedbackHandler"];
trace?: never;
};
"/api/feedback/{id}/timeline": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get: operations["SocializeApiModulesFeedbackHandlersGetDeveloperFeedbackTimelineHandler"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/feedback/{id}/screenshot": {
parameters: {
query?: never;
@@ -516,6 +564,22 @@ export interface paths {
patch?: never;
trace?: never;
};
"/api/my-feedback/{id}/timeline": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get: operations["SocializeApiModulesFeedbackHandlersGetMyFeedbackTimelineHandler"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/feedback": {
parameters: {
query?: never;
@@ -818,6 +882,26 @@ export interface components {
slug?: string;
logoUrl?: string | null;
timeZone?: string;
approvalMode?: string;
schedulePostsAutomaticallyOnApproval?: boolean;
lockContentAfterApproval?: boolean;
sendAutomaticApprovalReminders?: boolean;
approvalSteps?: components["schemas"]["SocializeApiModulesWorkspacesHandlersApprovalStepConfigurationDto"][];
/** Format: date-time */
createdAt?: string;
};
SocializeApiModulesWorkspacesHandlersApprovalStepConfigurationDto: {
/** Format: guid */
id?: string;
/** Format: guid */
workspaceId?: string;
name?: string;
/** Format: int32 */
sortOrder?: number;
targetType?: string;
targetValue?: string;
/** Format: int32 */
requiredApproverCount?: number;
/** Format: date-time */
createdAt?: string;
};
@@ -853,6 +937,20 @@ export interface components {
SocializeApiModulesWorkspacesHandlersUpdateWorkspaceRequest: {
name: string;
timeZone: string;
approvalMode?: string | null;
schedulePostsAutomaticallyOnApproval?: boolean | null;
lockContentAfterApproval?: boolean | null;
sendAutomaticApprovalReminders?: boolean | null;
approvalSteps?: components["schemas"]["SocializeApiModulesWorkspacesHandlersUpdateApprovalStepConfigurationRequest"][] | null;
};
SocializeApiModulesWorkspacesHandlersUpdateApprovalStepConfigurationRequest: {
name?: string;
/** Format: int32 */
sortOrder?: number;
targetType?: string;
targetValue?: string;
/** Format: int32 */
requiredApproverCount?: number;
};
SocializeApiModulesProjectsHandlersProjectDto: {
/** Format: guid */
@@ -1018,6 +1116,26 @@ export interface components {
message?: string;
};
SocializeApiModulesIdentityHandlersVerifyEmailRequest: Record<string, never>;
SocializeApiModulesFeedbackContractsFeedbackTimelineItemDto: {
/** Format: guid */
id?: string;
kind?: string;
/** Format: guid */
actorUserId?: string;
actorDisplayName?: string;
actorEmail?: string;
actorRole?: string | null;
body?: string | null;
activityType?: string | null;
fromValue?: string | null;
toValue?: string | null;
note?: string | null;
/** Format: date-time */
createdAt?: string;
};
SocializeApiModulesFeedbackHandlersAddFeedbackCommentRequest: {
body: string;
};
SocializeApiModulesFeedbackContractsFeedbackReportDto: {
/** Format: guid */
id?: string;
@@ -1032,6 +1150,7 @@ export interface components {
context?: components["schemas"]["SocializeApiModulesFeedbackContractsFeedbackContextDto"];
screenshot?: components["schemas"]["SocializeApiModulesFeedbackContractsFeedbackScreenshotDto"] | null;
tags?: string[];
timeline?: components["schemas"]["SocializeApiModulesFeedbackContractsFeedbackTimelineItemDto"][];
/** Format: date-time */
createdAt?: string;
/** Format: date-time */
@@ -1322,6 +1441,14 @@ export interface components {
workspaceId?: string;
/** Format: guid */
contentItemId?: string;
/** Format: guid */
workflowInstanceId?: string | null;
/** Format: int32 */
workflowStepSortOrder?: number | null;
workflowStepTargetType?: string | null;
workflowStepTargetValue?: string | null;
/** Format: int32 */
workflowStepRequiredApproverCount?: number | null;
stage?: string;
reviewerName?: string;
reviewerEmail?: string;
@@ -2286,6 +2413,97 @@ export interface operations {
};
};
};
SocializeApiModulesFeedbackHandlersAddDeveloperFeedbackCommentHandler: {
parameters: {
query?: never;
header?: never;
path: {
id: string;
};
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["SocializeApiModulesFeedbackHandlersAddFeedbackCommentRequest"];
};
};
responses: {
/** @description Success */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["SocializeApiModulesFeedbackContractsFeedbackTimelineItemDto"];
};
};
/** @description Bad Request */
400: {
headers: {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["FastEndpointsErrorResponse"];
};
};
/** @description Unauthorized */
401: {
headers: {
[name: string]: unknown;
};
content?: never;
};
/** @description Forbidden */
403: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
SocializeApiModulesFeedbackHandlersAddMyFeedbackCommentHandler: {
parameters: {
query?: never;
header?: never;
path: {
id: string;
};
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["SocializeApiModulesFeedbackHandlersAddFeedbackCommentRequest"];
};
};
responses: {
/** @description Success */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["SocializeApiModulesFeedbackContractsFeedbackTimelineItemDto"];
};
};
/** @description Bad Request */
400: {
headers: {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["FastEndpointsErrorResponse"];
};
};
/** @description Unauthorized */
401: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
SocializeApiModulesFeedbackHandlersAttachMyFeedbackScreenshotHandler: {
parameters: {
query?: never;
@@ -2455,6 +2673,42 @@ export interface operations {
};
};
};
SocializeApiModulesFeedbackHandlersGetDeveloperFeedbackTimelineHandler: {
parameters: {
query?: never;
header?: never;
path: {
id: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Success */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["SocializeApiModulesFeedbackContractsFeedbackTimelineItemDto"][];
};
};
/** @description Unauthorized */
401: {
headers: {
[name: string]: unknown;
};
content?: never;
};
/** @description Forbidden */
403: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
SocializeApiModulesFeedbackHandlersGetFeedbackScreenshotHandler: {
parameters: {
query?: never;
@@ -2513,6 +2767,35 @@ export interface operations {
};
};
};
SocializeApiModulesFeedbackHandlersGetMyFeedbackTimelineHandler: {
parameters: {
query?: never;
header?: never;
path: {
id: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Success */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["SocializeApiModulesFeedbackContractsFeedbackTimelineItemDto"][];
};
};
/** @description Unauthorized */
401: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
SocializeApiModulesFeedbackHandlersListDeveloperFeedbackHandler: {
parameters: {
query?: never;

View File

@@ -69,10 +69,8 @@
nextDueDate: matches
.filter(item => item.dueDate)
.sort((left, right) => new Date(left.dueDate).getTime() - new Date(right.dueDate).getTime())[0]?.dueDate ?? null,
readyCount: matches.filter(item => ['Approved', 'Ready to publish', 'Published'].includes(item.status)).length,
blockedCount: matches.filter(item =>
['In internal review', 'Ready for client review', 'In client review', 'Changes requested by client'].includes(item.status)
).length,
readyCount: matches.filter(item => ['Approved', 'Scheduled', 'Published'].includes(item.status)).length,
blockedCount: matches.filter(item => item.status === 'In approval').length,
};
}

View File

@@ -15,7 +15,7 @@ export const useContentItemsStore = defineStore('content-items', () => {
const error = ref(null);
const activeCount = computed(() =>
items.value.filter(item => item.status !== 'Approved' && item.status !== 'Published' && item.status !== 'Archived')
items.value.filter(item => !['Approved', 'Scheduled', 'Published'].includes(item.status))
.length
);

View File

@@ -45,6 +45,14 @@
});
const decisionForms = reactive({});
const manualStatuses = [
'Draft',
'In production',
'In approval',
'Approved',
'Scheduled',
'Published',
];
const saveError = reactive({
message: '',
});
@@ -80,6 +88,7 @@
new Map(projectsStore.projects.map(project => [project.id, project.name]))
);
const editorKey = computed(() => isCreateMode.value ? `new:${route.query.projectId ?? '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;
@@ -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">

View File

@@ -5,18 +5,11 @@ import { useProjectsStore } from '@/features/projects/stores/projectsStore.js';
const stageByStatus = {
Draft: 'Draft',
'In internal review': 'Internal review',
'Changes requested internally': 'Internal changes requested',
'Internal changes in progress': 'Internal revision',
'Ready for client review': 'Ready for client review',
'In client review': 'Client review',
'Changes requested by client': 'Client changes requested',
'Client changes in progress': 'Client revision',
'In production': 'In production',
'In approval': 'In approval',
Approved: 'Approved',
Rejected: 'Rejected',
'Ready to publish': 'Ready to publish',
Scheduled: 'Scheduled',
Published: 'Published',
Archived: 'Archived',
};
export const useReviewQueueStore = defineStore('review-queue', () => {
@@ -25,7 +18,7 @@ export const useReviewQueueStore = defineStore('review-queue', () => {
const items = computed(() =>
contentItemsStore.items
.filter(item => item.status !== 'Draft' && item.status !== 'Published' && item.status !== 'Archived')
.filter(item => item.status === 'In approval')
.map(item => {
const project = projectsStore.projects.find(candidate => candidate.id === item.projectId);

View File

@@ -0,0 +1,424 @@
<script setup>
import {
mdiArrowDown,
mdiArrowUp,
mdiDeleteOutline,
mdiPlus,
} from '@mdi/js';
const props = defineProps({
modelValue: {
type: Array,
default: () => [],
},
members: {
type: Array,
default: () => [],
},
errors: {
type: Array,
default: () => [],
},
disabled: {
type: Boolean,
default: false,
},
labels: {
type: Object,
required: true,
},
});
const emit = defineEmits(['update:modelValue']);
const roleOptions = [
'administrator',
'manager',
'workspace-member',
'client',
'provider',
];
const membershipOptions = ['Team', 'Client'];
const targetTypes = ['Role', 'Membership', 'Member'];
function emitSteps(steps) {
emit('update:modelValue', steps.map((step, index) => ({
...step,
sortOrder: index,
})));
}
function createStep() {
emitSteps([
...props.modelValue,
{
name: props.labels.defaultStepName(props.modelValue.length + 1),
sortOrder: props.modelValue.length,
targetType: 'Role',
targetValue: 'manager',
requiredApproverCount: 1,
},
]);
}
function updateStep(index, updates) {
const steps = props.modelValue.map((step, stepIndex) => {
if (stepIndex !== index) {
return step;
}
const nextStep = {
...step,
...updates,
};
if (updates.targetType) {
nextStep.targetValue = defaultTargetValue(updates.targetType);
}
return nextStep;
});
emitSteps(steps);
}
function defaultTargetValue(targetType) {
if (targetType === 'Membership') {
return membershipOptions[0];
}
if (targetType === 'Member') {
return props.members[0]?.id ?? '';
}
return roleOptions[1];
}
function getSelectedMemberIds(step) {
return (step.targetValue ?? '')
.split(',')
.map(value => value.trim())
.filter(Boolean);
}
function updateMemberTargets(index, selectedOptions) {
const targetValue = Array.from(selectedOptions)
.map(option => option.value)
.filter(Boolean)
.join(',');
updateStep(index, { targetValue });
}
function moveStep(index, offset) {
const nextIndex = index + offset;
if (nextIndex < 0 || nextIndex >= props.modelValue.length) {
return;
}
const steps = [...props.modelValue];
const [step] = steps.splice(index, 1);
steps.splice(nextIndex, 0, step);
emitSteps(steps);
}
function removeStep(index) {
emitSteps(props.modelValue.filter((_, stepIndex) => stepIndex !== index));
}
</script>
<template>
<div class="approval-workflow-editor">
<div class="approval-editor-header">
<div>
<strong>{{ labels.title }}</strong>
<span>{{ labels.description }}</span>
</div>
<button
type="button"
class="secondary-button"
:disabled="disabled"
@click="createStep"
>
<v-icon :icon="mdiPlus" />
<span>{{ labels.addStep }}</span>
</button>
</div>
<div
v-if="!modelValue.length"
class="approval-empty"
>
{{ labels.empty }}
</div>
<div
v-else
class="approval-step-list"
>
<section
v-for="(step, index) in modelValue"
:key="step.id ?? `${index}-${step.sortOrder}`"
class="approval-step-card"
>
<div class="approval-step-heading">
<div>
<small>{{ labels.stepNumber(index + 1) }}</small>
<strong>{{ step.name || labels.unnamedStep }}</strong>
</div>
<div class="approval-step-actions">
<button
type="button"
:aria-label="labels.moveUp"
:disabled="disabled || index === 0"
@click="moveStep(index, -1)"
>
<v-icon :icon="mdiArrowUp" />
</button>
<button
type="button"
:aria-label="labels.moveDown"
:disabled="disabled || index === modelValue.length - 1"
@click="moveStep(index, 1)"
>
<v-icon :icon="mdiArrowDown" />
</button>
<button
type="button"
:aria-label="labels.removeStep"
:disabled="disabled"
@click="removeStep(index)"
>
<v-icon :icon="mdiDeleteOutline" />
</button>
</div>
</div>
<div class="approval-step-fields">
<label class="field">
<span>{{ labels.fields.name }}</span>
<input
:value="step.name"
type="text"
:disabled="disabled"
@input="updateStep(index, { name: $event.target.value })"
/>
<small
v-if="errors[index]?.name"
class="field-error"
>
{{ errors[index].name }}
</small>
</label>
<label class="field">
<span>{{ labels.fields.targetType }}</span>
<select
:value="step.targetType"
:disabled="disabled"
@change="updateStep(index, { targetType: $event.target.value })"
>
<option
v-for="targetType in targetTypes"
:key="targetType"
:value="targetType"
>
{{ labels.targetTypes[targetType] }}
</option>
</select>
</label>
<label class="field">
<span>{{ labels.fields.targetValue }}</span>
<select
v-if="step.targetType === 'Role'"
:value="step.targetValue"
:disabled="disabled"
@change="updateStep(index, { targetValue: $event.target.value })"
>
<option
v-for="role in roleOptions"
:key="role"
:value="role"
>
{{ labels.roles[role] }}
</option>
</select>
<select
v-else-if="step.targetType === 'Membership'"
:value="step.targetValue"
:disabled="disabled"
@change="updateStep(index, { targetValue: $event.target.value })"
>
<option
v-for="membership in membershipOptions"
:key="membership"
:value="membership"
>
{{ labels.memberships[membership] }}
</option>
</select>
<select
v-else
:value="getSelectedMemberIds(step)"
:disabled="disabled"
multiple
size="5"
@change="updateMemberTargets(index, $event.target.selectedOptions)"
>
<option
v-for="member in members"
:key="member.id"
:value="member.id"
>
{{ member.displayName }} - {{ member.email }}
</option>
</select>
<small
v-if="step.targetType === 'Member'"
class="field-help"
>
{{ labels.selectMembers }}
</small>
<small
v-if="errors[index]?.targetValue"
class="field-error"
>
{{ errors[index].targetValue }}
</small>
</label>
<label class="field">
<span>{{ labels.fields.requiredApproverCount }}</span>
<input
:value="step.requiredApproverCount"
type="number"
min="1"
step="1"
:disabled="disabled"
@input="updateStep(index, { requiredApproverCount: Number($event.target.value) })"
/>
<small
v-if="errors[index]?.requiredApproverCount"
class="field-error"
>
{{ errors[index].requiredApproverCount }}
</small>
</label>
</div>
</section>
</div>
</div>
</template>
<style scoped>
.approval-workflow-editor {
@apply flex flex-col gap-3;
}
.approval-editor-header {
@apply flex flex-col gap-3 rounded-[1rem] border px-4 py-4 sm:flex-row sm:items-center sm:justify-between;
background: #fffaf2;
border-color: rgba(23, 32, 51, 0.08);
}
.approval-editor-header div,
.approval-step-heading div:first-child {
@apply flex min-w-0 flex-col gap-1;
}
.approval-editor-header strong,
.approval-step-heading strong {
color: #172033;
}
.approval-editor-header span,
.approval-empty,
.approval-step-heading small {
@apply text-sm leading-6;
color: #526178;
}
.approval-step-list {
@apply flex flex-col gap-3;
}
.approval-empty,
.approval-step-card {
@apply rounded-[1rem] border px-4 py-4;
background: #fffaf2;
border-color: rgba(23, 32, 51, 0.08);
}
.approval-step-card {
@apply flex flex-col gap-4;
}
.approval-step-heading {
@apply flex items-start justify-between gap-3;
}
.approval-step-actions {
@apply flex flex-shrink-0 gap-2;
}
.approval-step-actions button {
@apply inline-flex h-9 w-9 items-center justify-center rounded-full;
background: rgba(23, 32, 51, 0.08);
color: #172033;
}
.approval-step-actions button:disabled {
cursor: not-allowed;
opacity: 0.42;
}
.approval-step-fields {
@apply grid gap-3 md:grid-cols-2;
}
.secondary-button {
@apply inline-flex items-center justify-center gap-2 rounded-full px-4 py-2 text-sm font-semibold;
background: rgba(23, 32, 51, 0.08);
color: #172033;
}
.secondary-button:disabled {
cursor: not-allowed;
opacity: 0.56;
}
.field {
@apply flex flex-col gap-2;
}
.field span {
@apply text-sm font-semibold;
color: #172033;
}
.field input,
.field select {
@apply rounded-[1rem] border px-4 py-3 text-sm;
background: #fffdf8;
border-color: rgba(23, 32, 51, 0.1);
color: #172033;
outline: none;
}
.field-error {
@apply text-sm leading-6;
color: #b91c1c;
}
.field-help {
@apply text-sm leading-6;
color: #526178;
}
</style>

View File

@@ -17,18 +17,11 @@
const contentStatusMeta = {
Draft: { tone: 'production', readiness: 'building' },
'In internal review': { tone: 'approval', readiness: 'approval' },
'Changes requested internally': { tone: 'risk', readiness: 'rework' },
'Internal changes in progress': { tone: 'production', readiness: 'building' },
'Ready for client review': { tone: 'approval', readiness: 'approval' },
'In client review': { tone: 'approval', readiness: 'approval' },
'Changes requested by client': { tone: 'risk', readiness: 'rework' },
'Client changes in progress': { tone: 'production', readiness: 'building' },
'In production': { tone: 'production', readiness: 'building' },
'In approval': { tone: 'approval', readiness: 'approval' },
Approved: { tone: 'ready', readiness: 'ready' },
'Ready to publish': { tone: 'ready', readiness: 'ready' },
Scheduled: { tone: 'ready', readiness: 'scheduled' },
Published: { tone: 'published', readiness: 'published' },
Rejected: { tone: 'risk', readiness: 'blocked' },
Archived: { tone: 'muted', readiness: 'archived' },
};
const contentItemsByProjectId = computed(() => {
@@ -49,7 +42,7 @@
.map(project => buildProjectEntry(project));
const contentEntries = contentItemsStore.items
.filter(item => item.dueDate && item.status !== 'Archived')
.filter(item => item.dueDate)
.map(item => buildContentEntry(item));
return [...projectEntries, ...contentEntries].sort(sortByDate);
@@ -164,7 +157,7 @@
function buildProjectEntry(project) {
const projectItems = contentItemsByProjectId.value.get(project.id) ?? [];
const approvedCount = projectItems.filter(item => ['Approved', 'Ready to publish', 'Published'].includes(item.status)).length;
const approvedCount = projectItems.filter(item => ['Approved', 'Scheduled', 'Published'].includes(item.status)).length;
return {
id: project.id,

View File

@@ -32,9 +32,7 @@
return startOfDay(item.dueDate) >= today.value;
}).length;
const blockingCount = workspaceContent.filter(item =>
['In internal review', 'Ready for client review', 'In client review', 'Changes requested by client'].includes(item.status)
).length;
const blockingCount = workspaceContent.filter(item => item.status === 'In approval').length;
return {
id: workspace.id,
@@ -79,7 +77,7 @@
route: { name: 'content-item-detail', params: { id: item.id } },
}))
.filter(item =>
item.date < today.value && !['Approved', 'Ready to publish', 'Published', 'Archived'].includes(item.status)
item.date < today.value && !['Approved', 'Scheduled', 'Published'].includes(item.status)
)
.sort((left, right) => left.date.getTime() - right.date.getTime())
.slice(0, 6)

View File

@@ -3,6 +3,7 @@
import { useI18n } from 'vue-i18n';
import AppAvatar from '@/components/AppAvatar.vue';
import ImageCropperDialog from '@/components/ImageCropperDialog.vue';
import ApprovalWorkflowEditor from '@/features/workspaces/components/ApprovalWorkflowEditor.vue';
import TimeZoneSelect from '@/features/workspaces/components/TimeZoneSelect.vue';
import { useWorkspaceStore } from '@/features/workspaces/stores/workspaceStore.js';
import {
@@ -20,9 +21,15 @@
const settingsForm = reactive({
name: '',
timeZone: '',
approvalMode: 'Required',
schedulePostsAutomaticallyOnApproval: false,
lockContentAfterApproval: false,
sendAutomaticApprovalReminders: false,
approvalSteps: [],
});
const settingsError = ref(null);
const settingsStatus = ref(null);
const approvalStepErrors = ref([]);
const logoError = ref(null);
const logoStatus = ref(null);
const isLogoDialogOpen = ref(false);
@@ -38,6 +45,7 @@
const workspaceMembers = computed(() =>
workspaceStore.membersByWorkspace[workspaceStore.activeWorkspaceId] ?? []
);
const normalizedApprovalSteps = computed(() => normalizeApprovalSteps(settingsForm.approvalSteps));
const isSettingsDirty = computed(() => {
const workspace = workspaceStore.activeWorkspace;
@@ -45,7 +53,15 @@
return false;
}
return settingsForm.name.trim() !== workspace.name || settingsForm.timeZone.trim() !== workspace.timeZone;
const workspaceApprovalSteps = normalizeApprovalSteps(workspace.approvalSteps ?? []);
return settingsForm.name.trim() !== workspace.name ||
settingsForm.timeZone.trim() !== workspace.timeZone ||
settingsForm.approvalMode !== (workspace.approvalMode ?? 'Required') ||
settingsForm.schedulePostsAutomaticallyOnApproval !== Boolean(workspace.schedulePostsAutomaticallyOnApproval) ||
settingsForm.lockContentAfterApproval !== Boolean(workspace.lockContentAfterApproval) ||
settingsForm.sendAutomaticApprovalReminders !== Boolean(workspace.sendAutomaticApprovalReminders) ||
JSON.stringify(normalizedApprovalSteps.value) !== JSON.stringify(workspaceApprovalSteps);
});
const settingsTabs = computed(() => [
{ key: 'general', label: t('workspaceSettings.tabs.general'), icon: mdiCogOutline },
@@ -53,29 +69,113 @@
{ key: 'workflow', label: t('workspaceSettings.tabs.workflow'), icon: mdiTuneVariant },
{ key: 'connectors', label: t('workspaceSettings.tabs.connectors'), icon: mdiFolderGoogleDrive },
]);
const workflowSteps = computed(() => [
{
key: 'internal',
title: t('workspaceSettings.approvals.steps.internal'),
detail: t('workspaceSettings.approvals.stepDetail.approverCount', { count: 1 }),
},
{
key: 'client',
title: t('workspaceSettings.approvals.steps.client'),
detail: t('workspaceSettings.approvals.stepDetail.approverCount', { count: 1 }),
},
{
key: 'publish',
title: t('workspaceSettings.approvals.steps.publish'),
detail: t('workspaceSettings.approvals.stepDetail.manualPublish'),
},
const approvalModeOptions = computed(() => [
{ value: 'None', label: t('workspaceSettings.approvals.modes.none'), description: t('workspaceSettings.approvals.modeHelp.none') },
{ value: 'Optional', label: t('workspaceSettings.approvals.modes.optional'), description: t('workspaceSettings.approvals.modeHelp.optional') },
{ value: 'Required', label: t('workspaceSettings.approvals.modes.required'), description: t('workspaceSettings.approvals.modeHelp.required') },
{ value: 'Multi-level', label: t('workspaceSettings.approvals.modes.multiLevel'), description: t('workspaceSettings.approvals.modeHelp.multiLevel') },
]);
const activeApprovalModeOption = computed(() =>
approvalModeOptions.value.find(option => option.value === settingsForm.approvalMode) ?? approvalModeOptions.value[2]
);
const approvalWorkflowEditorLabels = computed(() => ({
title: t('workspaceSettings.approvals.editor.title'),
description: t('workspaceSettings.approvals.editor.description'),
addStep: t('workspaceSettings.approvals.editor.addStep'),
empty: t('workspaceSettings.approvals.editor.empty'),
unnamedStep: t('workspaceSettings.approvals.editor.unnamedStep'),
moveUp: t('workspaceSettings.approvals.editor.moveUp'),
moveDown: t('workspaceSettings.approvals.editor.moveDown'),
removeStep: t('workspaceSettings.approvals.editor.removeStep'),
selectMember: t('workspaceSettings.approvals.editor.selectMember'),
selectMembers: t('workspaceSettings.approvals.editor.selectMembers'),
defaultStepName: number => t('workspaceSettings.approvals.editor.defaultStepName', { number }),
stepNumber: number => t('workspaceSettings.approvals.editor.stepNumber', { number }),
fields: {
name: t('workspaceSettings.approvals.editor.fields.name'),
targetType: t('workspaceSettings.approvals.editor.fields.targetType'),
targetValue: t('workspaceSettings.approvals.editor.fields.targetValue'),
requiredApproverCount: t('workspaceSettings.approvals.editor.fields.requiredApproverCount'),
},
targetTypes: {
Role: t('workspaceSettings.approvals.editor.targetTypes.role'),
Membership: t('workspaceSettings.approvals.editor.targetTypes.membership'),
Member: t('workspaceSettings.approvals.editor.targetTypes.member'),
},
roles: {
administrator: t('workspaceSettings.roles.administrator'),
manager: t('workspaceSettings.roles.manager'),
'workspace-member': t('workspaceSettings.roles.workspace-member'),
client: t('workspaceSettings.roles.client'),
provider: t('workspaceSettings.roles.provider'),
},
memberships: {
Team: t('workspaceSettings.approvals.editor.memberships.team'),
Client: t('workspaceSettings.approvals.editor.memberships.client'),
},
}));
const workflowSteps = computed(() => {
if (settingsForm.approvalMode === 'None') {
return [
{
key: 'none',
title: t('workspaceSettings.approvals.steps.none'),
detail: t('workspaceSettings.approvals.stepDetail.none'),
},
];
}
if (settingsForm.approvalMode === 'Multi-level') {
const configuredSteps = normalizedApprovalSteps.value.map((step, index) => ({
key: `approval-${index}`,
title: step.name || t('workspaceSettings.approvals.editor.unnamedStep'),
detail: t('workspaceSettings.approvals.stepDetail.multiLevelTarget', {
count: step.requiredApproverCount,
target: formatApprovalTarget(step),
}),
}));
return [
...configuredSteps,
{
key: 'publish',
title: t('workspaceSettings.approvals.steps.publish'),
detail: settingsForm.schedulePostsAutomaticallyOnApproval
? t('workspaceSettings.approvals.stepDetail.autoSchedule')
: t('workspaceSettings.approvals.stepDetail.manualSchedule'),
},
];
}
return [
{
key: 'approval',
title: t('workspaceSettings.approvals.steps.approval'),
detail: settingsForm.approvalMode === 'Optional'
? t('workspaceSettings.approvals.stepDetail.optional')
: t('workspaceSettings.approvals.stepDetail.approverCount', { count: 1 }),
},
{
key: 'publish',
title: t('workspaceSettings.approvals.steps.publish'),
detail: settingsForm.schedulePostsAutomaticallyOnApproval
? t('workspaceSettings.approvals.stepDetail.autoSchedule')
: t('workspaceSettings.approvals.stepDetail.manualSchedule'),
},
];
});
watch(
() => workspaceStore.activeWorkspace,
workspace => {
settingsForm.name = workspace?.name ?? '';
settingsForm.timeZone = workspace?.timeZone ?? '';
settingsForm.approvalMode = workspace?.approvalMode ?? 'Required';
settingsForm.schedulePostsAutomaticallyOnApproval = Boolean(workspace?.schedulePostsAutomaticallyOnApproval);
settingsForm.lockContentAfterApproval = Boolean(workspace?.lockContentAfterApproval);
settingsForm.sendAutomaticApprovalReminders = Boolean(workspace?.sendAutomaticApprovalReminders);
settingsForm.approvalSteps = normalizeApprovalSteps(workspace?.approvalSteps ?? []);
approvalStepErrors.value = [];
settingsError.value = null;
settingsStatus.value = null;
},
@@ -117,12 +217,28 @@
return;
}
if (settingsForm.approvalMode === 'Multi-level' && !validateApprovalSteps()) {
settingsError.value ||= t('workspaceSettings.approvals.editor.errors.fixInvalidSteps');
return;
}
approvalStepErrors.value = [];
try {
await workspaceStore.updateWorkspace(workspace.id, {
name,
timeZone,
approvalMode: settingsForm.approvalMode,
schedulePostsAutomaticallyOnApproval: settingsForm.schedulePostsAutomaticallyOnApproval,
lockContentAfterApproval: settingsForm.lockContentAfterApproval,
sendAutomaticApprovalReminders: settingsForm.sendAutomaticApprovalReminders,
approvalSteps: settingsForm.approvalMode === 'Multi-level'
? normalizedApprovalSteps.value
: undefined,
});
settingsStatus.value = t('workspaceSettings.general.saved');
settingsStatus.value = activeTab.value === 'workflow'
? t('workspaceSettings.approvals.saved')
: t('workspaceSettings.general.saved');
} catch (error) {
console.error('Failed to update workspace settings:', error);
settingsError.value = t('workspaceSettings.errors.updateFailed');
@@ -183,6 +299,77 @@
const normalizedRole = role.charAt(0).toLowerCase() + role.slice(1);
return t(`workspaceSettings.roles.${normalizedRole}`, role);
}
function normalizeApprovalSteps(steps) {
return [...steps]
.sort((left, right) => Number(left.sortOrder ?? 0) - Number(right.sortOrder ?? 0))
.map((step, index) => ({
name: step.name ?? '',
sortOrder: index,
targetType: step.targetType ?? 'Role',
targetValue: step.targetValue ?? '',
requiredApproverCount: Number(step.requiredApproverCount ?? 1),
}));
}
function validateApprovalSteps() {
const errors = normalizedApprovalSteps.value.map(step => {
const stepErrors = {};
if (!step.name.trim()) {
stepErrors.name = t('workspaceSettings.approvals.editor.errors.nameRequired');
}
if (!step.targetValue?.trim()) {
stepErrors.targetValue = t('workspaceSettings.approvals.editor.errors.targetRequired');
}
if (step.targetType === 'Member' && getMemberTargetIds(step).length < step.requiredApproverCount) {
stepErrors.targetValue = t('workspaceSettings.approvals.editor.errors.notEnoughMembers');
}
if (!Number.isInteger(step.requiredApproverCount) || step.requiredApproverCount < 1) {
stepErrors.requiredApproverCount = t('workspaceSettings.approvals.editor.errors.requiredApproverCount');
}
return stepErrors;
});
if (!errors.length) {
settingsError.value = t('workspaceSettings.approvals.editor.errors.atLeastOneStep');
approvalStepErrors.value = [];
return false;
}
approvalStepErrors.value = errors;
settingsError.value = null;
return !errors.some(error => Object.keys(error).length > 0);
}
function formatApprovalTarget(step) {
if (step.targetType === 'Membership') {
return t(`workspaceSettings.approvals.editor.memberships.${step.targetValue.toLowerCase()}`, step.targetValue);
}
if (step.targetType === 'Member') {
const selectedNames = getMemberTargetIds(step)
.map(memberId => workspaceMembers.value.find(candidate => candidate.id === memberId)?.displayName)
.filter(Boolean);
return selectedNames.length
? selectedNames.join(', ')
: t('workspaceSettings.approvals.editor.targetTypes.member');
}
return t(`workspaceSettings.roles.${step.targetValue}`, step.targetValue);
}
function getMemberTargetIds(step) {
return (step.targetValue ?? '')
.split(',')
.map(value => value.trim())
.filter(Boolean);
}
</script>
<template>
@@ -432,19 +619,95 @@
<p>{{ t('workspaceSettings.approvals.flowDescription') }}</p>
</div>
<div
v-if="settingsError"
class="page-message error"
>
{{ settingsError }}
</div>
<div
v-if="settingsStatus"
class="page-message success"
>
{{ settingsStatus }}
</div>
<div class="workflow-rule-list">
<label class="field">
<span>{{ t('workspaceSettings.approvals.fields.approvalMode') }}</span>
<select
v-model="settingsForm.approvalMode"
:disabled="workspaceStore.isUpdating"
>
<option
v-for="option in approvalModeOptions"
:key="option.value"
:value="option.value"
>
{{ option.label }}
</option>
</select>
</label>
<div class="workflow-rule">
<strong>{{ t('workspaceSettings.approvals.fields.requireInternalReview') }}</strong>
<span>{{ t('workspaceSettings.approvals.fieldHelp.requireInternalReview') }}</span>
</div>
<div class="workflow-rule">
<strong>{{ t('workspaceSettings.approvals.fields.requireClientReview') }}</strong>
<span>{{ t('workspaceSettings.approvals.fieldHelp.requireClientReview') }}</span>
</div>
<div class="workflow-rule">
<strong>{{ t('workspaceSettings.approvals.fields.publishBehaviour') }}</strong>
<span>{{ t('workspaceSettings.approvals.publishBehaviour.manual') }}</span>
<strong>{{ activeApprovalModeOption.label }}</strong>
<span>{{ activeApprovalModeOption.description }}</span>
</div>
<ApprovalWorkflowEditor
v-if="settingsForm.approvalMode === 'Multi-level'"
v-model="settingsForm.approvalSteps"
:members="workspaceMembers"
:errors="approvalStepErrors"
:disabled="workspaceStore.isUpdating"
:labels="approvalWorkflowEditorLabels"
/>
<label class="workflow-toggle">
<input
v-model="settingsForm.schedulePostsAutomaticallyOnApproval"
type="checkbox"
:disabled="workspaceStore.isUpdating"
/>
<span>
<strong>{{ t('workspaceSettings.approvals.fields.schedulePostsAutomaticallyOnApproval') }}</strong>
<small>{{ t('workspaceSettings.approvals.fieldHelp.schedulePostsAutomaticallyOnApproval') }}</small>
</span>
</label>
<label class="workflow-toggle">
<input
v-model="settingsForm.lockContentAfterApproval"
type="checkbox"
:disabled="workspaceStore.isUpdating"
/>
<span>
<strong>{{ t('workspaceSettings.approvals.fields.lockContentAfterApproval') }}</strong>
<small>{{ t('workspaceSettings.approvals.fieldHelp.lockContentAfterApproval') }}</small>
</span>
</label>
<label class="workflow-toggle">
<input
v-model="settingsForm.sendAutomaticApprovalReminders"
type="checkbox"
:disabled="workspaceStore.isUpdating"
/>
<span>
<strong>{{ t('workspaceSettings.approvals.fields.sendAutomaticApprovalReminders') }}</strong>
<small>{{ t('workspaceSettings.approvals.fieldHelp.sendAutomaticApprovalReminders') }}</small>
</span>
</label>
<button
class="primary-button"
type="button"
:disabled="workspaceStore.isUpdating || !isSettingsDirty"
@click="submitWorkspaceSettings"
>
{{ workspaceStore.isUpdating ? t('common.saving') : t('workspaceSettings.approvals.saveAction') }}
</button>
</div>
</article>
@@ -683,6 +946,7 @@
.empty-state,
.connector-row,
.workflow-rule,
.workflow-toggle,
.workflow-step {
@apply rounded-[1rem] border px-4 py-4;
background: #fffaf2;
@@ -696,10 +960,19 @@
.invite-row div,
.connector-copy,
.workflow-rule,
.workflow-toggle span,
.workflow-step-copy {
@apply flex flex-col gap-1;
}
.workflow-toggle {
@apply flex items-start gap-3 text-sm;
}
.workflow-toggle input {
@apply mt-1 h-4 w-4 accent-teal-700;
}
.connector-row {
@apply flex flex-col gap-4 md:flex-row md:items-center md:justify-between;
}

View File

@@ -16,6 +16,7 @@
mdiCalendarMonthOutline,
mdiChevronDown,
mdiCogOutline,
mdiFileDocumentOutline,
mdiFolderOutline,
mdiHomeOutline,
mdiImageMultipleOutline,
@@ -401,6 +402,36 @@
</router-link>
</div>
<div class="sidebar-section">
<div class="sidebar-section-header">
<router-link
to="/app/content"
class="sidebar-link sidebar-link-section"
active-class="sidebar-link-active"
:title="!isExpanded ? t('nav.content') : null"
>
<span class="sidebar-link-main">
<v-icon :icon="mdiFileDocumentOutline" />
<span
v-if="isExpanded"
class="sidebar-link-label"
>
{{ t('nav.content') }}
</span>
</span>
</router-link>
<router-link
v-if="isExpanded"
:to="{ name: 'content-item-create' }"
class="sidebar-section-action"
:title="t('contentItems.newItem')"
>
<v-icon :icon="mdiPlus" />
</router-link>
</div>
</div>
<div class="sidebar-section">
<div class="sidebar-section-header">
<router-link

View File

@@ -308,7 +308,7 @@
"building": "In production",
"approval": "Awaiting approval",
"rework": "Needs revision",
"ready": "Ready to publish",
"ready": "Approved",
"published": "Published",
"blocked": "Blocked",
"archived": "Archived",
@@ -559,35 +559,83 @@
},
"approvals": {
"flowTitle": "Approval flow",
"flowDescription": "Personalize how content moves through internal review, client review, and publishing for this workspace.",
"flowDescription": "Configure how content approval works across this workspace.",
"previewTitle": "Flow preview",
"previewDescription": "This is the sequence the workspace will use based on the current configuration.",
"saved": "Approval flow saved for this workspace in this browser.",
"saved": "Approval flow saved.",
"saveAction": "Save approval flow",
"fields": {
"requireInternalReview": "Require internal review",
"internalApproversRequired": "Internal approvers required",
"requireClientReview": "Require client review",
"clientApproversRequired": "Client approvers required",
"defaultReviewerRole": "Default reviewer role",
"publishBehaviour": "After final approval"
"approvalMode": "Approval mode",
"schedulePostsAutomaticallyOnApproval": "Schedule posts automatically on approval",
"lockContentAfterApproval": "Lock content after approval",
"sendAutomaticApprovalReminders": "Send automatic approval reminders"
},
"fieldHelp": {
"requireInternalReview": "Content must be approved internally before client review can begin.",
"requireClientReview": "Content must still pass through client approval before publication."
"schedulePostsAutomaticallyOnApproval": "Final approval moves content to Scheduled when it already has a planned publish date.",
"lockContentAfterApproval": "Approval-controlled content becomes locked after final approval. Scheduling fields remain editable.",
"sendAutomaticApprovalReminders": "Current approvers receive daily reminders while an approval step is pending."
},
"publishBehaviour": {
"manual": "Mark ready to publish",
"auto": "Auto-advance to ready"
"modes": {
"none": "None",
"optional": "Optional",
"required": "Required",
"multiLevel": "Multi-level"
},
"modeHelp": {
"none": "Content skips approval workflow and can become Approved without approval actions.",
"optional": "A one-step approval workflow is available but does not block publication workflow.",
"required": "At least one approval is required before content can become Approved or Scheduled.",
"multiLevel": "Approval uses ordered steps with targeted approvers for each step."
},
"editor": {
"title": "Multi-level steps",
"description": "Define who approves each ordered step before content reaches final approval.",
"addStep": "Add step",
"empty": "Add at least one approval step before saving multi-level approval.",
"unnamedStep": "Unnamed step",
"moveUp": "Move step up",
"moveDown": "Move step down",
"removeStep": "Remove step",
"selectMember": "Select a member",
"selectMembers": "Select one or more members. Hold Ctrl or Command to select multiple.",
"defaultStepName": "Approval step {number}",
"stepNumber": "Step {number}",
"fields": {
"name": "Display name",
"targetType": "Target type",
"targetValue": "Target",
"requiredApproverCount": "Required approvers"
},
"targetTypes": {
"role": "Role",
"membership": "Membership",
"member": "Member"
},
"memberships": {
"team": "Team",
"client": "Client"
},
"errors": {
"atLeastOneStep": "Multi-level approval requires at least one step.",
"fixInvalidSteps": "Fix the highlighted approval steps before saving.",
"nameRequired": "Enter a step name.",
"targetRequired": "Choose who can approve this step.",
"notEnoughMembers": "Select at least as many members as required approvers.",
"requiredApproverCount": "Enter at least 1 required approver."
}
},
"steps": {
"internal": "Internal review",
"client": "Client review",
"none": "Approval skipped",
"approval": "Approval",
"publish": "Publishing handoff"
},
"stepDetail": {
"none": "No approval workflow is created for content in this workspace.",
"optional": "Approval can be collected, but it is not required before publication workflow.",
"approverCount": "{count} approver(s) required",
"autoPublish": "Content moves to ready automatically after the final approval.",
"manualPublish": "Content stays in a manual ready-to-publish handoff after the final approval."
"multiLevelTarget": "{count} approver(s) from {target}",
"autoSchedule": "Approved content with a planned publish date moves to Scheduled.",
"manualSchedule": "Approved content remains Approved until scheduling is handled."
}
}
},

View File

@@ -308,7 +308,7 @@
"building": "En production",
"approval": "En attente d'approbation",
"rework": "Révision requise",
"ready": "Prêt à publier",
"ready": "Approuvé",
"published": "Publié",
"blocked": "Bloqué",
"archived": "Archivé",
@@ -559,35 +559,83 @@
},
"approvals": {
"flowTitle": "Flux d'approbation",
"flowDescription": "Personnalisez le passage du contenu par la révision interne, la révision client et la mise en publication pour cet espace.",
"flowDescription": "Configurez le fonctionnement de l'approbation du contenu dans cet espace.",
"previewTitle": "Aperçu du flux",
"previewDescription": "Voici la séquence que l'espace utilisera selon la configuration actuelle.",
"saved": "Le flux d'approbation a été enregistré pour cet espace dans ce navigateur.",
"saved": "Flux d'approbation enregistré.",
"saveAction": "Enregistrer le flux",
"fields": {
"requireInternalReview": "Exiger une révision interne",
"internalApproversRequired": "Approbateurs internes requis",
"requireClientReview": "Exiger une révision client",
"clientApproversRequired": "Approbateurs client requis",
"defaultReviewerRole": "Rôle du réviseur par défaut",
"publishBehaviour": "Après l'approbation finale"
"approvalMode": "Mode d'approbation",
"schedulePostsAutomaticallyOnApproval": "Planifier automatiquement après approbation",
"lockContentAfterApproval": "Verrouiller le contenu après approbation",
"sendAutomaticApprovalReminders": "Envoyer des rappels automatiques"
},
"fieldHelp": {
"requireInternalReview": "Le contenu doit être approuvé en interne avant de passer à la révision client.",
"requireClientReview": "Le contenu doit encore passer par une approbation client avant la publication."
"schedulePostsAutomaticallyOnApproval": "L'approbation finale passe le contenu à Planifié quand une date de publication est déjà prévue.",
"lockContentAfterApproval": "Le contenu contrôlé par l'approbation est verrouillé après l'approbation finale. Les champs de planification restent modifiables.",
"sendAutomaticApprovalReminders": "Les approbateurs courants reçoivent des rappels quotidiens tant qu'une étape est en attente."
},
"publishBehaviour": {
"manual": "Marquer prêt à publier",
"auto": "Passer automatiquement à prêt"
"modes": {
"none": "Aucun",
"optional": "Optionnel",
"required": "Requis",
"multiLevel": "Multi-niveaux"
},
"modeHelp": {
"none": "Le contenu saute le workflow d'approbation et peut devenir Approuvé sans action d'approbation.",
"optional": "Un workflow d'approbation en une étape est disponible, mais il ne bloque pas la publication.",
"required": "Au moins une approbation est requise avant que le contenu devienne Approuvé ou Planifié.",
"multiLevel": "L'approbation utilise des étapes ordonnées avec des approbateurs ciblés pour chaque étape."
},
"editor": {
"title": "Étapes multi-niveaux",
"description": "Définissez qui approuve chaque étape ordonnée avant l'approbation finale du contenu.",
"addStep": "Ajouter une étape",
"empty": "Ajoutez au moins une étape d'approbation avant d'enregistrer le mode multi-niveaux.",
"unnamedStep": "Étape sans nom",
"moveUp": "Monter l'étape",
"moveDown": "Descendre l'étape",
"removeStep": "Supprimer l'étape",
"selectMember": "Sélectionner un membre",
"selectMembers": "Sélectionnez un ou plusieurs membres. Maintenez Ctrl ou Commande pour une sélection multiple.",
"defaultStepName": "Étape d'approbation {number}",
"stepNumber": "Étape {number}",
"fields": {
"name": "Nom affiché",
"targetType": "Type de cible",
"targetValue": "Cible",
"requiredApproverCount": "Approbateurs requis"
},
"targetTypes": {
"role": "Rôle",
"membership": "Appartenance",
"member": "Membre"
},
"memberships": {
"team": "Équipe",
"client": "Client"
},
"errors": {
"atLeastOneStep": "L'approbation multi-niveaux requiert au moins une étape.",
"fixInvalidSteps": "Corrigez les étapes d'approbation indiquées avant d'enregistrer.",
"nameRequired": "Saisissez un nom d'étape.",
"targetRequired": "Choisissez qui peut approuver cette étape.",
"notEnoughMembers": "Sélectionnez au moins autant de membres que d'approbateurs requis.",
"requiredApproverCount": "Saisissez au moins 1 approbateur requis."
}
},
"steps": {
"internal": "Révision interne",
"client": "Révision client",
"none": "Approbation ignorée",
"approval": "Approbation",
"publish": "Passage à la publication"
},
"stepDetail": {
"none": "Aucun workflow d'approbation n'est créé pour le contenu de cet espace.",
"optional": "L'approbation peut être recueillie, mais elle n'est pas requise avant la publication.",
"approverCount": "{count} approbateur(s) requis",
"autoPublish": "Le contenu passe automatiquement à prêt après l'approbation finale.",
"manualPublish": "Le contenu reste dans une étape manuelle prêt à publier après l'approbation finale."
"multiLevelTarget": "{count} approbateur(s) de {target}",
"autoSchedule": "Le contenu approuvé avec une date de publication prévue passe à Planifié.",
"manualSchedule": "Le contenu approuvé reste Approuvé jusqu'à sa planification."
}
}
},