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,294 +7,295 @@ import { jwtDecode } from 'jwt-decode';
import { formatDuration } from '@/internal_time_ago.js';
export const useAuthStore = defineStore('auth', () => {
const clientApi = useClient();
const router = useRouter();
const clientApi = useClient();
const router = useRouter();
const isRefreshing = ref(false);
let refreshPromise = null;
const isRefreshing = ref(false);
let refreshPromise = null;
const accessToken = useSessionStorage('auth-accessToken', undefined);
const refreshToken = useSessionStorage('auth-refreshToken', undefined);
const tokenClaims = useSessionStorage('auth-tokenClaims', null, {
serializer: {
read: v => (v ? JSON.parse(v) : null),
write: v => (v ? JSON.stringify(v) : null),
},
});
const accessToken = useSessionStorage('auth-accessToken', undefined);
const refreshToken = useSessionStorage('auth-refreshToken', undefined);
const tokenClaims = useSessionStorage('auth-tokenClaims', null, {
serializer: {
read: v => (v ? JSON.parse(v) : null),
write: v => (v ? JSON.stringify(v) : null),
},
});
const isAuthenticated = computed(() => !!accessToken.value);
const userId = computed(() => tokenClaims.value?.sub);
const userRoles = computed(() => {
const claims = tokenClaims.value ?? {};
const candidates = [
claims.role,
claims.roles,
claims['http://schemas.microsoft.com/ws/2008/06/identity/claims/role'],
].flatMap(value => Array.isArray(value) ? value : value ? [value] : []);
const isAuthenticated = computed(() => !!accessToken.value);
const userId = computed(() => tokenClaims.value?.sub);
const userRoles = computed(() => {
const claims = tokenClaims.value ?? {};
const candidates = [
claims.role,
claims.roles,
claims['http://schemas.microsoft.com/ws/2008/06/identity/claims/role'],
].flatMap(value => Array.isArray(value) ? value : value ? [value] : [])
.map(v => v.toLowerCase());
return [...new Set(candidates)];
});
const persona = computed(() => tokenClaims.value?.persona ?? null);
const isManager = computed(() => userRoles.value.includes('Administrator') || userRoles.value.includes('Manager'));
const isClient = computed(() => userRoles.value.includes('Client'));
const isProvider = computed(() => userRoles.value.includes('Provider'));
return [...new Set(candidates)];
});
const persona = computed(() => tokenClaims.value?.persona ?? null);
const isManager = computed(() => userRoles.value.includes('administrator') || userRoles.value.includes('manager'));
const isClient = computed(() => userRoles.value.includes('client'));
const isProvider = computed(() => userRoles.value.includes('provider'));
function updateTokens(data) {
if (!data?.accessToken || !data?.refreshToken) {
throw new Error('Invalid token data');
}
accessToken.value = data.accessToken;
refreshToken.value = data.refreshToken;
const claims = getClaimsFromToken(data.accessToken);
tokenClaims.value = claims;
console.log('Tokens updated, user ID:', claims?.sub);
function updateTokens(data) {
if (!data?.accessToken || !data?.refreshToken) {
throw new Error('Invalid token data');
}
accessToken.value = data.accessToken;
refreshToken.value = data.refreshToken;
const claims = getClaimsFromToken(data.accessToken);
tokenClaims.value = claims;
console.log('Tokens updated, user ID:', claims?.sub);
}
function cleanTokens() {
console.log('cleanTokens called - clearing stored tokens');
accessToken.value = undefined;
refreshToken.value = undefined;
tokenClaims.value = null;
}
async function logout() {
cleanTokens();
await router.push('/');
}
async function login(email, password) {
console.log('login called with email:', email);
if (!email || !password) {
throw new Error('Email and password are required');
}
function cleanTokens() {
console.log('cleanTokens called - clearing stored tokens');
accessToken.value = undefined;
refreshToken.value = undefined;
tokenClaims.value = null;
try {
const response = await clientApi.post('api/users/login', {
email: email.trim(),
password: password,
});
if (!response.data?.accessToken || !response.data?.refreshToken) {
throw new Error('Invalid login response');
}
updateTokens(response.data);
console.log('login successful');
return true;
} catch (error) {
console.error('Login failed:', error);
cleanTokens();
throw error;
}
}
async function loginWithGoogle(accessTokenParam) {
console.log('loginWithGoogle called');
if (!accessTokenParam) {
throw new Error('Google access token is required');
}
async function logout() {
cleanTokens();
await router.push('/');
try {
const response = await clientApi.post('api/users/login-with-google', {
token: accessTokenParam,
});
if (!response.data?.accessToken || !response.data?.refreshToken) {
throw new Error('Invalid Google login response');
}
updateTokens(response.data);
console.log('Google login successful');
return true;
} catch (error) {
console.error('Google login failed:', error);
cleanTokens();
throw error;
}
}
async function loginWithFacebook(authResponse) {
console.log('loginWithFacebook called');
if (!authResponse?.accessToken) {
throw new Error('Facebook access token is required');
}
async function login(email, password) {
console.log('login called with email:', email);
if (!email || !password) {
throw new Error('Email and password are required');
}
try {
const response = await clientApi.post('api/users/login-with-facebook', {
token: authResponse.accessToken,
});
if (!response.data?.accessToken || !response.data?.refreshToken) {
throw new Error('Invalid Facebook login response');
}
updateTokens(response.data);
console.log('Facebook login successful');
return true;
} catch (error) {
console.error('Facebook login failed:', error);
cleanTokens();
throw error;
}
}
async function refresh() {
console.log('refresh called');
if (!refreshToken.value) {
cleanTokens(); // Clear tokens first
throw new Error('No refresh token available');
}
if (isRefreshing.value && refreshPromise) {
console.log('Already refreshing, returning existing refreshPromise');
return refreshPromise;
}
try {
isRefreshing.value = true;
refreshPromise = (async () => {
try {
const response = await clientApi.post('api/users/login', {
email: email.trim(),
password: password,
console.log('Sending refresh request...');
const response = await clientApi.post('api/users/refresh', {
refreshToken: refreshToken.value,
});
if (!response.data?.accessToken || !response.data?.refreshToken) {
throw new Error('Invalid refresh response');
}
updateTokens({
accessToken: response.data.accessToken,
refreshToken: response.data.refreshToken,
});
console.log('Token refresh successful');
return true;
} catch (error) {
console.error('Token refresh failed:', error);
cleanTokens();
const currentRoute = router.currentRoute.value;
const returnUrl = currentRoute.fullPath;
// Handle navigation
router
.push({
name: 'login',
query: { returnUrl },
})
.catch(navError => {
console.error('Navigation error after token refresh failure:', navError);
});
if (!response.data?.accessToken || !response.data?.refreshToken) {
throw new Error('Invalid login response');
}
updateTokens(response.data);
console.log('login successful');
return true;
} catch (error) {
console.error('Login failed:', error);
cleanTokens();
throw error;
throw error; // Re-throw to notify callers
}
})();
return await refreshPromise;
} catch (error) {
throw error;
} finally {
// Ensure these are always reset, even if an error is thrown
isRefreshing.value = false;
refreshPromise = null;
}
}
function getClaimsFromToken(token) {
if (!token) return null;
try {
return jwtDecode(token);
} catch (error) {
console.error('Failed to decode token:', error);
return null;
}
}
function isTokenExpiringSoon(token) {
if (!token) {
console.log('No token provided, considered expiring soon');
return true;
}
async function loginWithGoogle(accessTokenParam) {
console.log('loginWithGoogle called');
if (!accessTokenParam) {
throw new Error('Google access token is required');
}
try {
const response = await clientApi.post('api/users/login-with-google', {
token: accessTokenParam,
});
if (!response.data?.accessToken || !response.data?.refreshToken) {
throw new Error('Invalid Google login response');
}
updateTokens(response.data);
console.log('Google login successful');
return true;
} catch (error) {
console.error('Google login failed:', error);
cleanTokens();
throw error;
}
const claims = getClaimsFromToken(token);
if (!claims || !claims.exp) {
console.log('No valid claims found, considered expiring soon');
return true;
}
async function loginWithFacebook(authResponse) {
console.log('loginWithFacebook called');
if (!authResponse?.accessToken) {
throw new Error('Facebook access token is required');
}
const expirationTime = claims.exp * 1000; // Convert to milliseconds
const currentTime = Date.now();
const fiveMinutesInMs = 2 * 60 * 1000; // 2 minutes for demonstration
try {
const response = await clientApi.post('api/users/login-with-facebook', {
token: authResponse.accessToken,
});
// Calculate time remaining (can be negative if already expired)
const timeRemainingMs = expirationTime - currentTime;
if (!response.data?.accessToken || !response.data?.refreshToken) {
throw new Error('Invalid Facebook login response');
}
// Token is expiring soon if less than 2 minutes remaining or already expired
const isExpiring = timeRemainingMs < fiveMinutesInMs;
updateTokens(response.data);
console.log('Facebook login successful');
return true;
} catch (error) {
console.error('Facebook login failed:', error);
cleanTokens();
throw error;
}
// Determine the sign for display purposes
const formattedTimeRemaining =
timeRemainingMs < 0 ? `-${formatDuration(Math.abs(timeRemainingMs))}` : formatDuration(timeRemainingMs);
if (isExpiring) {
console.log(`Token expiration check; is token expired: ${isExpiring}`, {
expirationTime: new Date(expirationTime).toLocaleString(),
currentTime: new Date(currentTime).toLocaleString(),
timeRemaining: formattedTimeRemaining,
});
}
async function refresh() {
console.log('refresh called');
return isExpiring;
}
if (!refreshToken.value) {
cleanTokens(); // Clear tokens first
throw new Error('No refresh token available');
}
if (isRefreshing.value && refreshPromise) {
console.log('Already refreshing, returning existing refreshPromise');
return refreshPromise;
}
try {
isRefreshing.value = true;
refreshPromise = (async () => {
try {
console.log('Sending refresh request...');
const response = await clientApi.post('api/users/refresh', {
refreshToken: refreshToken.value,
});
if (!response.data?.accessToken || !response.data?.refreshToken) {
throw new Error('Invalid refresh response');
}
updateTokens({
accessToken: response.data.accessToken,
refreshToken: response.data.refreshToken,
});
console.log('Token refresh successful');
return true;
} catch (error) {
console.error('Token refresh failed:', error);
cleanTokens();
const currentRoute = router.currentRoute.value;
const returnUrl = currentRoute.fullPath;
// Handle navigation
router
.push({
name: 'login',
query: { returnUrl },
})
.catch(navError => {
console.error('Navigation error after token refresh failure:', navError);
});
throw error; // Re-throw to notify callers
}
})();
return await refreshPromise;
} catch (error) {
throw error;
} finally {
// Ensure these are always reset, even if an error is thrown
isRefreshing.value = false;
refreshPromise = null;
}
async function changePassword(newPassword) {
console.log('changePassword called');
if (!isAuthenticated.value) {
throw new Error('User must be authenticated to change password');
}
function getClaimsFromToken(token) {
if (!token) return null;
try {
return jwtDecode(token);
} catch (error) {
console.error('Failed to decode token:', error);
return null;
}
if (!newPassword) {
throw new Error('New password is required');
}
function isTokenExpiringSoon(token) {
if (!token) {
console.log('No token provided, considered expiring soon');
return true;
}
try {
const response = await clientApi.post('api/users/set-password', {
newPassword,
});
const claims = getClaimsFromToken(token);
if (!claims || !claims.exp) {
console.log('No valid claims found, considered expiring soon');
return true;
}
const expirationTime = claims.exp * 1000; // Convert to milliseconds
const currentTime = Date.now();
const fiveMinutesInMs = 2 * 60 * 1000; // 2 minutes for demonstration
// Calculate time remaining (can be negative if already expired)
const timeRemainingMs = expirationTime - currentTime;
// Token is expiring soon if less than 2 minutes remaining or already expired
const isExpiring = timeRemainingMs < fiveMinutesInMs;
// Determine the sign for display purposes
const formattedTimeRemaining =
timeRemainingMs < 0 ? `-${formatDuration(Math.abs(timeRemainingMs))}` : formatDuration(timeRemainingMs);
if (isExpiring) {
console.log(`Token expiration check; is token expired: ${isExpiring}`, {
expirationTime: new Date(expirationTime).toLocaleString(),
currentTime: new Date(currentTime).toLocaleString(),
timeRemaining: formattedTimeRemaining,
});
}
return isExpiring;
console.log('Password changed successfully');
return true;
} catch (error) {
console.error('Password change failed:', error);
throw error;
}
}
async function changePassword(newPassword) {
console.log('changePassword called');
if (!isAuthenticated.value) {
throw new Error('User must be authenticated to change password');
}
function hasAnyRole(roles) {
return roles.some(role => userRoles.value.includes(role));
}
if (!newPassword) {
throw new Error('New password is required');
}
try {
const response = await clientApi.post('api/users/set-password', {
newPassword,
});
console.log('Password changed successfully');
return true;
} catch (error) {
console.error('Password change failed:', error);
throw error;
}
}
function hasAnyRole(roles) {
return roles.some(role => userRoles.value.includes(role));
}
return {
accessToken,
refreshToken,
isAuthenticated,
userId,
userRoles,
persona,
hasAnyRole,
isManager,
isClient,
isProvider,
isRefreshing,
login,
loginWithGoogle,
loginWithFacebook,
logout,
refresh,
isTokenExpiringSoon,
changePassword,
};
return {
accessToken,
refreshToken,
isAuthenticated,
userId,
userRoles,
persona,
hasAnyRole,
isManager,
isClient,
isProvider,
isRefreshing,
login,
loginWithGoogle,
loginWithFacebook,
logout,
refresh,
isTokenExpiringSoon,
changePassword,
};
});

View File

@@ -4,19 +4,19 @@ import { useAuthStore } from '@/features/auth/stores/authStore.js';
import { useWorkspaceStore } from '@/features/workspaces/stores/workspaceStore.js';
import { useClient } from '@/plugins/api.js';
export const useProjectsStore = defineStore('projects', () => {
export const useCampaignsStore = defineStore('campaigns', () => {
const authStore = useAuthStore();
const workspaceStore = useWorkspaceStore();
const client = useClient();
const projects = ref([]);
const campaigns = ref([]);
const isLoading = ref(false);
const isCreating = ref(false);
const error = ref(null);
async function fetchProjects() {
async function fetchCampaigns() {
if (!authStore.isAuthenticated || !workspaceStore.activeWorkspaceId) {
projects.value = [];
campaigns.value = [];
error.value = null;
return;
}
@@ -25,49 +25,49 @@ export const useProjectsStore = defineStore('projects', () => {
error.value = null;
try {
const response = await client.get('/api/projects', {
const response = await client.get('/api/campaigns', {
params: {
workspaceId: workspaceStore.activeWorkspaceId,
},
});
projects.value = response.data ?? [];
campaigns.value = response.data ?? [];
} catch (fetchError) {
console.error('Failed to fetch projects:', fetchError);
projects.value = [];
error.value = 'Failed to load projects.';
console.error('Failed to fetch campaigns:', fetchError);
campaigns.value = [];
error.value = 'Failed to load campaigns.';
} finally {
isLoading.value = false;
}
}
async function createProject(payload) {
async function createCampaign(payload) {
if (!authStore.isAuthenticated || !workspaceStore.activeWorkspaceId) {
throw new Error('You must be authenticated to create a project.');
throw new Error('You must be authenticated to create a campaign.');
}
if (isCreating.value) {
throw new Error('A project creation request is already in progress.');
throw new Error('A campaign creation request is already in progress.');
}
isCreating.value = true;
error.value = null;
try {
const response = await client.post('/api/projects', {
const response = await client.post('/api/campaigns', {
...payload,
workspaceId: workspaceStore.activeWorkspaceId,
});
if (response.data) {
projects.value = [...projects.value, response.data]
campaigns.value = [...campaigns.value, response.data]
.sort((left, right) => left.name.localeCompare(right.name));
}
return response.data;
} catch (createError) {
console.error('Failed to create project:', createError);
error.value = 'Failed to create project.';
console.error('Failed to create campaign:', createError);
error.value = 'Failed to create campaign.';
throw createError;
} finally {
isCreating.value = false;
@@ -78,22 +78,22 @@ export const useProjectsStore = defineStore('projects', () => {
() => [authStore.isAuthenticated, workspaceStore.activeWorkspaceId],
async ([isAuthenticated, workspaceId]) => {
if (!isAuthenticated || !workspaceId) {
projects.value = [];
campaigns.value = [];
error.value = null;
return;
}
await fetchProjects();
await fetchCampaigns();
},
{ immediate: true }
);
return {
projects,
campaigns,
isLoading,
isCreating,
error,
fetchProjects,
createProject,
fetchCampaigns,
createCampaign,
};
});

View File

@@ -3,22 +3,22 @@
import { useRoute } from 'vue-router';
import { useAuthStore } from '@/features/auth/stores/authStore.js';
import { useWorkspaceStore } from '@/features/workspaces/stores/workspaceStore.js';
import { useProjectsStore } from '@/features/projects/stores/projectsStore.js';
import { useCampaignsStore } from '@/features/campaigns/stores/campaignsStore.js';
import { useContentItemsStore } from '@/features/content/stores/contentItemsStore.js';
const authStore = useAuthStore();
const route = useRoute();
const workspaceStore = useWorkspaceStore();
const projectsStore = useProjectsStore();
const campaignsStore = useCampaignsStore();
const contentItemsStore = useContentItemsStore();
const project = computed(() =>
projectsStore.projects.find(candidate => candidate.id === route.params.projectId) ?? null
const campaign = computed(() =>
campaignsStore.campaigns.find(candidate => candidate.id === route.params.campaignId) ?? null
);
const scopedItems = computed(() =>
contentItemsStore.items
.filter(item => item.projectId === route.params.projectId)
.filter(item => item.campaignId === route.params.campaignId)
.sort((left, right) => {
const leftDue = left.dueDate ? new Date(left.dueDate).getTime() : Number.MAX_SAFE_INTEGER;
const rightDue = right.dueDate ? new Date(right.dueDate).getTime() : Number.MAX_SAFE_INTEGER;
@@ -26,8 +26,8 @@
})
);
function formatProjectDateRange(projectValue) {
if (!projectValue?.startDate || !projectValue?.endDate) {
function formatCampaignDateRange(campaignValue) {
if (!campaignValue?.startDate || !campaignValue?.endDate) {
return 'No date range';
}
@@ -35,14 +35,14 @@
month: 'short',
day: 'numeric',
year: 'numeric',
}).formatRange(new Date(projectValue.startDate), new Date(projectValue.endDate));
}).formatRange(new Date(campaignValue.startDate), new Date(campaignValue.endDate));
}
</script>
<template>
<section class="page-shell">
<div
v-if="!project"
v-if="!campaign"
class="page-message error"
>
The selected campaign could not be found in the active workspace.
@@ -66,21 +66,21 @@
Campaigns
</router-link>
</div>
<h1>{{ project.name }}</h1>
<p>{{ project.description || `${workspaceStore.activeWorkspace?.name} delivery stream with only the content scheduled in this campaign.` }}</p>
<h1>{{ campaign.name }}</h1>
<p>{{ campaign.description || `${workspaceStore.activeWorkspace?.name} delivery stream with only the content scheduled in this campaign.` }}</p>
</div>
<div class="hero-meta">
<div class="meta-chip">{{ project.status }}</div>
<div class="meta-copy">{{ formatProjectDateRange(project) }}</div>
<div class="meta-chip">{{ campaign.status }}</div>
<div class="meta-copy">{{ formatCampaignDateRange(campaign) }}</div>
</div>
</div>
<div
v-if="project.notes"
v-if="campaign.notes"
class="page-message"
>
{{ project.notes }}
{{ campaign.notes }}
</div>
<div class="section-header">
@@ -91,10 +91,10 @@
<div class="scope-actions">
<router-link
v-if="authStore.isManager || authStore.isProvider"
:to="{ name: 'content-item-create', query: { projectId: project.id } }"
:to="{ name: 'content-item-create', query: { campaignId: campaign.id } }"
class="scope-button"
>
New content in {{ project.name }}
New content in {{ campaign.name }}
</router-link>
</div>

View File

@@ -5,13 +5,13 @@
import { useAuthStore } from '@/features/auth/stores/authStore.js';
import { useClientsStore } from '@/features/clients/stores/clientsStore.js';
import { useWorkspaceStore } from '@/features/workspaces/stores/workspaceStore.js';
import { useProjectsStore } from '@/features/projects/stores/projectsStore.js';
import { useCampaignsStore } from '@/features/campaigns/stores/campaignsStore.js';
const route = useRoute();
const authStore = useAuthStore();
const workspaceStore = useWorkspaceStore();
const clientsStore = useClientsStore();
const projectsStore = useProjectsStore();
const campaignsStore = useCampaignsStore();
const { t } = useI18n();
const isCreateFormVisible = ref(false);
const formError = ref(null);
@@ -41,29 +41,29 @@
}
async function submitForm() {
if (projectsStore.isCreating) {
if (campaignsStore.isCreating) {
return;
}
formError.value = null;
if (!form.name || !form.startDate || !form.endDate) {
formError.value = t('projects.errors.required');
formError.value = t('campaigns.errors.required');
return;
}
if (new Date(form.endDate) < new Date(form.startDate)) {
formError.value = t('projects.errors.invalidDateRange');
formError.value = t('campaigns.errors.invalidDateRange');
return;
}
if (!operationalClient.value?.id) {
formError.value = t('projects.errors.workspaceAccountRequired');
formError.value = t('campaigns.errors.workspaceAccountRequired');
return;
}
try {
await projectsStore.createProject({
await campaignsStore.createCampaign({
clientId: operationalClient.value.id,
name: form.name,
startDate: new Date(form.startDate).toISOString(),
@@ -75,7 +75,7 @@
isCreateFormVisible.value = false;
resetForm();
} catch (error) {
formError.value = t('projects.errors.createFailed');
formError.value = t('campaigns.errors.createFailed');
}
}
@@ -89,13 +89,13 @@
{ immediate: true }
);
function formatProjectDateRange(project) {
if (!project?.startDate || !project?.endDate) {
return t('projects.noDateRange');
function formatCampaignDateRange(campaign) {
if (!campaign?.startDate || !campaign?.endDate) {
return t('campaigns.noDateRange');
}
const start = new Date(project.startDate);
const end = new Date(project.endDate);
const start = new Date(campaign.startDate);
const end = new Date(campaign.endDate);
return new Intl.DateTimeFormat(undefined, {
month: 'short',
day: 'numeric',
@@ -108,9 +108,9 @@
<section class="page-shell">
<div class="header">
<div>
<div class="eyebrow">{{ t('projects.eyebrow') }}</div>
<h1>{{ t('projects.title') }}</h1>
<p>{{ t('projects.description') }}</p>
<div class="eyebrow">{{ t('campaigns.eyebrow') }}</div>
<h1>{{ t('campaigns.title') }}</h1>
<p>{{ t('campaigns.description') }}</p>
</div>
</div>
@@ -120,7 +120,7 @@
class="create-button"
@click="openCreateForm"
>
{{ t('projects.newProject') }}
{{ t('campaigns.newCampaign') }}
</button>
</div>
@@ -129,7 +129,7 @@
class="create-panel"
>
<div class="panel-header">
<strong>{{ t('projects.createTitle') }}</strong>
<strong>{{ t('campaigns.createTitle') }}</strong>
<span>{{ workspaceStore.activeWorkspace?.name }}</span>
</div>
@@ -142,45 +142,45 @@
<div class="form-grid">
<label class="field">
<span>{{ t('projects.fields.startDate') }}</span>
<span>{{ t('campaigns.fields.startDate') }}</span>
<input
v-model="form.startDate"
type="date"
:disabled="projectsStore.isCreating"
:disabled="campaignsStore.isCreating"
/>
</label>
<label class="field">
<span>{{ t('projects.fields.endDate') }}</span>
<span>{{ t('campaigns.fields.endDate') }}</span>
<input
v-model="form.endDate"
type="date"
:disabled="projectsStore.isCreating"
:disabled="campaignsStore.isCreating"
/>
</label>
<label class="field field-wide">
<span>{{ t('projects.fields.name') }}</span>
<span>{{ t('campaigns.fields.name') }}</span>
<input
v-model="form.name"
type="text"
:disabled="projectsStore.isCreating"
:disabled="campaignsStore.isCreating"
/>
</label>
<label class="field field-wide">
<span>{{ t('projects.fields.description') }}</span>
<span>{{ t('campaigns.fields.description') }}</span>
<textarea
v-model="form.description"
:disabled="projectsStore.isCreating"
:disabled="campaignsStore.isCreating"
></textarea>
</label>
<label class="field field-wide">
<span>{{ t('projects.fields.notes') }}</span>
<span>{{ t('campaigns.fields.notes') }}</span>
<textarea
v-model="form.notes"
:disabled="projectsStore.isCreating"
:disabled="campaignsStore.isCreating"
></textarea>
</label>
</div>
@@ -188,64 +188,64 @@
<div class="panel-actions">
<button
class="secondary"
:disabled="projectsStore.isCreating"
:disabled="campaignsStore.isCreating"
@click="isCreateFormVisible = false"
>
{{ t('common.cancel') }}
</button>
<button
class="primary"
:disabled="projectsStore.isCreating"
:disabled="campaignsStore.isCreating"
@click="submitForm"
>
<v-progress-circular
v-if="projectsStore.isCreating"
v-if="campaignsStore.isCreating"
indeterminate
:size="16"
:width="2"
/>
<span>{{ projectsStore.isCreating ? t('common.creating') : t('projects.createTitle') }}</span>
<span>{{ campaignsStore.isCreating ? t('common.creating') : t('campaigns.createTitle') }}</span>
</button>
</div>
</div>
<div
v-if="projectsStore.isLoading"
v-if="campaignsStore.isLoading"
class="page-message"
>
{{ t('projects.loading') }}
{{ t('campaigns.loading') }}
</div>
<div
v-else-if="projectsStore.error"
v-else-if="campaignsStore.error"
class="page-message error"
>
{{ projectsStore.error }}
{{ campaignsStore.error }}
</div>
<div class="project-stack">
<div class="campaign-stack">
<router-link
v-for="project in projectsStore.projects"
:key="project.id"
:to="{ name: 'campaign-detail', params: { projectId: project.id } }"
class="project-row"
v-for="campaign in campaignsStore.campaigns"
:key="campaign.id"
:to="{ name: 'campaign-detail', params: { campaignId: campaign.id } }"
class="campaign-row"
>
<div>
<strong>{{ project.name }}</strong>
<span>{{ project.description || project.status }}</span>
<strong>{{ campaign.name }}</strong>
<span>{{ campaign.description || campaign.status }}</span>
</div>
<div class="project-meta">
<div class="campaign-meta">
<span>{{ workspaceStore.activeWorkspace?.name || t('nav.noWorkspace') }}</span>
<em>{{ formatProjectDateRange(project) }}</em>
<em>{{ formatCampaignDateRange(campaign) }}</em>
</div>
</router-link>
</div>
<div
v-if="!projectsStore.isLoading && !projectsStore.projects.length"
v-if="!campaignsStore.isLoading && !campaignsStore.campaigns.length"
class="page-message"
>
{{ t('projects.empty') }}
{{ t('campaigns.empty') }}
</div>
</section>
</template>
@@ -267,9 +267,9 @@
.header p,
.panel-header span,
.project-row span,
.project-meta span,
.project-meta em {
.campaign-row span,
.campaign-meta span,
.campaign-meta em {
@apply text-sm leading-6 not-italic;
color: #526178;
}
@@ -296,7 +296,7 @@
}
.create-panel,
.project-row {
.campaign-row {
@apply rounded-[1.5rem] border;
background: rgba(255, 255, 255, 0.9);
border-color: rgba(23, 32, 51, 0.08);
@@ -311,7 +311,7 @@
}
.panel-header strong,
.project-row strong {
.campaign-row strong {
color: #172033;
}
@@ -347,19 +347,19 @@
@apply flex justify-end gap-3;
}
.project-stack {
.campaign-stack {
@apply flex flex-col gap-4;
}
.project-row {
.campaign-row {
@apply flex flex-col justify-between gap-4 p-5 no-underline lg:flex-row lg:items-center;
}
.project-row strong {
.campaign-row strong {
@apply block text-xl font-black;
}
.project-meta {
.campaign-meta {
@apply flex flex-col items-start gap-1 lg:items-end;
}

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

@@ -5,13 +5,13 @@
import ImageCropperDialog from '@/components/ImageCropperDialog.vue';
import { useAuthStore } from '@/features/auth/stores/authStore.js';
import { useClientsStore } from '@/features/clients/stores/clientsStore.js';
import { useProjectsStore } from '@/features/projects/stores/projectsStore.js';
import { useCampaignsStore } from '@/features/campaigns/stores/campaignsStore.js';
import { useContentItemsStore } from '@/features/content/stores/contentItemsStore.js';
const authStore = useAuthStore();
const route = useRoute();
const clientsStore = useClientsStore();
const projectsStore = useProjectsStore();
const campaignsStore = useCampaignsStore();
const contentItemsStore = useContentItemsStore();
const isEditFormVisible = ref(false);
const isPortraitDialogOpen = ref(false);
@@ -48,9 +48,9 @@
clientsStore.clients.find(candidate => candidate.id === route.params.clientId) ?? null
);
const scopedProjects = computed(() =>
projectsStore.projects
.filter(project => project.clientId === route.params.clientId)
const scopedCampaigns = computed(() =>
campaignsStore.campaigns
.filter(campaign => campaign.clientId === route.params.clientId)
.sort((left, right) => {
const leftDue = left.endDate ? new Date(left.endDate).getTime() : Number.MAX_SAFE_INTEGER;
const rightDue = right.endDate ? new Date(right.endDate).getTime() : Number.MAX_SAFE_INTEGER;
@@ -58,26 +58,26 @@
})
);
const currentProjects = computed(() =>
scopedProjects.value.filter(project => project.status !== 'Completed' && project.status !== 'Archived')
const currentCampaigns = computed(() =>
scopedCampaigns.value.filter(campaign => campaign.status !== 'Completed' && campaign.status !== 'Archived')
);
const pastProjects = computed(() =>
scopedProjects.value.filter(project => project.status === 'Completed' || project.status === 'Archived')
const pastCampaigns = computed(() =>
scopedCampaigns.value.filter(campaign => campaign.status === 'Completed' || campaign.status === 'Archived')
);
const itemCountByProjectId = computed(() => {
const itemCountByCampaignId = computed(() => {
const counts = new Map();
for (const item of contentItemsStore.items.filter(candidate => candidate.clientId === route.params.clientId)) {
counts.set(item.projectId, (counts.get(item.projectId) ?? 0) + 1);
counts.set(item.campaignId, (counts.get(item.campaignId) ?? 0) + 1);
}
return counts;
});
function formatProjectDateRange(project) {
if (!project?.startDate || !project?.endDate) {
function formatCampaignDateRange(campaign) {
if (!campaign?.startDate || !campaign?.endDate) {
return 'No date range';
}
@@ -85,7 +85,7 @@
month: 'short',
day: 'numeric',
year: 'numeric',
}).formatRange(new Date(project.startDate), new Date(project.endDate));
}).formatRange(new Date(campaign.startDate), new Date(campaign.endDate));
}
function syncForm() {
@@ -188,18 +188,18 @@
<div class="hero-meta">
<span class="hero-status">{{ client.status }}</span>
</div>
<p>The client area scopes projects and content so review stays inside one account.</p>
<p>The client area scopes campaigns and content so review stays inside one account.</p>
</div>
</div>
<div class="stats-grid">
<article class="stat-card">
<span>Current campaigns</span>
<strong>{{ currentProjects.length }}</strong>
<strong>{{ currentCampaigns.length }}</strong>
</article>
<article class="stat-card">
<span>Past campaigns</span>
<strong>{{ pastProjects.length }}</strong>
<strong>{{ pastCampaigns.length }}</strong>
</article>
<article class="stat-card">
<span>Total content items</span>
@@ -420,26 +420,26 @@
<div class="section">
<div class="section-header">
<strong>Current campaigns</strong>
<span>{{ currentProjects.length }} active</span>
<span>{{ currentCampaigns.length }} active</span>
</div>
<div
v-if="currentProjects.length"
class="project-list"
v-if="currentCampaigns.length"
class="campaign-list"
>
<router-link
v-for="project in currentProjects"
:key="project.id"
:to="{ name: 'client-project-detail', params: { clientId: client.id, projectId: project.id } }"
class="project-card"
v-for="campaign in currentCampaigns"
:key="campaign.id"
:to="{ name: 'campaign-detail', params: { campaignId: campaign.id } }"
class="campaign-card"
>
<div>
<strong>{{ project.name }}</strong>
<span>{{ project.status }}</span>
<strong>{{ campaign.name }}</strong>
<span>{{ campaign.status }}</span>
</div>
<div class="project-meta">
<small>{{ itemCountByProjectId.get(project.id) ?? 0 }} content items</small>
<em>{{ formatProjectDateRange(project) }}</em>
<div class="campaign-meta">
<small>{{ itemCountByCampaignId.get(campaign.id) ?? 0 }} content items</small>
<em>{{ formatCampaignDateRange(campaign) }}</em>
</div>
</router-link>
</div>
@@ -454,26 +454,26 @@
<div class="section">
<div class="section-header">
<strong>Past campaigns</strong>
<span>{{ pastProjects.length }} archived or completed</span>
<span>{{ pastCampaigns.length }} archived or completed</span>
</div>
<div
v-if="pastProjects.length"
class="project-list"
v-if="pastCampaigns.length"
class="campaign-list"
>
<router-link
v-for="project in pastProjects"
:key="project.id"
:to="{ name: 'client-project-detail', params: { clientId: client.id, projectId: project.id } }"
class="project-card muted"
v-for="campaign in pastCampaigns"
:key="campaign.id"
:to="{ name: 'campaign-detail', params: { campaignId: campaign.id } }"
class="campaign-card muted"
>
<div>
<strong>{{ project.name }}</strong>
<span>{{ project.status }}</span>
<strong>{{ campaign.name }}</strong>
<span>{{ campaign.status }}</span>
</div>
<div class="project-meta">
<small>{{ itemCountByProjectId.get(project.id) ?? 0 }} content items</small>
<em>{{ formatProjectDateRange(project) }}</em>
<div class="campaign-meta">
<small>{{ itemCountByCampaignId.get(campaign.id) ?? 0 }} content items</small>
<em>{{ formatCampaignDateRange(campaign) }}</em>
</div>
</router-link>
</div>
@@ -489,7 +489,7 @@
.hero,
.stat-card,
.project-card {
.campaign-card {
@apply rounded-[1.5rem] border;
background: rgba(255, 255, 255, 0.9);
border-color: rgba(23, 32, 51, 0.08);
@@ -501,7 +501,7 @@
.hero-main h1,
.stat-card strong,
.project-card strong,
.campaign-card strong,
.contact-card strong {
color: #172033;
}
@@ -513,9 +513,9 @@
.hero-main p,
.breadcrumb,
.stat-card span,
.project-card span,
.project-card small,
.project-card em,
.campaign-card span,
.campaign-card small,
.campaign-card em,
.section-header span {
@apply text-sm leading-6 not-italic;
color: #526178;
@@ -675,27 +675,27 @@
color: #172033;
}
.project-list {
.campaign-list {
@apply grid gap-4 md:grid-cols-2;
}
.project-card {
.campaign-card {
@apply flex flex-col gap-4 p-5 no-underline transition;
}
.project-card:hover {
.campaign-card:hover {
transform: translateY(-2px);
}
.project-card.muted {
.campaign-card.muted {
background: rgba(255, 250, 242, 0.88);
}
.project-card span {
.campaign-card span {
@apply uppercase tracking-[0.16em];
}
.project-meta {
.campaign-meta {
@apply flex items-center justify-between gap-3;
}

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
);
@@ -34,7 +34,7 @@ export const useContentItemsStore = defineStore('content-items', () => {
params: {
workspaceId: workspaceStore.activeWorkspaceId,
clientId: filters.clientId,
projectId: filters.projectId,
campaignId: filters.campaignId,
},
});

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>

View File

@@ -8,7 +8,7 @@
import { useContentItemDetailStore } from '@/features/content/stores/contentItemDetailStore.js';
import { useContentItemsStore } from '@/features/content/stores/contentItemsStore.js';
import { useFeedbackSubmissionStore } from '@/features/feedback/stores/feedbackSubmissionStore.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';
import {
mdiArrowTopRight,
@@ -33,7 +33,7 @@
const contentItemsStore = useContentItemsStore();
const contentItemDetailStore = useContentItemDetailStore();
const feedbackStore = useFeedbackSubmissionStore();
const projectsStore = useProjectsStore();
const campaignsStore = useCampaignsStore();
const workspaceStore = useWorkspaceStore();
const form = reactive({
@@ -83,16 +83,16 @@
? contentItemDetailStore.item
: contentItemsStore.items.find(item => item.id === routeId) ?? null;
});
const currentProject = computed(() => {
const projectId = route.params.projectId ?? currentContentItem.value?.projectId;
if (!projectId) {
const currentCampaign = computed(() => {
const campaignId = route.params.campaignId ?? currentContentItem.value?.campaignId;
if (!campaignId) {
return null;
}
return projectsStore.projects.find(project => project.id === projectId) ?? null;
return campaignsStore.campaigns.find(campaign => campaign.id === campaignId) ?? null;
});
const currentClient = computed(() => {
const clientId = route.query.clientId ?? currentProject.value?.clientId ?? currentContentItem.value?.clientId;
const clientId = route.query.clientId ?? currentCampaign.value?.clientId ?? currentContentItem.value?.clientId;
if (!clientId) {
return clientsStore.operationalClient ?? null;
}
@@ -447,8 +447,8 @@
workspaceName: workspaceStore.activeWorkspace?.name ?? null,
clientId: currentClient.value?.id ?? null,
clientName: currentClient.value?.name ?? null,
projectId: currentProject.value?.id ?? null,
projectName: currentProject.value?.name ?? null,
campaignId: currentCampaign.value?.id ?? null,
campaignName: currentCampaign.value?.name ?? null,
contentItemId: currentContentItem.value?.id ?? null,
contentItemTitle: currentContentItem.value?.title ?? null,
};

View File

@@ -82,7 +82,7 @@ export const useDeveloperFeedbackStore = defineStore('developer-feedback', () =>
report.metadata?.submittedPath,
report.context?.workspaceName,
report.context?.clientName,
report.context?.projectName,
report.context?.campaignName,
report.context?.contentItemTitle,
...(report.tags ?? []),
]

View File

@@ -54,7 +54,7 @@
return [
[t('feedback.review.detail.context.workspace'), context?.workspaceName ?? context?.workspaceId],
[t('feedback.review.detail.context.client'), context?.clientName ?? context?.clientId],
[t('feedback.review.detail.context.project'), context?.projectName ?? context?.projectId],
[t('feedback.review.detail.context.campaign'), context?.campaignName ?? context?.campaignId],
[t('feedback.review.detail.context.contentItem'), context?.contentItemTitle ?? context?.contentItemId],
];
});

View File

@@ -49,7 +49,7 @@
return [
report.context?.workspaceName,
report.context?.clientName,
report.context?.projectName,
report.context?.campaignName,
report.context?.contentItemTitle,
]
.filter(Boolean)

View File

@@ -6,7 +6,7 @@ export function getNotificationRoute(notification, authStore) {
if (isFeedbackNotification(notification)) {
return {
name: authStore.hasAnyRole(['Developer']) ? 'developer-feedback-detail' : 'my-feedback-detail',
name: authStore.hasAnyRole(['developer']) ? 'developer-feedback-detail' : 'my-feedback-detail',
params: { id: notification.entityId },
};
}

View File

@@ -1,38 +1,31 @@
import { computed } from 'vue';
import { defineStore } from 'pinia';
import { useContentItemsStore } from '@/features/content/stores/contentItemsStore.js';
import { useProjectsStore } from '@/features/projects/stores/projectsStore.js';
import { useCampaignsStore } from '@/features/campaigns/stores/campaignsStore.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', () => {
const contentItemsStore = useContentItemsStore();
const projectsStore = useProjectsStore();
const campaignsStore = useCampaignsStore();
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);
const campaign = campaignsStore.campaigns.find(candidate => candidate.id === item.campaignId);
return {
id: item.id,
title: item.title,
projectName: project?.name ?? 'Unknown campaign',
campaignName: campaign?.name ?? 'Unknown campaign',
stage: stageByStatus[item.status] ?? item.status,
status: item.status,
dueLabel: item.dueDate ? `Due ${new Date(item.dueDate).toLocaleDateString()}` : 'No due date',

View File

@@ -24,7 +24,7 @@
>
<div>
<strong>{{ item.title }}</strong>
<span>{{ item.projectName }} · {{ item.stage }}</span>
<span>{{ item.campaignName }} · {{ item.stage }}</span>
</div>
<div class="queue-meta">
<em>{{ item.status }}</em>

View File

@@ -60,7 +60,7 @@ export const useUserProfileStore = defineStore(
const persona = computed(() => value.value?.persona ?? null)
const authorizedWorkspaceIds = computed(() => value.value?.authorizedWorkspaceIds ?? [])
const authorizedClientIds = computed(() => value.value?.authorizedClientIds ?? [])
const authorizedProjectIds = computed(() => value.value?.authorizedProjectIds ?? [])
const authorizedCampaignIds = computed(() => value.value?.authorizedCampaignIds ?? [])
async function fetchCurrentUserProfile() {
try {
@@ -214,7 +214,7 @@ export const useUserProfileStore = defineStore(
persona,
authorizedWorkspaceIds,
authorizedClientIds,
authorizedProjectIds,
authorizedCampaignIds,
changeFullname,
changeAlias,
changeBirthday,

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

@@ -3,12 +3,12 @@
import { useI18n } from 'vue-i18n';
import { mdiChevronLeft, mdiChevronRight } from '@mdi/js';
import { useWorkspaceStore } from '@/features/workspaces/stores/workspaceStore.js';
import { useProjectsStore } from '@/features/projects/stores/projectsStore.js';
import { useCampaignsStore } from '@/features/campaigns/stores/campaignsStore.js';
import { useContentItemsStore } from '@/features/content/stores/contentItemsStore.js';
const { t, locale } = useI18n();
const workspaceStore = useWorkspaceStore();
const projectsStore = useProjectsStore();
const campaignsStore = useCampaignsStore();
const contentItemsStore = useContentItemsStore();
const today = startOfDay(new Date());
@@ -17,42 +17,35 @@
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(() => {
const contentItemsByCampaignId = computed(() => {
const grouped = new Map();
for (const item of contentItemsStore.items) {
const existing = grouped.get(item.projectId) ?? [];
const existing = grouped.get(item.campaignId) ?? [];
existing.push(item);
grouped.set(item.projectId, existing);
grouped.set(item.campaignId, existing);
}
return grouped;
});
const calendarEntries = computed(() => {
const projectEntries = projectsStore.projects
.filter(project => project.endDate || project.startDate)
.map(project => buildProjectEntry(project));
const campaignEntries = campaignsStore.campaigns
.filter(campaign => campaign.endDate || campaign.startDate)
.map(campaign => buildCampaignEntry(campaign));
const contentEntries = contentItemsStore.items
.filter(item => item.dueDate && item.status !== 'Archived')
.filter(item => item.dueDate)
.map(item => buildContentEntry(item));
return [...projectEntries, ...contentEntries].sort(sortByDate);
return [...campaignEntries, ...contentEntries].sort(sortByDate);
});
const entriesByDay = computed(() => {
@@ -126,11 +119,11 @@
});
const isLoading = computed(() =>
workspaceStore.isLoading || projectsStore.isLoading || contentItemsStore.isLoading
workspaceStore.isLoading || campaignsStore.isLoading || contentItemsStore.isLoading
);
const pageError = computed(() =>
workspaceStore.error || projectsStore.error || contentItemsStore.error
workspaceStore.error || campaignsStore.error || contentItemsStore.error
);
function buildDay(date, isOutsideMonth) {
@@ -147,13 +140,13 @@
function buildContentEntry(item) {
const statusMeta = contentStatusMeta[item.status] ?? { tone: 'production', readiness: 'building' };
const project = projectsStore.projects.find(candidate => candidate.id === item.projectId);
const campaign = campaignsStore.campaigns.find(candidate => candidate.id === item.campaignId);
return {
id: item.id,
type: 'content',
title: item.title,
subtitle: project?.name ?? t('dashboard.labels.unassignedProject'),
subtitle: campaign?.name ?? t('dashboard.labels.unassignedCampaign'),
scheduledAt: new Date(item.dueDate),
dayKey: dateKey(item.dueDate),
timeLabel: formatHour(item.dueDate),
@@ -162,22 +155,22 @@
};
}
function buildProjectEntry(project) {
const projectItems = contentItemsByProjectId.value.get(project.id) ?? [];
const approvedCount = projectItems.filter(item => ['Approved', 'Ready to publish', 'Published'].includes(item.status)).length;
function buildCampaignEntry(campaign) {
const campaignItems = contentItemsByCampaignId.value.get(campaign.id) ?? [];
const approvedCount = campaignItems.filter(item => ['Approved', 'Scheduled', 'Published'].includes(item.status)).length;
return {
id: project.id,
type: 'project',
title: project.name,
subtitle: projectItems.length
? t('dashboard.projectProgress', { scheduled: projectItems.length, approved: approvedCount })
id: campaign.id,
type: 'campaign',
title: campaign.name,
subtitle: campaignItems.length
? t('dashboard.campaignProgress', { scheduled: campaignItems.length, approved: approvedCount })
: t('dashboard.readiness.missing'),
scheduledAt: new Date(project.endDate ?? project.startDate),
dayKey: dateKey(project.endDate ?? project.startDate),
scheduledAt: new Date(campaign.endDate ?? campaign.startDate),
dayKey: dateKey(campaign.endDate ?? campaign.startDate),
timeLabel: t('dashboard.campaignDeadline'),
tone: projectItems.length ? 'project' : 'risk',
route: { name: 'campaign-detail', params: { projectId: project.id } },
tone: campaignItems.length ? 'campaign' : 'risk',
route: { name: 'campaign-detail', params: { campaignId: campaign.id } },
};
}
@@ -560,7 +553,7 @@
border-color: rgba(220, 38, 38, 0.16);
}
.calendar-entry.project {
.calendar-entry.campaign {
background: #f8fafc;
border-color: rgba(71, 85, 105, 0.18);
border-style: dashed;

View File

@@ -12,7 +12,7 @@
const isLoading = ref(false);
const error = ref(null);
const projects = ref([]);
const campaigns = ref([]);
const contentItems = ref([]);
const notifications = ref([]);
@@ -22,7 +22,7 @@
const workspaceStats = computed(() =>
workspaceStore.workspaces.map(workspace => {
const workspaceProjects = projects.value.filter(project => project.workspaceId === workspace.id);
const workspaceCampaigns = campaigns.value.filter(campaign => campaign.workspaceId === workspace.id);
const workspaceContent = contentItems.value.filter(item => item.workspaceId === workspace.id);
const upcomingCount = workspaceContent.filter(item => {
if (!item.dueDate) {
@@ -32,15 +32,13 @@
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,
name: workspace.name,
timeZone: workspace.timeZone,
projectCount: workspaceProjects.length,
campaignCount: workspaceCampaigns.length,
contentCount: workspaceContent.length,
upcomingCount,
blockingCount,
@@ -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)
@@ -96,14 +94,14 @@
const overviewStats = computed(() => [
{ label: t('overview.stats.workspaces'), value: workspaceStore.workspaces.length },
{ label: t('overview.stats.projects'), value: projects.value.length },
{ label: t('overview.stats.campaigns'), value: campaigns.value.length },
{ label: t('overview.stats.upcoming'), value: upcomingEvents.value.length },
{ label: t('overview.stats.blockers'), value: crossWorkspaceRisks.value.length },
]);
async function loadOverview() {
if (!authStore.isAuthenticated) {
projects.value = [];
campaigns.value = [];
contentItems.value = [];
notifications.value = [];
return;
@@ -113,19 +111,19 @@
error.value = null;
try {
const [projectsResponse, contentItemsResponse, notificationsResponse] = await Promise.all([
client.get('/api/projects'),
const [campaignsResponse, contentItemsResponse, notificationsResponse] = await Promise.all([
client.get('/api/campaigns'),
client.get('/api/content-items'),
client.get('/api/notifications'),
]);
projects.value = projectsResponse.data ?? [];
campaigns.value = campaignsResponse.data ?? [];
contentItems.value = contentItemsResponse.data ?? [];
notifications.value = notificationsResponse.data ?? [];
} catch (loadError) {
console.error('Failed to load cross-workspace overview:', loadError);
error.value = 'Failed to load overview data.';
projects.value = [];
campaigns.value = [];
contentItems.value = [];
notifications.value = [];
} finally {
@@ -161,7 +159,7 @@
if (isAuthenticated) {
await loadOverview();
} else {
projects.value = [];
campaigns.value = [];
contentItems.value = [];
notifications.value = [];
}
@@ -238,7 +236,7 @@
<span>{{ workspace.timeZone }}</span>
</div>
<div class="workspace-meta">
<small>{{ workspace.projectCount }} {{ t('overview.labels.projects') }}</small>
<small>{{ workspace.campaignCount }} {{ t('overview.labels.campaigns') }}</small>
<small>{{ workspace.upcomingCount }} {{ t('overview.labels.upcoming') }}</small>
<small>{{ workspace.blockingCount }} {{ t('overview.labels.blocked') }}</small>
</div>

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