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

@@ -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;
}