chore: add missing multi-level editor for approval workflow, rename projects to campaings.
This commit is contained in:
529
frontend/src/api/schema.d.ts
vendored
529
frontend/src/api/schema.d.ts
vendored
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
});
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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 ?? []),
|
||||
]
|
||||
|
||||
@@ -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],
|
||||
];
|
||||
});
|
||||
|
||||
@@ -49,7 +49,7 @@
|
||||
return [
|
||||
report.context?.workspaceName,
|
||||
report.context?.clientName,
|
||||
report.context?.projectName,
|
||||
report.context?.campaignName,
|
||||
report.context?.contentItemTitle,
|
||||
]
|
||||
.filter(Boolean)
|
||||
|
||||
@@ -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 },
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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' } },
|
||||
}];
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 },
|
||||
|
||||
Reference in New Issue
Block a user