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

@@ -100,22 +100,6 @@ export interface paths {
patch?: never;
trace?: never;
};
"/api/projects": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get: operations["SocializeApiModulesProjectsHandlersGetProjectsHandler"];
put?: never;
post: operations["SocializeApiModulesProjectsHandlersCreateProjectHandler"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/notifications": {
parameters: {
query?: never;
@@ -436,6 +420,38 @@ export interface paths {
patch?: never;
trace?: never;
};
"/api/feedback/{id}/comments": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
post: operations["SocializeApiModulesFeedbackHandlersAddDeveloperFeedbackCommentHandler"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/my-feedback/{id}/comments": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
post: operations["SocializeApiModulesFeedbackHandlersAddMyFeedbackCommentHandler"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/my-feedback/{id}/screenshot": {
parameters: {
query?: never;
@@ -484,6 +500,22 @@ export interface paths {
patch: operations["SocializeApiModulesFeedbackHandlersUpdateDeveloperFeedbackHandler"];
trace?: never;
};
"/api/feedback/{id}/timeline": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get: operations["SocializeApiModulesFeedbackHandlersGetDeveloperFeedbackTimelineHandler"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/feedback/{id}/screenshot": {
parameters: {
query?: never;
@@ -516,6 +548,22 @@ export interface paths {
patch?: never;
trace?: never;
};
"/api/my-feedback/{id}/timeline": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get: operations["SocializeApiModulesFeedbackHandlersGetMyFeedbackTimelineHandler"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/feedback": {
parameters: {
query?: never;
@@ -708,6 +756,22 @@ export interface paths {
patch?: never;
trace?: never;
};
"/api/campaigns": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get: operations["SocializeApiModulesCampaignsHandlersGetCampaignsHandler"];
put?: never;
post: operations["SocializeApiModulesCampaignsHandlersCreateCampaignHandler"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/assets/{id}/revisions": {
parameters: {
query?: never;
@@ -818,6 +882,26 @@ export interface components {
slug?: string;
logoUrl?: string | null;
timeZone?: string;
approvalMode?: string;
schedulePostsAutomaticallyOnApproval?: boolean;
lockContentAfterApproval?: boolean;
sendAutomaticApprovalReminders?: boolean;
approvalSteps?: components["schemas"]["SocializeApiModulesWorkspacesHandlersApprovalStepConfigurationDto"][];
/** Format: date-time */
createdAt?: string;
};
SocializeApiModulesWorkspacesHandlersApprovalStepConfigurationDto: {
/** Format: guid */
id?: string;
/** Format: guid */
workspaceId?: string;
name?: string;
/** Format: int32 */
sortOrder?: number;
targetType?: string;
targetValue?: string;
/** Format: int32 */
requiredApproverCount?: number;
/** Format: date-time */
createdAt?: string;
};
@@ -853,37 +937,21 @@ export interface components {
SocializeApiModulesWorkspacesHandlersUpdateWorkspaceRequest: {
name: string;
timeZone: string;
approvalMode?: string | null;
schedulePostsAutomaticallyOnApproval?: boolean | null;
lockContentAfterApproval?: boolean | null;
sendAutomaticApprovalReminders?: boolean | null;
approvalSteps?: components["schemas"]["SocializeApiModulesWorkspacesHandlersUpdateApprovalStepConfigurationRequest"][] | null;
};
SocializeApiModulesProjectsHandlersProjectDto: {
/** Format: guid */
id?: string;
/** Format: guid */
workspaceId?: string;
/** Format: guid */
clientId?: string;
SocializeApiModulesWorkspacesHandlersUpdateApprovalStepConfigurationRequest: {
name?: string;
description?: string | null;
notes?: string | null;
status?: string;
/** Format: date-time */
startDate?: string;
/** Format: date-time */
endDate?: string;
/** Format: int32 */
sortOrder?: number;
targetType?: string;
targetValue?: string;
/** Format: int32 */
requiredApproverCount?: number;
};
SocializeApiModulesProjectsHandlersCreateProjectRequest: {
/** Format: guid */
workspaceId: string;
/** Format: guid */
clientId: string;
name: string;
/** Format: date-time */
startDate: string;
/** Format: date-time */
endDate: string;
description?: string | null;
notes?: string | null;
};
SocializeApiModulesProjectsHandlersGetProjectsRequest: Record<string, never>;
SocializeApiModulesNotificationsHandlersNotificationEventDto: {
/** Format: guid */
id?: string;
@@ -943,7 +1011,7 @@ export interface components {
persona?: string | null;
authorizedWorkspaceIds?: string[];
authorizedClientIds?: string[];
authorizedProjectIds?: string[];
authorizedCampaignIds?: string[];
username?: string;
alias?: string | null;
portraitUrl?: string | null;
@@ -1018,6 +1086,26 @@ export interface components {
message?: string;
};
SocializeApiModulesIdentityHandlersVerifyEmailRequest: Record<string, never>;
SocializeApiModulesFeedbackContractsFeedbackTimelineItemDto: {
/** Format: guid */
id?: string;
kind?: string;
/** Format: guid */
actorUserId?: string;
actorDisplayName?: string;
actorEmail?: string;
actorRole?: string | null;
body?: string | null;
activityType?: string | null;
fromValue?: string | null;
toValue?: string | null;
note?: string | null;
/** Format: date-time */
createdAt?: string;
};
SocializeApiModulesFeedbackHandlersAddFeedbackCommentRequest: {
body: string;
};
SocializeApiModulesFeedbackContractsFeedbackReportDto: {
/** Format: guid */
id?: string;
@@ -1032,6 +1120,7 @@ export interface components {
context?: components["schemas"]["SocializeApiModulesFeedbackContractsFeedbackContextDto"];
screenshot?: components["schemas"]["SocializeApiModulesFeedbackContractsFeedbackScreenshotDto"] | null;
tags?: string[];
timeline?: components["schemas"]["SocializeApiModulesFeedbackContractsFeedbackTimelineItemDto"][];
/** Format: date-time */
createdAt?: string;
/** Format: date-time */
@@ -1057,8 +1146,8 @@ export interface components {
clientId?: string | null;
clientName?: string | null;
/** Format: guid */
projectId?: string | null;
projectName?: string | null;
campaignId?: string | null;
campaignName?: string | null;
/** Format: guid */
contentItemId?: string | null;
contentItemTitle?: string | null;
@@ -1098,8 +1187,8 @@ export interface components {
clientId?: string | null;
clientName?: string | null;
/** Format: guid */
projectId?: string | null;
projectName?: string | null;
campaignId?: string | null;
campaignName?: string | null;
/** Format: guid */
contentItemId?: string | null;
contentItemTitle?: string | null;
@@ -1117,7 +1206,7 @@ export interface components {
/** Format: guid */
clientId?: string;
/** Format: guid */
projectId?: string;
campaignId?: string;
title?: string;
publicationMessage?: string;
publicationTargets?: string;
@@ -1135,7 +1224,7 @@ export interface components {
/** Format: guid */
clientId: string;
/** Format: guid */
projectId: string;
campaignId: string;
title: string;
publicationMessage: string;
publicationTargets: string;
@@ -1176,7 +1265,7 @@ export interface components {
/** Format: guid */
clientId?: string;
/** Format: guid */
projectId?: string;
campaignId?: string;
title?: string;
publicationMessage?: string;
publicationTargets?: string;
@@ -1264,6 +1353,36 @@ export interface components {
primaryContactEmail?: string | null;
primaryContactPortraitUrl?: string | null;
};
SocializeApiModulesCampaignsHandlersCampaignDto: {
/** Format: guid */
id?: string;
/** Format: guid */
workspaceId?: string;
/** Format: guid */
clientId?: string;
name?: string;
description?: string | null;
notes?: string | null;
status?: string;
/** Format: date-time */
startDate?: string;
/** Format: date-time */
endDate?: string;
};
SocializeApiModulesCampaignsHandlersCreateCampaignRequest: {
/** Format: guid */
workspaceId: string;
/** Format: guid */
clientId: string;
name: string;
/** Format: date-time */
startDate: string;
/** Format: date-time */
endDate: string;
description?: string | null;
notes?: string | null;
};
SocializeApiModulesCampaignsHandlersGetCampaignsRequest: Record<string, never>;
SocializeApiModulesAssetsHandlersAssetRevisionDto: {
/** Format: guid */
id?: string;
@@ -1322,6 +1441,14 @@ export interface components {
workspaceId?: string;
/** Format: guid */
contentItemId?: string;
/** Format: guid */
workflowInstanceId?: string | null;
/** Format: int32 */
workflowStepSortOrder?: number | null;
workflowStepTargetType?: string | null;
workflowStepTargetValue?: string | null;
/** Format: int32 */
workflowStepRequiredApproverCount?: number | null;
stage?: string;
reviewerName?: string;
reviewerEmail?: string;
@@ -1651,76 +1778,6 @@ export interface operations {
};
};
};
SocializeApiModulesProjectsHandlersGetProjectsHandler: {
parameters: {
query?: {
workspaceId?: string | null;
clientId?: string | null;
};
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Success */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["SocializeApiModulesProjectsHandlersProjectDto"][];
};
};
/** @description Unauthorized */
401: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
SocializeApiModulesProjectsHandlersCreateProjectHandler: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["SocializeApiModulesProjectsHandlersCreateProjectRequest"];
};
};
responses: {
/** @description Success */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["SocializeApiModulesProjectsHandlersProjectDto"];
};
};
/** @description Bad Request */
400: {
headers: {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["FastEndpointsErrorResponse"];
};
};
/** @description Unauthorized */
401: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
SocializeApiModulesNotificationsHandlersGetNotificationsHandler: {
parameters: {
query?: {
@@ -2286,6 +2343,97 @@ export interface operations {
};
};
};
SocializeApiModulesFeedbackHandlersAddDeveloperFeedbackCommentHandler: {
parameters: {
query?: never;
header?: never;
path: {
id: string;
};
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["SocializeApiModulesFeedbackHandlersAddFeedbackCommentRequest"];
};
};
responses: {
/** @description Success */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["SocializeApiModulesFeedbackContractsFeedbackTimelineItemDto"];
};
};
/** @description Bad Request */
400: {
headers: {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["FastEndpointsErrorResponse"];
};
};
/** @description Unauthorized */
401: {
headers: {
[name: string]: unknown;
};
content?: never;
};
/** @description Forbidden */
403: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
SocializeApiModulesFeedbackHandlersAddMyFeedbackCommentHandler: {
parameters: {
query?: never;
header?: never;
path: {
id: string;
};
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["SocializeApiModulesFeedbackHandlersAddFeedbackCommentRequest"];
};
};
responses: {
/** @description Success */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["SocializeApiModulesFeedbackContractsFeedbackTimelineItemDto"];
};
};
/** @description Bad Request */
400: {
headers: {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["FastEndpointsErrorResponse"];
};
};
/** @description Unauthorized */
401: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
SocializeApiModulesFeedbackHandlersAttachMyFeedbackScreenshotHandler: {
parameters: {
query?: never;
@@ -2455,6 +2603,42 @@ export interface operations {
};
};
};
SocializeApiModulesFeedbackHandlersGetDeveloperFeedbackTimelineHandler: {
parameters: {
query?: never;
header?: never;
path: {
id: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Success */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["SocializeApiModulesFeedbackContractsFeedbackTimelineItemDto"][];
};
};
/** @description Unauthorized */
401: {
headers: {
[name: string]: unknown;
};
content?: never;
};
/** @description Forbidden */
403: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
SocializeApiModulesFeedbackHandlersGetFeedbackScreenshotHandler: {
parameters: {
query?: never;
@@ -2513,6 +2697,35 @@ export interface operations {
};
};
};
SocializeApiModulesFeedbackHandlersGetMyFeedbackTimelineHandler: {
parameters: {
query?: never;
header?: never;
path: {
id: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Success */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["SocializeApiModulesFeedbackContractsFeedbackTimelineItemDto"][];
};
};
/** @description Unauthorized */
401: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
SocializeApiModulesFeedbackHandlersListDeveloperFeedbackHandler: {
parameters: {
query?: never;
@@ -2653,7 +2866,7 @@ export interface operations {
query?: {
workspaceId?: string | null;
clientId?: string | null;
projectId?: string | null;
campaignId?: string | null;
};
header?: never;
path?: never;
@@ -3112,6 +3325,76 @@ export interface operations {
};
};
};
SocializeApiModulesCampaignsHandlersGetCampaignsHandler: {
parameters: {
query?: {
workspaceId?: string | null;
clientId?: string | null;
};
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Success */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["SocializeApiModulesCampaignsHandlersCampaignDto"][];
};
};
/** @description Unauthorized */
401: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
SocializeApiModulesCampaignsHandlersCreateCampaignHandler: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["SocializeApiModulesCampaignsHandlersCreateCampaignRequest"];
};
};
responses: {
/** @description Success */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["SocializeApiModulesCampaignsHandlersCampaignDto"];
};
};
/** @description Bad Request */
400: {
headers: {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["FastEndpointsErrorResponse"];
};
};
/** @description Unauthorized */
401: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
SocializeApiModulesAssetsHandlersCreateAssetRevisionHandler: {
parameters: {
query?: never;

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

View File

@@ -44,7 +44,7 @@
case 'campaigns':
return [{
key: 'create-campaign',
label: t('projects.newProject'),
label: t('campaigns.newCampaign'),
icon: mdiPlus,
route: { name: 'campaigns', query: { create: 'true' } },
}];

View File

@@ -9,13 +9,14 @@
import { useNotificationsStore } from '@/features/notifications/stores/notificationsStore.js';
import { getNotificationRoute } from '@/features/notifications/notificationRoutes.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 { useUserProfileStore } from '@/features/user-profile/stores/userProfileStore.js';
import {
mdiBellOutline,
mdiCalendarMonthOutline,
mdiChevronDown,
mdiCogOutline,
mdiFileDocumentOutline,
mdiFolderOutline,
mdiHomeOutline,
mdiImageMultipleOutline,
@@ -40,7 +41,7 @@
const contentItemsStore = useContentItemsStore();
const languageStore = useLanguageStore();
const notificationsStore = useNotificationsStore();
const projectsStore = useProjectsStore();
const campaignsStore = useCampaignsStore();
const userProfileStore = useUserProfileStore();
const isUserMenuOpen = ref(false);
const isNotificationsOpen = ref(false);
@@ -56,7 +57,7 @@
{ to: '/app/workspace', labelKey: 'nav.workspacePlan', icon: mdiCalendarMonthOutline },
{ to: '/app/media-library', labelKey: 'nav.mediaLibrary', icon: mdiImageMultipleOutline },
{ to: '/app/my-feedback', labelKey: 'nav.myFeedback', icon: mdiBugOutline },
{ to: '/app/feedback', labelKey: 'nav.feedbackReview', icon: mdiBugOutline, roles: ['Developer'] },
{ to: '/app/feedback', labelKey: 'nav.feedbackReview', icon: mdiBugOutline, roles: ['developer'] },
{ to: '/app/workspace-settings', labelKey: 'nav.settings', icon: mdiCogOutline },
];
const visiblePrimaryLinks = computed(() =>
@@ -65,23 +66,23 @@
const openSections = ref({
channels: false,
projects: false,
campaigns: false,
});
const normalizedSearchQuery = computed(() => searchQuery.value.trim().toLowerCase());
const projectResults = computed(() => {
const campaignResults = computed(() => {
if (!normalizedSearchQuery.value) {
return [];
}
return projectsStore.projects
.filter(project => project.name.toLowerCase().includes(normalizedSearchQuery.value))
return campaignsStore.campaigns
.filter(campaign => campaign.name.toLowerCase().includes(normalizedSearchQuery.value))
.slice(0, 5)
.map(project => ({
id: project.id,
label: project.name,
.map(campaign => ({
id: campaign.id,
label: campaign.name,
description: 'Campaign',
route: { name: 'campaign-detail', params: { projectId: project.id } },
route: { name: 'campaign-detail', params: { campaignId: campaign.id } },
}));
});
const contentResults = computed(() => {
@@ -104,7 +105,7 @@
}));
});
const hasSearchResults = computed(() =>
projectResults.value.length > 0 || contentResults.value.length > 0
campaignResults.value.length > 0 || contentResults.value.length > 0
);
const isSearchOpen = computed(() => isSearchFocused.value && normalizedSearchQuery.value.length > 0);
@@ -208,7 +209,7 @@
}
if (path.startsWith('/app/campaigns')) {
openSections.value.projects = true;
openSections.value.campaigns = true;
}
},
{ immediate: true }
@@ -269,13 +270,13 @@
class="sidebar-floating-panel"
>
<div
v-if="projectResults.length"
v-if="campaignResults.length"
class="sidebar-search-group"
>
<strong>Campaigns</strong>
<button
v-for="result in projectResults"
:key="`project-${result.id}`"
v-for="result in campaignResults"
:key="`campaign-${result.id}`"
class="sidebar-search-result"
@click="openSearchResult(result)"
>
@@ -401,13 +402,43 @@
</router-link>
</div>
<div class="sidebar-section">
<div class="sidebar-section-header">
<router-link
to="/app/content"
class="sidebar-link sidebar-link-section"
active-class="sidebar-link-active"
:title="!isExpanded ? t('nav.content') : null"
>
<span class="sidebar-link-main">
<v-icon :icon="mdiFileDocumentOutline" />
<span
v-if="isExpanded"
class="sidebar-link-label"
>
{{ t('nav.content') }}
</span>
</span>
</router-link>
<router-link
v-if="isExpanded"
:to="{ name: 'content-item-create' }"
class="sidebar-section-action"
:title="t('contentItems.newItem')"
>
<v-icon :icon="mdiPlus" />
</router-link>
</div>
</div>
<div class="sidebar-section">
<div class="sidebar-section-header">
<router-link
to="/app/campaigns"
class="sidebar-link sidebar-link-section"
active-class="sidebar-link-active"
:title="!isExpanded ? t('nav.projects') : null"
:title="!isExpanded ? t('nav.campaigns') : null"
>
<span class="sidebar-link-main">
<v-icon :icon="mdiFolderOutline" />
@@ -415,7 +446,7 @@
v-if="isExpanded"
class="sidebar-link-label"
>
{{ t('nav.projects') }}
{{ t('nav.campaigns') }}
</span>
</span>
</router-link>
@@ -424,7 +455,7 @@
v-if="isExpanded"
to="/app/campaigns?create=true"
class="sidebar-section-action"
:title="t('projects.createTitle')"
:title="t('campaigns.createTitle')"
>
<v-icon :icon="mdiPlus" />
</router-link>
@@ -433,18 +464,18 @@
v-if="isExpanded"
class="sidebar-section-toggle"
type="button"
@click="toggleSection('projects')"
@click="toggleSection('campaigns')"
>
<v-icon
:icon="mdiChevronDown"
class="sidebar-chevron"
:class="{ 'sidebar-chevron-open': openSections.projects }"
:class="{ 'sidebar-chevron-open': openSections.campaigns }"
/>
</button>
</div>
<div
v-if="isExpanded && openSections.projects"
v-if="isExpanded && openSections.campaigns"
class="sidebar-sublist"
>
<router-link
@@ -452,24 +483,24 @@
class="sidebar-sublink sidebar-sublink-overview"
active-class="sidebar-sublink-active"
>
<span>{{ t('sidebar.allProjects') }}</span>
<span>{{ t('sidebar.allCampaigns') }}</span>
</router-link>
<router-link
v-for="project in projectsStore.projects"
:key="project.id"
:to="{ name: 'campaign-detail', params: { projectId: project.id } }"
v-for="campaign in campaignsStore.campaigns"
:key="campaign.id"
:to="{ name: 'campaign-detail', params: { campaignId: campaign.id } }"
class="sidebar-sublink"
active-class="sidebar-sublink-active"
>
<span>{{ project.name }}</span>
<span>{{ campaign.name }}</span>
</router-link>
<div
v-if="!projectsStore.projects.length"
v-if="!campaignsStore.campaigns.length"
class="sidebar-empty"
>
{{ t('sidebar.noProjects') }}
{{ t('sidebar.noCampaigns') }}
</div>
</div>
</div>

View File

@@ -74,7 +74,7 @@
"myFeedback": "My Feedback",
"feedbackReview": "Feedback Review",
"channels": "Channels",
"projects": "Campaigns",
"campaigns": "Campaigns",
"reviewQueue": "Review Queue",
"content": "Content",
"profile": "Profile",
@@ -210,7 +210,7 @@
"title": "Context",
"workspace": "Workspace",
"client": "Client",
"project": "Campaign",
"campaign": "Campaign",
"contentItem": "Content item"
},
"activity": {
@@ -253,11 +253,11 @@
"sidebar": {
"allClients": "All clients",
"allChannels": "All channels",
"allProjects": "All campaigns",
"allCampaigns": "All campaigns",
"allReviewItems": "Full review queue",
"noClients": "No clients yet.",
"noChannels": "No channels yet.",
"noProjects": "No campaigns yet.",
"noCampaigns": "No campaigns yet.",
"noReviewItems": "No review items right now."
},
"settings": {
@@ -281,12 +281,12 @@
"deliveryRisks": "What can slip",
"overdueItems": "Overdue items",
"approvalBlockers": "Awaiting approval or revisions",
"unscheduledProjects": "Campaigns without scheduled content",
"unscheduledCampaigns": "Campaigns without scheduled content",
"reviewQueueSnapshot": "Review queue snapshot",
"emptyUpcoming": "No upcoming scheduled content.",
"emptyOverdue": "Nothing overdue right now.",
"emptyApproval": "No approval blockers at the moment.",
"emptyProjects": "Every campaign has at least one scheduled content item.",
"emptyCampaigns": "Every campaign has at least one scheduled content item.",
"emptyReviewQueue": "No active review queue items.",
"previousDay": "Previous day",
"nextDay": "Next day",
@@ -295,20 +295,20 @@
"week": "Week",
"campaignDeadline": "Campaign deadline",
"emptyPeriod": "No scheduled items.",
"daySummary": "{content} content items · {projects} campaign deadlines",
"daySummary": "{content} content items · {campaigns} campaign deadlines",
"moreItems": "+{count} more",
"emptyDayAgenda": "No content is scheduled for this day.",
"projectProgress": "{scheduled} scheduled · {approved} approved",
"campaignProgress": "{scheduled} scheduled · {approved} approved",
"missingSchedule": "Needs content scheduled",
"noDueDate": "No due date",
"labels": {
"unassignedProject": "Unassigned campaign"
"unassignedCampaign": "Unassigned campaign"
},
"readiness": {
"building": "In production",
"approval": "Awaiting approval",
"rework": "Needs revision",
"ready": "Ready to publish",
"ready": "Approved",
"published": "Published",
"blocked": "Blocked",
"archived": "Archived",
@@ -339,13 +339,13 @@
"emptyRisks": "No cross-workspace delivery risks right now.",
"emptyActivity": "No recent workflow activity yet.",
"labels": {
"projects": "campaigns",
"campaigns": "campaigns",
"upcoming": "upcoming",
"blocked": "blocked"
},
"stats": {
"workspaces": "Workspaces",
"projects": "Campaigns",
"campaigns": "Campaigns",
"upcoming": "Upcoming items",
"blockers": "At-risk items"
}
@@ -372,11 +372,11 @@
"primaryContactPortraitUrl": "Primary contact portrait URL"
}
},
"projects": {
"campaigns": {
"eyebrow": "Campaign planning",
"title": "Campaigns",
"description": "Campaigns grouped inside the active workspace by status, date range, and planning notes.",
"newProject": "New campaign",
"newCampaign": "New campaign",
"createTitle": "Create campaign",
"loading": "Loading campaigns...",
"empty": "No campaigns are available for the active workspace.",
@@ -444,8 +444,8 @@
"title": "Title",
"client": "Client",
"selectClient": "Select a client",
"project": "Campaign",
"selectProject": "Select a campaign",
"campaign": "Campaign",
"selectCampaign": "Select a campaign",
"dueDate": "Due date",
"publicationTargets": "Publication targets",
"publicationTargetsPlaceholder": "Instagram Reel, TikTok",
@@ -559,35 +559,83 @@
},
"approvals": {
"flowTitle": "Approval flow",
"flowDescription": "Personalize how content moves through internal review, client review, and publishing for this workspace.",
"flowDescription": "Configure how content approval works across this workspace.",
"previewTitle": "Flow preview",
"previewDescription": "This is the sequence the workspace will use based on the current configuration.",
"saved": "Approval flow saved for this workspace in this browser.",
"saved": "Approval flow saved.",
"saveAction": "Save approval flow",
"fields": {
"requireInternalReview": "Require internal review",
"internalApproversRequired": "Internal approvers required",
"requireClientReview": "Require client review",
"clientApproversRequired": "Client approvers required",
"defaultReviewerRole": "Default reviewer role",
"publishBehaviour": "After final approval"
"approvalMode": "Approval mode",
"schedulePostsAutomaticallyOnApproval": "Schedule posts automatically on approval",
"lockContentAfterApproval": "Lock content after approval",
"sendAutomaticApprovalReminders": "Send automatic approval reminders"
},
"fieldHelp": {
"requireInternalReview": "Content must be approved internally before client review can begin.",
"requireClientReview": "Content must still pass through client approval before publication."
"schedulePostsAutomaticallyOnApproval": "Final approval moves content to Scheduled when it already has a planned publish date.",
"lockContentAfterApproval": "Approval-controlled content becomes locked after final approval. Scheduling fields remain editable.",
"sendAutomaticApprovalReminders": "Current approvers receive daily reminders while an approval step is pending."
},
"publishBehaviour": {
"manual": "Mark ready to publish",
"auto": "Auto-advance to ready"
"modes": {
"none": "None",
"optional": "Optional",
"required": "Required",
"multiLevel": "Multi-level"
},
"modeHelp": {
"none": "Content skips approval workflow and can become Approved without approval actions.",
"optional": "A one-step approval workflow is available but does not block publication workflow.",
"required": "At least one approval is required before content can become Approved or Scheduled.",
"multiLevel": "Approval uses ordered steps with targeted approvers for each step."
},
"editor": {
"title": "Multi-level steps",
"description": "Define who approves each ordered step before content reaches final approval.",
"addStep": "Add step",
"empty": "Add at least one approval step before saving multi-level approval.",
"unnamedStep": "Unnamed step",
"moveUp": "Move step up",
"moveDown": "Move step down",
"removeStep": "Remove step",
"selectMember": "Select a member",
"selectMembers": "Select one or more members. Hold Ctrl or Command to select multiple.",
"defaultStepName": "Approval step {number}",
"stepNumber": "Step {number}",
"fields": {
"name": "Display name",
"targetType": "Target type",
"targetValue": "Target",
"requiredApproverCount": "Required approvers"
},
"targetTypes": {
"role": "Role",
"membership": "Membership",
"member": "Member"
},
"memberships": {
"team": "Team",
"client": "Client"
},
"errors": {
"atLeastOneStep": "Multi-level approval requires at least one step.",
"fixInvalidSteps": "Fix the highlighted approval steps before saving.",
"nameRequired": "Enter a step name.",
"targetRequired": "Choose who can approve this step.",
"notEnoughMembers": "Select at least as many members as required approvers.",
"requiredApproverCount": "Enter at least 1 required approver."
}
},
"steps": {
"internal": "Internal review",
"client": "Client review",
"none": "Approval skipped",
"approval": "Approval",
"publish": "Publishing handoff"
},
"stepDetail": {
"none": "No approval workflow is created for content in this workspace.",
"optional": "Approval can be collected, but it is not required before publication workflow.",
"approverCount": "{count} approver(s) required",
"autoPublish": "Content moves to ready automatically after the final approval.",
"manualPublish": "Content stays in a manual ready-to-publish handoff after the final approval."
"multiLevelTarget": "{count} approver(s) from {target}",
"autoSchedule": "Approved content with a planned publish date moves to Scheduled.",
"manualSchedule": "Approved content remains Approved until scheduling is handled."
}
}
},

View File

@@ -74,7 +74,7 @@
"myFeedback": "Mon feedback",
"feedbackReview": "Revue feedback",
"channels": "Canaux",
"projects": "Campagnes",
"campaigns": "Campagnes",
"reviewQueue": "File de révision",
"content": "Contenu",
"profile": "Profil",
@@ -210,7 +210,7 @@
"title": "Contexte",
"workspace": "Espace",
"client": "Client",
"project": "Campagne",
"campaign": "Campagne",
"contentItem": "Élément de contenu"
},
"activity": {
@@ -253,11 +253,11 @@
"sidebar": {
"allClients": "Tous les clients",
"allChannels": "Tous les canaux",
"allProjects": "Toutes les campagnes",
"allCampaigns": "Toutes les campagnes",
"allReviewItems": "File de révision complète",
"noClients": "Aucun client pour le moment.",
"noChannels": "Aucun canal pour le moment.",
"noProjects": "Aucune campagne pour le moment.",
"noCampaigns": "Aucune campagne pour le moment.",
"noReviewItems": "Aucun élément à réviser pour le moment."
},
"settings": {
@@ -281,12 +281,12 @@
"deliveryRisks": "Ce qui peut glisser",
"overdueItems": "Éléments en retard",
"approvalBlockers": "En attente d'approbation ou de révision",
"unscheduledProjects": "Campagnes sans contenu planifié",
"unscheduledCampaigns": "Campagnes sans contenu planifié",
"reviewQueueSnapshot": "Aperçu de la file de révision",
"emptyUpcoming": "Aucun contenu planifié à venir.",
"emptyOverdue": "Rien n'est en retard pour le moment.",
"emptyApproval": "Aucun blocage d'approbation pour le moment.",
"emptyProjects": "Chaque campagne a au moins un élément de contenu planifié.",
"emptyCampaigns": "Chaque campagne a au moins un élément de contenu planifié.",
"emptyReviewQueue": "Aucun élément actif dans la file de révision.",
"previousDay": "Jour précédent",
"nextDay": "Jour suivant",
@@ -295,20 +295,20 @@
"week": "Semaine",
"campaignDeadline": "Échéance de campagne",
"emptyPeriod": "Aucun élément planifié.",
"daySummary": "{content} contenus · {projects} échéances de campagne",
"daySummary": "{content} contenus · {campaigns} échéances de campagne",
"moreItems": "+{count} autres",
"emptyDayAgenda": "Aucun contenu n'est planifié pour cette journée.",
"projectProgress": "{scheduled} planifiés · {approved} approuvés",
"campaignProgress": "{scheduled} planifiés · {approved} approuvés",
"missingSchedule": "Contenu à planifier",
"noDueDate": "Aucune échéance",
"labels": {
"unassignedProject": "Campagne non attribuée"
"unassignedCampaign": "Campagne non attribuée"
},
"readiness": {
"building": "En production",
"approval": "En attente d'approbation",
"rework": "Révision requise",
"ready": "Prêt à publier",
"ready": "Approuvé",
"published": "Publié",
"blocked": "Bloqué",
"archived": "Archivé",
@@ -339,13 +339,13 @@
"emptyRisks": "Aucun risque de livraison multi-espace pour le moment.",
"emptyActivity": "Aucune activité récente du workflow.",
"labels": {
"projects": "campagnes",
"campaigns": "campagnes",
"upcoming": "à venir",
"blocked": "bloqués"
},
"stats": {
"workspaces": "Espaces",
"projects": "Campagnes",
"campaigns": "Campagnes",
"upcoming": "Éléments à venir",
"blockers": "Éléments à risque"
}
@@ -372,11 +372,11 @@
"primaryContactPortraitUrl": "URL du portrait du contact principal"
}
},
"projects": {
"campaigns": {
"eyebrow": "Planification des campagnes",
"title": "Campagnes",
"description": "Campagnes regroupées dans l'espace actif par statut, plage de dates et notes de planification.",
"newProject": "Nouvelle campagne",
"newCampaign": "Nouvelle campagne",
"createTitle": "Créer une campagne",
"loading": "Chargement des campagnes...",
"empty": "Aucune campagne n'est disponible pour l'espace actif.",
@@ -444,8 +444,8 @@
"title": "Titre",
"client": "Client",
"selectClient": "Sélectionner un client",
"project": "Campagne",
"selectProject": "Sélectionner une campagne",
"campaign": "Campagne",
"selectCampaign": "Sélectionner une campagne",
"dueDate": "Date d'échéance",
"publicationTargets": "Cibles de publication",
"publicationTargetsPlaceholder": "Instagram Reel, TikTok",
@@ -559,35 +559,83 @@
},
"approvals": {
"flowTitle": "Flux d'approbation",
"flowDescription": "Personnalisez le passage du contenu par la révision interne, la révision client et la mise en publication pour cet espace.",
"flowDescription": "Configurez le fonctionnement de l'approbation du contenu dans cet espace.",
"previewTitle": "Aperçu du flux",
"previewDescription": "Voici la séquence que l'espace utilisera selon la configuration actuelle.",
"saved": "Le flux d'approbation a été enregistré pour cet espace dans ce navigateur.",
"saved": "Flux d'approbation enregistré.",
"saveAction": "Enregistrer le flux",
"fields": {
"requireInternalReview": "Exiger une révision interne",
"internalApproversRequired": "Approbateurs internes requis",
"requireClientReview": "Exiger une révision client",
"clientApproversRequired": "Approbateurs client requis",
"defaultReviewerRole": "Rôle du réviseur par défaut",
"publishBehaviour": "Après l'approbation finale"
"approvalMode": "Mode d'approbation",
"schedulePostsAutomaticallyOnApproval": "Planifier automatiquement après approbation",
"lockContentAfterApproval": "Verrouiller le contenu après approbation",
"sendAutomaticApprovalReminders": "Envoyer des rappels automatiques"
},
"fieldHelp": {
"requireInternalReview": "Le contenu doit être approuvé en interne avant de passer à la révision client.",
"requireClientReview": "Le contenu doit encore passer par une approbation client avant la publication."
"schedulePostsAutomaticallyOnApproval": "L'approbation finale passe le contenu à Planifié quand une date de publication est déjà prévue.",
"lockContentAfterApproval": "Le contenu contrôlé par l'approbation est verrouillé après l'approbation finale. Les champs de planification restent modifiables.",
"sendAutomaticApprovalReminders": "Les approbateurs courants reçoivent des rappels quotidiens tant qu'une étape est en attente."
},
"publishBehaviour": {
"manual": "Marquer prêt à publier",
"auto": "Passer automatiquement à prêt"
"modes": {
"none": "Aucun",
"optional": "Optionnel",
"required": "Requis",
"multiLevel": "Multi-niveaux"
},
"modeHelp": {
"none": "Le contenu saute le workflow d'approbation et peut devenir Approuvé sans action d'approbation.",
"optional": "Un workflow d'approbation en une étape est disponible, mais il ne bloque pas la publication.",
"required": "Au moins une approbation est requise avant que le contenu devienne Approuvé ou Planifié.",
"multiLevel": "L'approbation utilise des étapes ordonnées avec des approbateurs ciblés pour chaque étape."
},
"editor": {
"title": "Étapes multi-niveaux",
"description": "Définissez qui approuve chaque étape ordonnée avant l'approbation finale du contenu.",
"addStep": "Ajouter une étape",
"empty": "Ajoutez au moins une étape d'approbation avant d'enregistrer le mode multi-niveaux.",
"unnamedStep": "Étape sans nom",
"moveUp": "Monter l'étape",
"moveDown": "Descendre l'étape",
"removeStep": "Supprimer l'étape",
"selectMember": "Sélectionner un membre",
"selectMembers": "Sélectionnez un ou plusieurs membres. Maintenez Ctrl ou Commande pour une sélection multiple.",
"defaultStepName": "Étape d'approbation {number}",
"stepNumber": "Étape {number}",
"fields": {
"name": "Nom affiché",
"targetType": "Type de cible",
"targetValue": "Cible",
"requiredApproverCount": "Approbateurs requis"
},
"targetTypes": {
"role": "Rôle",
"membership": "Appartenance",
"member": "Membre"
},
"memberships": {
"team": "Équipe",
"client": "Client"
},
"errors": {
"atLeastOneStep": "L'approbation multi-niveaux requiert au moins une étape.",
"fixInvalidSteps": "Corrigez les étapes d'approbation indiquées avant d'enregistrer.",
"nameRequired": "Saisissez un nom d'étape.",
"targetRequired": "Choisissez qui peut approuver cette étape.",
"notEnoughMembers": "Sélectionnez au moins autant de membres que d'approbateurs requis.",
"requiredApproverCount": "Saisissez au moins 1 approbateur requis."
}
},
"steps": {
"internal": "Révision interne",
"client": "Révision client",
"none": "Approbation ignorée",
"approval": "Approbation",
"publish": "Passage à la publication"
},
"stepDetail": {
"none": "Aucun workflow d'approbation n'est créé pour le contenu de cet espace.",
"optional": "L'approbation peut être recueillie, mais elle n'est pas requise avant la publication.",
"approverCount": "{count} approbateur(s) requis",
"autoPublish": "Le contenu passe automatiquement à prêt après l'approbation finale.",
"manualPublish": "Le contenu reste dans une étape manuelle prêt à publier après l'approbation finale."
"multiLevelTarget": "{count} approbateur(s) de {target}",
"autoSchedule": "Le contenu approuvé avec une date de publication prévue passe à Planifié.",
"manualSchedule": "Le contenu approuvé reste Approuvé jusqu'à sa planification."
}
}
},

View File

@@ -29,7 +29,7 @@ import { useWorkspaceStore } from '@/features/workspaces/stores/workspaceStore.j
import { useReviewQueueStore } from '@/features/reviews/stores/reviewQueueStore.js';
import { useContentItemsStore } from '@/features/content/stores/contentItemsStore.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 { useNotificationsStore } from '@/features/notifications/stores/notificationsStore.js';
import { useChannelsStore } from '@/features/channels/stores/channelsStore.js';
import { i18n } from '@/plugins/i18n.js';
@@ -95,7 +95,7 @@ useAuthStore();
useUserProfileStore();
useWorkspaceStore();
useClientsStore();
useProjectsStore();
useCampaignsStore();
useChannelsStore();
useReviewQueueStore();
useContentItemsStore();

View File

@@ -10,8 +10,8 @@ const VerifyEmailView = () => import('@/features/auth/views/VerifyEmailView.vue'
const OverviewView = () => import('@/features/workspaces/views/OverviewView.vue');
const DashboardView = () => import('@/features/workspaces/views/DashboardView.vue');
const ChannelsView = () => import('@/features/channels/views/ChannelsView.vue');
const CampaignsView = () => import('@/features/projects/views/ProjectsView.vue');
const CampaignDetailView = () => import('@/features/projects/views/ProjectDetailView.vue');
const CampaignsView = () => import('@/features/campaigns/views/CampaignsView.vue');
const CampaignDetailView = () => import('@/features/campaigns/views/CampaignDetailView.vue');
const MediaLibraryView = () => import('@/features/content/views/MediaLibraryView.vue');
const WorkspaceCreateView = () => import('@/features/workspaces/views/WorkspaceCreateView.vue');
const SettingsLayoutView = () => import('@/features/settings/views/SettingsLayoutView.vue');
@@ -67,7 +67,7 @@ const routes = [
meta: { requiresAuth: true },
},
{
path: '/app/campaigns/:projectId',
path: '/app/campaigns/:campaignId',
name: 'campaign-detail',
component: CampaignDetailView,
meta: { requiresAuth: true },