Add calendar integrations and collaboration updates
This commit is contained in:
343
frontend/src/api/schema.d.ts
vendored
343
frontend/src/api/schema.d.ts
vendored
@@ -100,6 +100,22 @@ export interface paths {
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/organizations/{organizationId}/members": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
put?: never;
|
||||
post: operations["SocializeApiModulesOrganizationsHandlersAddOrganizationMemberHandler"];
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/organizations/{organizationId}": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@@ -108,7 +124,7 @@ export interface paths {
|
||||
cookie?: never;
|
||||
};
|
||||
get: operations["SocializeApiModulesOrganizationsHandlersGetOrganizationHandler"];
|
||||
put?: never;
|
||||
put: operations["SocializeApiModulesOrganizationsHandlersUpdateOrganizationHandler"];
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
@@ -820,6 +836,38 @@ export interface paths {
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/calendar-integrations/sources": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get: operations["SocializeApiModulesCalendarIntegrationsHandlersListCalendarSourcesHandler"];
|
||||
put?: never;
|
||||
post: operations["SocializeApiModulesCalendarIntegrationsHandlersCreateCalendarSourceHandler"];
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/calendar-integrations/sources/{sourceId}": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
put: operations["SocializeApiModulesCalendarIntegrationsHandlersUpdateCalendarSourceHandler"];
|
||||
post?: never;
|
||||
delete: operations["SocializeApiModulesCalendarIntegrationsHandlersDeleteCalendarSourceHandler"];
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/assets/{id}/revisions": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@@ -1003,6 +1051,22 @@ export interface components {
|
||||
/** Format: int32 */
|
||||
requiredApproverCount?: number;
|
||||
};
|
||||
SocializeApiModulesOrganizationsHandlersOrganizationMemberDto: {
|
||||
/** Format: guid */
|
||||
userId?: string;
|
||||
displayName?: string;
|
||||
email?: string;
|
||||
portraitUrl?: string | null;
|
||||
role?: string;
|
||||
permissions?: string[];
|
||||
/** Format: date-time */
|
||||
createdAt?: string;
|
||||
};
|
||||
SocializeApiModulesOrganizationsHandlersAddOrganizationMemberRequest: {
|
||||
/** Format: email */
|
||||
email: string;
|
||||
role: string;
|
||||
};
|
||||
SocializeApiModulesOrganizationsHandlersOrganizationDto: {
|
||||
/** Format: guid */
|
||||
id?: string;
|
||||
@@ -1015,16 +1079,8 @@ export interface components {
|
||||
/** Format: date-time */
|
||||
createdAt?: string;
|
||||
};
|
||||
SocializeApiModulesOrganizationsHandlersOrganizationMemberDto: {
|
||||
/** Format: guid */
|
||||
userId?: string;
|
||||
displayName?: string;
|
||||
email?: string;
|
||||
portraitUrl?: string | null;
|
||||
role?: string;
|
||||
permissions?: string[];
|
||||
/** Format: date-time */
|
||||
createdAt?: string;
|
||||
SocializeApiModulesOrganizationsHandlersUpdateOrganizationRequest: {
|
||||
name: string;
|
||||
};
|
||||
SocializeApiModulesNotificationsHandlersNotificationEventDto: {
|
||||
/** Format: guid */
|
||||
@@ -1478,6 +1534,49 @@ export interface components {
|
||||
notes?: string | null;
|
||||
};
|
||||
SocializeApiModulesCampaignsHandlersGetCampaignsRequest: Record<string, never>;
|
||||
SocializeApiModulesCalendarIntegrationsHandlersCalendarSourceDto: {
|
||||
/** Format: guid */
|
||||
id?: string;
|
||||
scope?: string;
|
||||
/** Format: guid */
|
||||
organizationId?: string | null;
|
||||
/** Format: guid */
|
||||
workspaceId?: string | null;
|
||||
/** Format: guid */
|
||||
userId?: string | null;
|
||||
sourceUrl?: string | null;
|
||||
catalogSourceReference?: string | null;
|
||||
displayTitle?: string;
|
||||
color?: string;
|
||||
category?: string;
|
||||
isEnabled?: boolean;
|
||||
inheritanceMode?: string | null;
|
||||
isReadOnly?: boolean;
|
||||
/** Format: date-time */
|
||||
lastSuccessfulSyncAt?: string | null;
|
||||
/** Format: date-time */
|
||||
lastAttemptedSyncAt?: string | null;
|
||||
lastSyncError?: string | null;
|
||||
/** Format: date-time */
|
||||
createdAt?: string;
|
||||
/** Format: date-time */
|
||||
updatedAt?: string;
|
||||
};
|
||||
SocializeApiModulesCalendarIntegrationsHandlersUpsertCalendarSourceRequest: {
|
||||
scope: string;
|
||||
/** Format: guid */
|
||||
organizationId?: string | null;
|
||||
/** Format: guid */
|
||||
workspaceId?: string | null;
|
||||
sourceUrl?: string | null;
|
||||
catalogSourceReference?: string | null;
|
||||
displayTitle: string;
|
||||
color: string;
|
||||
category: string;
|
||||
isEnabled?: boolean;
|
||||
inheritanceMode?: string | null;
|
||||
};
|
||||
SocializeApiModulesCalendarIntegrationsHandlersListCalendarSourcesRequest: Record<string, never>;
|
||||
SocializeApiModulesAssetsHandlersAssetRevisionDto: {
|
||||
/** Format: guid */
|
||||
id?: string;
|
||||
@@ -1860,6 +1959,48 @@ export interface operations {
|
||||
};
|
||||
};
|
||||
};
|
||||
SocializeApiModulesOrganizationsHandlersAddOrganizationMemberHandler: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
organizationId: string;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody: {
|
||||
content: {
|
||||
"application/json": components["schemas"]["SocializeApiModulesOrganizationsHandlersAddOrganizationMemberRequest"];
|
||||
};
|
||||
};
|
||||
responses: {
|
||||
/** @description Success */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["SocializeApiModulesOrganizationsHandlersOrganizationMemberDto"];
|
||||
};
|
||||
};
|
||||
/** @description Bad Request */
|
||||
400: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/problem+json": components["schemas"]["FastEndpointsErrorResponse"];
|
||||
};
|
||||
};
|
||||
/** @description Unauthorized */
|
||||
401: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content?: never;
|
||||
};
|
||||
};
|
||||
};
|
||||
SocializeApiModulesOrganizationsHandlersGetOrganizationHandler: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@@ -1889,6 +2030,48 @@ export interface operations {
|
||||
};
|
||||
};
|
||||
};
|
||||
SocializeApiModulesOrganizationsHandlersUpdateOrganizationHandler: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
organizationId: string;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody: {
|
||||
content: {
|
||||
"application/json": components["schemas"]["SocializeApiModulesOrganizationsHandlersUpdateOrganizationRequest"];
|
||||
};
|
||||
};
|
||||
responses: {
|
||||
/** @description Success */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["SocializeApiModulesOrganizationsHandlersOrganizationDto"];
|
||||
};
|
||||
};
|
||||
/** @description Bad Request */
|
||||
400: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/problem+json": components["schemas"]["FastEndpointsErrorResponse"];
|
||||
};
|
||||
};
|
||||
/** @description Unauthorized */
|
||||
401: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content?: never;
|
||||
};
|
||||
};
|
||||
};
|
||||
SocializeApiModulesOrganizationsHandlersGetOrganizationsHandler: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@@ -3602,6 +3785,144 @@ export interface operations {
|
||||
};
|
||||
};
|
||||
};
|
||||
SocializeApiModulesCalendarIntegrationsHandlersListCalendarSourcesHandler: {
|
||||
parameters: {
|
||||
query?: {
|
||||
workspaceId?: string | null;
|
||||
};
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description Success */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["SocializeApiModulesCalendarIntegrationsHandlersCalendarSourceDto"][];
|
||||
};
|
||||
};
|
||||
/** @description Unauthorized */
|
||||
401: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content?: never;
|
||||
};
|
||||
};
|
||||
};
|
||||
SocializeApiModulesCalendarIntegrationsHandlersCreateCalendarSourceHandler: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody: {
|
||||
content: {
|
||||
"application/json": components["schemas"]["SocializeApiModulesCalendarIntegrationsHandlersUpsertCalendarSourceRequest"];
|
||||
};
|
||||
};
|
||||
responses: {
|
||||
/** @description Success */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["SocializeApiModulesCalendarIntegrationsHandlersCalendarSourceDto"];
|
||||
};
|
||||
};
|
||||
/** @description Bad Request */
|
||||
400: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/problem+json": components["schemas"]["FastEndpointsErrorResponse"];
|
||||
};
|
||||
};
|
||||
/** @description Unauthorized */
|
||||
401: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content?: never;
|
||||
};
|
||||
};
|
||||
};
|
||||
SocializeApiModulesCalendarIntegrationsHandlersUpdateCalendarSourceHandler: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
sourceId: string;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody: {
|
||||
content: {
|
||||
"application/json": components["schemas"]["SocializeApiModulesCalendarIntegrationsHandlersUpsertCalendarSourceRequest"];
|
||||
};
|
||||
};
|
||||
responses: {
|
||||
/** @description Success */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["SocializeApiModulesCalendarIntegrationsHandlersCalendarSourceDto"];
|
||||
};
|
||||
};
|
||||
/** @description Bad Request */
|
||||
400: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/problem+json": components["schemas"]["FastEndpointsErrorResponse"];
|
||||
};
|
||||
};
|
||||
/** @description Unauthorized */
|
||||
401: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content?: never;
|
||||
};
|
||||
};
|
||||
};
|
||||
SocializeApiModulesCalendarIntegrationsHandlersDeleteCalendarSourceHandler: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
sourceId: string;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description No Content */
|
||||
204: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content?: never;
|
||||
};
|
||||
/** @description Unauthorized */
|
||||
401: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content?: never;
|
||||
};
|
||||
};
|
||||
};
|
||||
SocializeApiModulesAssetsHandlersCreateAssetRevisionHandler: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
|
||||
@@ -0,0 +1,182 @@
|
||||
import { computed, ref } from 'vue';
|
||||
import { defineStore } from 'pinia';
|
||||
import { useAuthStore } from '@/features/auth/stores/authStore.js';
|
||||
import { useWorkspaceStore } from '@/features/workspaces/stores/workspaceStore.js';
|
||||
import { useClient } from '@/plugins/api.js';
|
||||
|
||||
export const useCalendarIntegrationsStore = defineStore('calendar-integrations', () => {
|
||||
const authStore = useAuthStore();
|
||||
const workspaceStore = useWorkspaceStore();
|
||||
const client = useClient();
|
||||
|
||||
const sources = ref([]);
|
||||
const events = ref([]);
|
||||
const catalogEntries = ref([]);
|
||||
const hiddenSourceIds = ref(new Set());
|
||||
const isLoadingSources = ref(false);
|
||||
const isLoadingEvents = ref(false);
|
||||
const isLoadingCatalog = ref(false);
|
||||
const isCreatingSource = ref(false);
|
||||
const error = ref(null);
|
||||
|
||||
const visibleSourceIds = computed(() =>
|
||||
new Set(sources.value
|
||||
.filter(source => source.isEnabled && !hiddenSourceIds.value.has(source.id))
|
||||
.map(source => source.id))
|
||||
);
|
||||
|
||||
const visibleEvents = computed(() =>
|
||||
events.value.filter(event => visibleSourceIds.value.has(event.calendarSourceId))
|
||||
);
|
||||
|
||||
function sourceById(sourceId) {
|
||||
return sources.value.find(source => source.id === sourceId) ?? null;
|
||||
}
|
||||
|
||||
async function fetchSources(workspaceId = workspaceStore.activeWorkspaceId) {
|
||||
if (!authStore.isAuthenticated) {
|
||||
sources.value = [];
|
||||
error.value = null;
|
||||
return;
|
||||
}
|
||||
|
||||
isLoadingSources.value = true;
|
||||
error.value = null;
|
||||
|
||||
try {
|
||||
const response = await client.get('/api/calendar-integrations/sources', {
|
||||
params: {
|
||||
workspaceId: workspaceId ?? undefined,
|
||||
},
|
||||
});
|
||||
sources.value = response.data ?? [];
|
||||
} catch (fetchError) {
|
||||
console.error('Failed to fetch calendar sources:', fetchError);
|
||||
sources.value = [];
|
||||
error.value = 'Failed to load calendar sources.';
|
||||
} finally {
|
||||
isLoadingSources.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchEvents({ workspaceId = workspaceStore.activeWorkspaceId, startDate, endDate } = {}) {
|
||||
if (!authStore.isAuthenticated) {
|
||||
events.value = [];
|
||||
error.value = null;
|
||||
return;
|
||||
}
|
||||
|
||||
isLoadingEvents.value = true;
|
||||
|
||||
try {
|
||||
const response = await client.get('/api/calendar-integrations/events', {
|
||||
params: {
|
||||
workspaceId: workspaceId ?? undefined,
|
||||
startDate,
|
||||
endDate,
|
||||
},
|
||||
});
|
||||
events.value = response.data ?? [];
|
||||
} catch (fetchError) {
|
||||
console.error('Failed to fetch calendar events:', fetchError);
|
||||
events.value = [];
|
||||
error.value = 'Failed to load calendar events.';
|
||||
} finally {
|
||||
isLoadingEvents.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function searchCatalog(filters = {}) {
|
||||
if (!authStore.isAuthenticated) {
|
||||
catalogEntries.value = [];
|
||||
return;
|
||||
}
|
||||
|
||||
isLoadingCatalog.value = true;
|
||||
|
||||
try {
|
||||
const response = await client.get('/api/calendar-integrations/catalog', {
|
||||
params: filters,
|
||||
});
|
||||
catalogEntries.value = response.data ?? [];
|
||||
} catch (fetchError) {
|
||||
console.error('Failed to search calendar catalog:', fetchError);
|
||||
catalogEntries.value = [];
|
||||
error.value = 'Failed to load calendar catalog.';
|
||||
} finally {
|
||||
isLoadingCatalog.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function createSource(payload) {
|
||||
isCreatingSource.value = true;
|
||||
error.value = null;
|
||||
|
||||
try {
|
||||
const response = await client.post('/api/calendar-integrations/sources', payload);
|
||||
if (response.data) {
|
||||
sources.value = [...sources.value, response.data]
|
||||
.sort((left, right) => left.displayTitle.localeCompare(right.displayTitle));
|
||||
}
|
||||
return response.data;
|
||||
} catch (createError) {
|
||||
console.error('Failed to create calendar source:', createError);
|
||||
error.value = 'Failed to add calendar source.';
|
||||
throw createError;
|
||||
} finally {
|
||||
isCreatingSource.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshSource(sourceId) {
|
||||
if (!sourceId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await client.post(`/api/calendar-integrations/sources/${sourceId}/refresh`);
|
||||
const refreshedSource = response.data;
|
||||
if (refreshedSource) {
|
||||
sources.value = sources.value.map(source =>
|
||||
source.id === refreshedSource.id ? refreshedSource : source
|
||||
);
|
||||
}
|
||||
return refreshedSource;
|
||||
} catch (refreshError) {
|
||||
console.error('Failed to refresh calendar source:', refreshError);
|
||||
error.value = 'Failed to refresh calendar source.';
|
||||
throw refreshError;
|
||||
}
|
||||
}
|
||||
|
||||
function toggleSourceVisibility(sourceId) {
|
||||
const nextHiddenIds = new Set(hiddenSourceIds.value);
|
||||
if (nextHiddenIds.has(sourceId)) {
|
||||
nextHiddenIds.delete(sourceId);
|
||||
} else {
|
||||
nextHiddenIds.add(sourceId);
|
||||
}
|
||||
hiddenSourceIds.value = nextHiddenIds;
|
||||
}
|
||||
|
||||
return {
|
||||
sources,
|
||||
events,
|
||||
catalogEntries,
|
||||
hiddenSourceIds,
|
||||
visibleSourceIds,
|
||||
visibleEvents,
|
||||
isLoadingSources,
|
||||
isLoadingEvents,
|
||||
isLoadingCatalog,
|
||||
isCreatingSource,
|
||||
error,
|
||||
sourceById,
|
||||
fetchSources,
|
||||
fetchEvents,
|
||||
searchCatalog,
|
||||
createSource,
|
||||
refreshSource,
|
||||
toggleSourceVisibility,
|
||||
};
|
||||
});
|
||||
@@ -13,6 +13,7 @@ export const useContentItemDetailStore = defineStore('content-item-detail', () =
|
||||
const comments = ref([]);
|
||||
const approvals = ref([]);
|
||||
const notifications = ref([]);
|
||||
const activity = ref([]);
|
||||
const isLoading = ref(false);
|
||||
const error = ref(null);
|
||||
const actions = reactive({
|
||||
@@ -35,6 +36,7 @@ export const useContentItemDetailStore = defineStore('content-item-detail', () =
|
||||
comments.value = [];
|
||||
approvals.value = [];
|
||||
notifications.value = [];
|
||||
activity.value = [];
|
||||
error.value = null;
|
||||
}
|
||||
|
||||
@@ -49,19 +51,14 @@ export const useContentItemDetailStore = defineStore('content-item-detail', () =
|
||||
assetsResponse,
|
||||
commentsResponse,
|
||||
approvalsResponse,
|
||||
notificationsResponse,
|
||||
activityResponse,
|
||||
] = await Promise.all([
|
||||
client.get(`/api/content-items/${contentItemId}`),
|
||||
client.get(`/api/content-items/${contentItemId}/revisions`),
|
||||
client.get('/api/assets', { params: { contentItemId } }),
|
||||
client.get('/api/comments', { params: { contentItemId } }),
|
||||
client.get('/api/approvals', { params: { contentItemId } }),
|
||||
client.get('/api/notifications', {
|
||||
params: {
|
||||
workspaceId: workspaceStore.activeWorkspaceId ?? undefined,
|
||||
contentItemId,
|
||||
},
|
||||
}),
|
||||
client.get(`/api/content-items/${contentItemId}/activity`),
|
||||
]);
|
||||
|
||||
item.value = itemResponse.data;
|
||||
@@ -69,7 +66,7 @@ export const useContentItemDetailStore = defineStore('content-item-detail', () =
|
||||
assets.value = assetsResponse.data ?? [];
|
||||
comments.value = commentsResponse.data ?? [];
|
||||
approvals.value = approvalsResponse.data ?? [];
|
||||
notifications.value = notificationsResponse.data ?? [];
|
||||
activity.value = activityResponse.data ?? [];
|
||||
} catch (fetchError) {
|
||||
console.error('Failed to load content item detail:', fetchError);
|
||||
reset();
|
||||
@@ -105,7 +102,7 @@ export const useContentItemDetailStore = defineStore('content-item-detail', () =
|
||||
});
|
||||
if (response.data) {
|
||||
assets.value = [...assets.value, response.data];
|
||||
await fetchNotifications(contentItemId);
|
||||
await fetchActivity(contentItemId);
|
||||
}
|
||||
return response.data;
|
||||
} finally {
|
||||
@@ -120,7 +117,7 @@ export const useContentItemDetailStore = defineStore('content-item-detail', () =
|
||||
const response = await client.post(`/api/assets/${assetId}/revisions`, payload);
|
||||
if (response.data) {
|
||||
await fetchAssets(contentItemId);
|
||||
await fetchNotifications(contentItemId);
|
||||
await fetchActivity(contentItemId);
|
||||
}
|
||||
return response.data;
|
||||
} finally {
|
||||
@@ -139,22 +136,7 @@ export const useContentItemDetailStore = defineStore('content-item-detail', () =
|
||||
});
|
||||
if (response.data) {
|
||||
comments.value = [...comments.value, response.data];
|
||||
await fetchNotifications(contentItemId);
|
||||
}
|
||||
return response.data;
|
||||
} finally {
|
||||
actions.comment = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function resolveComment(contentItemId, commentId) {
|
||||
actions.comment = true;
|
||||
|
||||
try {
|
||||
const response = await client.post(`/api/comments/${commentId}/resolve`);
|
||||
if (response.data) {
|
||||
comments.value = comments.value.map(comment => comment.id === commentId ? response.data : comment);
|
||||
await fetchNotifications(contentItemId);
|
||||
await fetchActivity(contentItemId);
|
||||
}
|
||||
return response.data;
|
||||
} finally {
|
||||
@@ -170,7 +152,7 @@ export const useContentItemDetailStore = defineStore('content-item-detail', () =
|
||||
if (response.data) {
|
||||
approvals.value = approvals.value.map(approval => approval.id === approvalId ? response.data : approval);
|
||||
await fetchContentItem(contentItemId);
|
||||
await fetchNotifications(contentItemId);
|
||||
await fetchActivity(contentItemId);
|
||||
}
|
||||
return response.data;
|
||||
} finally {
|
||||
@@ -184,7 +166,7 @@ export const useContentItemDetailStore = defineStore('content-item-detail', () =
|
||||
try {
|
||||
const response = await client.post(`/api/content-items/${contentItemId}/status`, { status });
|
||||
item.value = response.data;
|
||||
await fetchNotifications(contentItemId);
|
||||
await fetchActivity(contentItemId);
|
||||
return response.data;
|
||||
} finally {
|
||||
actions.status = false;
|
||||
@@ -214,6 +196,12 @@ export const useContentItemDetailStore = defineStore('content-item-detail', () =
|
||||
return notifications.value;
|
||||
}
|
||||
|
||||
async function fetchActivity(contentItemId) {
|
||||
const response = await client.get(`/api/content-items/${contentItemId}/activity`);
|
||||
activity.value = response.data ?? [];
|
||||
return activity.value;
|
||||
}
|
||||
|
||||
return {
|
||||
item,
|
||||
revisions,
|
||||
@@ -221,6 +209,7 @@ export const useContentItemDetailStore = defineStore('content-item-detail', () =
|
||||
comments,
|
||||
approvals,
|
||||
notifications,
|
||||
activity,
|
||||
isLoading,
|
||||
error,
|
||||
actions,
|
||||
@@ -230,8 +219,8 @@ export const useContentItemDetailStore = defineStore('content-item-detail', () =
|
||||
addGoogleDriveAsset,
|
||||
addAssetRevision,
|
||||
addComment,
|
||||
resolveComment,
|
||||
submitDecision,
|
||||
updateStatus,
|
||||
fetchActivity,
|
||||
};
|
||||
});
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,20 +1,44 @@
|
||||
<script setup>
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import { computed, reactive, ref, watch } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import { mdiChevronLeft, mdiChevronRight } from '@mdi/js';
|
||||
import { mdiCalendarPlus, mdiChevronDown, mdiChevronLeft, mdiChevronRight, mdiClose, mdiMagnify, mdiPlus } from '@mdi/js';
|
||||
import { useCampaignsStore } from '@/features/campaigns/stores/campaignsStore.js';
|
||||
import { organizationPermissions, useOrganizationStore } from '@/features/organizations/stores/organizationStore.js';
|
||||
import { useAuthStore } from '@/features/auth/stores/authStore.js';
|
||||
import { useWorkspaceStore } from '@/features/workspaces/stores/workspaceStore.js';
|
||||
import { useCalendarIntegrationsStore } from '@/features/content/stores/calendarIntegrationsStore.js';
|
||||
import { useContentItemsStore } from '@/features/content/stores/contentItemsStore.js';
|
||||
|
||||
const { t, locale } = useI18n();
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const authStore = useAuthStore();
|
||||
const workspaceStore = useWorkspaceStore();
|
||||
const organizationStore = useOrganizationStore();
|
||||
const campaignsStore = useCampaignsStore();
|
||||
const contentItemsStore = useContentItemsStore();
|
||||
const calendarStore = useCalendarIntegrationsStore();
|
||||
|
||||
const today = startOfDay(new Date());
|
||||
const viewMode = ref(parseViewMode(route.query.view));
|
||||
const cursorDate = ref(parseCursorDate(route.query.date, today));
|
||||
const isAddCalendarOpen = ref(route.query.addCalendar === 'true');
|
||||
const isCalendarSelectorOpen = ref(false);
|
||||
const activeAddMode = ref('catalog');
|
||||
const catalogFilters = reactive({
|
||||
search: '',
|
||||
country: '',
|
||||
category: '',
|
||||
});
|
||||
const customCalendarForm = reactive({
|
||||
title: '',
|
||||
sourceUrl: '',
|
||||
color: '#2F80ED',
|
||||
category: 'public-holiday',
|
||||
scope: 'User',
|
||||
});
|
||||
const addCalendarError = ref('');
|
||||
|
||||
const contentStatusMeta = {
|
||||
Draft: { tone: 'production' },
|
||||
@@ -46,7 +70,10 @@
|
||||
.filter(item => item.dueDate)
|
||||
.map(item => buildContentEntry(item));
|
||||
|
||||
return [...campaignEntries, ...contentEntries].sort(sortByDate);
|
||||
const importedEntries = calendarStore.visibleEvents
|
||||
.map(event => buildImportedCalendarEntry(event));
|
||||
|
||||
return [...campaignEntries, ...contentEntries, ...importedEntries].sort(sortByDate);
|
||||
});
|
||||
|
||||
const entriesByDay = computed(() => {
|
||||
@@ -133,6 +160,12 @@
|
||||
})
|
||||
);
|
||||
|
||||
const upcomingEntries = computed(() =>
|
||||
calendarEntries.value
|
||||
.filter(entry => entry.scheduledAt >= today)
|
||||
.slice(0, 80)
|
||||
);
|
||||
|
||||
const isLoading = computed(() =>
|
||||
contentItemsStore.isLoading || campaignsStore.isLoading
|
||||
);
|
||||
@@ -142,6 +175,44 @@
|
||||
);
|
||||
|
||||
const isCalendarView = computed(() => viewMode.value === 'month' || viewMode.value === 'week');
|
||||
const calendarWorkspaceId = computed(() =>
|
||||
workspaceStore.activeWorkspaceId ?? workspaceStore.visibleWorkspaceIds[0] ?? null
|
||||
);
|
||||
const calendarRange = computed(() => {
|
||||
if (viewMode.value === 'week') {
|
||||
const start = startOfWeek(cursorDate.value);
|
||||
return {
|
||||
startDate: dateKey(start),
|
||||
endDate: dateKey(addDays(start, 6)),
|
||||
};
|
||||
}
|
||||
|
||||
const start = startOfWeek(startOfMonth(cursorDate.value));
|
||||
const end = endOfWeek(endOfMonth(cursorDate.value));
|
||||
return {
|
||||
startDate: dateKey(start),
|
||||
endDate: dateKey(end),
|
||||
};
|
||||
});
|
||||
const canManageOrganizationCalendars = computed(() =>
|
||||
organizationStore.userCan(organizationStore.activeOrganization, organizationPermissions.manageConnectors)
|
||||
);
|
||||
const canManageWorkspaceCalendars = computed(() => authStore.isManager);
|
||||
const availableCalendarSources = computed(() =>
|
||||
[...calendarStore.sources].sort((left, right) => {
|
||||
const scopeSort = ['Organization', 'Workspace', 'User'];
|
||||
const scopeDiff = scopeSort.indexOf(left.scope) - scopeSort.indexOf(right.scope);
|
||||
return scopeDiff || left.displayTitle.localeCompare(right.displayTitle);
|
||||
})
|
||||
);
|
||||
const visibleCalendarSourceCount = computed(() =>
|
||||
availableCalendarSources.value.filter(source => sourceIsVisible(source.id)).length
|
||||
);
|
||||
const addScopeOptions = computed(() => [
|
||||
...(canManageOrganizationCalendars.value ? [{ value: 'Organization', label: t('contentItems.calendar.organization') }] : []),
|
||||
...(canManageWorkspaceCalendars.value && calendarWorkspaceId.value ? [{ value: 'Workspace', label: t('contentItems.calendar.workspace') }] : []),
|
||||
{ value: 'User', label: t('contentItems.calendar.mine') },
|
||||
]);
|
||||
|
||||
function buildDay(date, isOutsideMonth) {
|
||||
const key = dateKey(date);
|
||||
@@ -191,6 +262,205 @@
|
||||
};
|
||||
}
|
||||
|
||||
function buildImportedCalendarEntry(event) {
|
||||
const source = calendarStore.sourceById(event.calendarSourceId);
|
||||
const scheduledAt = parseCalendarEventDate(event);
|
||||
|
||||
return {
|
||||
id: event.id,
|
||||
type: 'imported-calendar',
|
||||
title: event.title,
|
||||
subtitle: source?.displayTitle ?? t('contentItems.calendar.importedEvent'),
|
||||
scheduledAt,
|
||||
dayKey: dateKey(event.startDate),
|
||||
timeLabel: event.isAllDay ? t('contentItems.calendar.allDay') : formatHour(scheduledAt),
|
||||
tone: 'calendar-context',
|
||||
source,
|
||||
color: source?.color ?? '#64748b',
|
||||
route: null,
|
||||
};
|
||||
}
|
||||
|
||||
function parseCalendarEventDate(event) {
|
||||
if (event.startLocalDateTime) {
|
||||
return new Date(event.startLocalDateTime);
|
||||
}
|
||||
|
||||
if (event.startUtc) {
|
||||
return new Date(event.startUtc);
|
||||
}
|
||||
|
||||
return new Date(`${event.startDate}T00:00:00`);
|
||||
}
|
||||
|
||||
function normalizeCalendarUrl(value) {
|
||||
return String(value ?? '').trim().replace(/\/$/, '').toLowerCase();
|
||||
}
|
||||
|
||||
function entryStyle(entry) {
|
||||
if (entry.type !== 'imported-calendar') {
|
||||
return {};
|
||||
}
|
||||
|
||||
const color = entry.color || '#64748b';
|
||||
return {
|
||||
borderColor: `${color}55`,
|
||||
borderLeftColor: color,
|
||||
};
|
||||
}
|
||||
|
||||
function sourceIsVisible(sourceId) {
|
||||
return !calendarStore.hiddenSourceIds.has(sourceId);
|
||||
}
|
||||
|
||||
function toggleSource(sourceId) {
|
||||
calendarStore.toggleSourceVisibility(sourceId);
|
||||
}
|
||||
|
||||
function openAddCalendar() {
|
||||
isCalendarSelectorOpen.value = false;
|
||||
isAddCalendarOpen.value = true;
|
||||
}
|
||||
|
||||
function createFromImportedEvent(entry) {
|
||||
router.push({
|
||||
name: 'content-item-create',
|
||||
query: {
|
||||
date: entry.dayKey,
|
||||
title: entry.title,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function refreshCalendarData() {
|
||||
if (!calendarWorkspaceId.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
await calendarStore.fetchSources(calendarWorkspaceId.value);
|
||||
|
||||
const sourcesToRefresh = calendarStore.sources.filter(source =>
|
||||
source.isEnabled &&
|
||||
!calendarStore.hiddenSourceIds.has(source.id) &&
|
||||
(!source.lastSuccessfulSyncAt || source.lastSyncError)
|
||||
);
|
||||
|
||||
await Promise.all(sourcesToRefresh.map(source => calendarStore.refreshSource(source.id)));
|
||||
|
||||
await calendarStore.fetchEvents({
|
||||
workspaceId: calendarWorkspaceId.value,
|
||||
startDate: calendarRange.value.startDate,
|
||||
endDate: calendarRange.value.endDate,
|
||||
});
|
||||
}
|
||||
|
||||
async function searchCatalog() {
|
||||
await calendarStore.searchCatalog({
|
||||
search: catalogFilters.search || undefined,
|
||||
country: catalogFilters.country || undefined,
|
||||
category: catalogFilters.category || undefined,
|
||||
});
|
||||
}
|
||||
|
||||
async function addCatalogSource(entry) {
|
||||
if (catalogEntryAlreadyAdded(entry)) {
|
||||
addCalendarError.value = t('contentItems.calendar.errors.duplicate');
|
||||
return;
|
||||
}
|
||||
|
||||
await addCalendarSource({
|
||||
title: entry.title,
|
||||
sourceUrl: entry.sourceUrl,
|
||||
color: entry.defaultColor,
|
||||
category: entry.category,
|
||||
catalogSourceReference: String(entry.id),
|
||||
});
|
||||
}
|
||||
|
||||
async function addCustomSource() {
|
||||
await addCalendarSource({
|
||||
title: customCalendarForm.title,
|
||||
sourceUrl: customCalendarForm.sourceUrl,
|
||||
color: customCalendarForm.color,
|
||||
category: customCalendarForm.category,
|
||||
catalogSourceReference: null,
|
||||
});
|
||||
}
|
||||
|
||||
async function addCalendarSource({ title, sourceUrl, color, category, catalogSourceReference }) {
|
||||
addCalendarError.value = '';
|
||||
const scope = customCalendarForm.scope;
|
||||
const payload = {
|
||||
scope,
|
||||
organizationId: scope === 'Organization' ? organizationStore.activeOrganization?.id : null,
|
||||
workspaceId: scope === 'Workspace' ? calendarWorkspaceId.value : null,
|
||||
sourceUrl,
|
||||
catalogSourceReference,
|
||||
displayTitle: title.trim(),
|
||||
color,
|
||||
category,
|
||||
isEnabled: true,
|
||||
inheritanceMode: scope === 'Organization' ? 'Optional' : null,
|
||||
};
|
||||
|
||||
if (!payload.displayTitle || !payload.sourceUrl) {
|
||||
addCalendarError.value = t('contentItems.calendar.errors.required');
|
||||
return;
|
||||
}
|
||||
|
||||
if (sourceAlreadyAdded(payload)) {
|
||||
addCalendarError.value = t('contentItems.calendar.errors.duplicate');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const source = await calendarStore.createSource(payload);
|
||||
await calendarStore.refreshSource(source?.id);
|
||||
isAddCalendarOpen.value = false;
|
||||
customCalendarForm.title = '';
|
||||
customCalendarForm.sourceUrl = '';
|
||||
await refreshCalendarData();
|
||||
} catch {
|
||||
addCalendarError.value = t('contentItems.calendar.errors.createFailed');
|
||||
}
|
||||
}
|
||||
|
||||
function catalogEntryAlreadyAdded(entry) {
|
||||
return sourceAlreadyAdded({
|
||||
scope: customCalendarForm.scope,
|
||||
organizationId: customCalendarForm.scope === 'Organization' ? organizationStore.activeOrganization?.id : null,
|
||||
workspaceId: customCalendarForm.scope === 'Workspace' ? calendarWorkspaceId.value : null,
|
||||
sourceUrl: entry.sourceUrl,
|
||||
catalogSourceReference: String(entry.id),
|
||||
});
|
||||
}
|
||||
|
||||
function sourceAlreadyAdded(payload) {
|
||||
const normalizedUrl = normalizeCalendarUrl(payload.sourceUrl);
|
||||
const normalizedReference = String(payload.catalogSourceReference ?? '').trim();
|
||||
|
||||
return calendarStore.sources.some(source => {
|
||||
if (source.scope !== payload.scope) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (payload.scope === 'Organization' && source.organizationId !== payload.organizationId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (payload.scope === 'Workspace' && source.workspaceId !== payload.workspaceId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (payload.scope === 'User' && (source.organizationId || source.workspaceId)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return Boolean(normalizedReference && source.catalogSourceReference === normalizedReference) ||
|
||||
Boolean(normalizedUrl && normalizeCalendarUrl(source.sourceUrl) === normalizedUrl);
|
||||
});
|
||||
}
|
||||
|
||||
function setView(mode) {
|
||||
viewMode.value = mode;
|
||||
|
||||
@@ -228,6 +498,14 @@
|
||||
return value ? new Date(value).toLocaleDateString() : t('contentItems.noDueDate');
|
||||
}
|
||||
|
||||
function formatEntryDate(value) {
|
||||
return new Intl.DateTimeFormat(locale.value, {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
}).format(value);
|
||||
}
|
||||
|
||||
function startOfDay(value) {
|
||||
const date = new Date(value);
|
||||
date.setHours(0, 0, 0, 0);
|
||||
@@ -271,6 +549,10 @@
|
||||
}
|
||||
|
||||
function dateKey(value) {
|
||||
if (typeof value === 'string' && /^\d{4}-\d{2}-\d{2}/.test(value)) {
|
||||
return value.slice(0, 10);
|
||||
}
|
||||
|
||||
const date = new Date(value);
|
||||
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`;
|
||||
}
|
||||
@@ -317,6 +599,33 @@
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
watch(
|
||||
() => [calendarWorkspaceId.value, viewMode.value, calendarRange.value.startDate, calendarRange.value.endDate],
|
||||
async () => {
|
||||
await refreshCalendarData();
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
watch(
|
||||
() => addScopeOptions.value.map(option => option.value).join(','),
|
||||
() => {
|
||||
if (!addScopeOptions.value.some(option => option.value === customCalendarForm.scope)) {
|
||||
customCalendarForm.scope = addScopeOptions.value[0]?.value ?? 'User';
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
watch(
|
||||
() => isAddCalendarOpen.value,
|
||||
async value => {
|
||||
if (value && calendarStore.catalogEntries.length === 0) {
|
||||
await searchCatalog();
|
||||
}
|
||||
}
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -326,31 +635,84 @@
|
||||
<h1>{{ t('contentItems.title') }}</h1>
|
||||
</div>
|
||||
|
||||
<div class="view-toggle">
|
||||
<button
|
||||
class="toggle-button"
|
||||
:class="{ 'toggle-button-active': viewMode === 'month' }"
|
||||
type="button"
|
||||
@click="setView('month')"
|
||||
>
|
||||
{{ t('dashboard.month') }}
|
||||
</button>
|
||||
<button
|
||||
class="toggle-button"
|
||||
:class="{ 'toggle-button-active': viewMode === 'week' }"
|
||||
type="button"
|
||||
@click="setView('week')"
|
||||
>
|
||||
{{ t('dashboard.week') }}
|
||||
</button>
|
||||
<button
|
||||
class="toggle-button"
|
||||
:class="{ 'toggle-button-active': viewMode === 'upcoming' }"
|
||||
type="button"
|
||||
@click="setView('upcoming')"
|
||||
>
|
||||
{{ t('contentItems.upcoming') }}
|
||||
</button>
|
||||
<div class="header-actions">
|
||||
<div class="calendar-selector">
|
||||
<button
|
||||
class="calendar-selector-button"
|
||||
type="button"
|
||||
@click="isCalendarSelectorOpen = !isCalendarSelectorOpen"
|
||||
>
|
||||
<span>{{ t('contentItems.calendar.calendars') }}</span>
|
||||
<strong>{{ visibleCalendarSourceCount }}/{{ availableCalendarSources.length }}</strong>
|
||||
<v-icon :icon="mdiChevronDown" />
|
||||
</button>
|
||||
|
||||
<div
|
||||
v-if="isCalendarSelectorOpen"
|
||||
class="calendar-selector-menu"
|
||||
>
|
||||
<button
|
||||
v-for="source in availableCalendarSources"
|
||||
:key="source.id"
|
||||
class="calendar-selector-row"
|
||||
type="button"
|
||||
@click="toggleSource(source.id)"
|
||||
>
|
||||
<span
|
||||
class="source-swatch"
|
||||
:style="{ background: source.color }"
|
||||
/>
|
||||
<span class="calendar-selector-title">{{ source.displayTitle }}</span>
|
||||
<span
|
||||
class="visibility-switch"
|
||||
:class="{ active: sourceIsVisible(source.id) }"
|
||||
/>
|
||||
</button>
|
||||
|
||||
<div
|
||||
v-if="!availableCalendarSources.length"
|
||||
class="calendar-selector-empty"
|
||||
>
|
||||
{{ t('contentItems.calendar.noCalendars') }}
|
||||
</div>
|
||||
|
||||
<button
|
||||
class="calendar-selector-add"
|
||||
type="button"
|
||||
@click="openAddCalendar"
|
||||
>
|
||||
<v-icon :icon="mdiCalendarPlus" />
|
||||
<span>{{ t('contentItems.calendar.addCalendar') }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="view-toggle">
|
||||
<button
|
||||
class="toggle-button"
|
||||
:class="{ 'toggle-button-active': viewMode === 'month' }"
|
||||
type="button"
|
||||
@click="setView('month')"
|
||||
>
|
||||
{{ t('dashboard.month') }}
|
||||
</button>
|
||||
<button
|
||||
class="toggle-button"
|
||||
:class="{ 'toggle-button-active': viewMode === 'week' }"
|
||||
type="button"
|
||||
@click="setView('week')"
|
||||
>
|
||||
{{ t('dashboard.week') }}
|
||||
</button>
|
||||
<button
|
||||
class="toggle-button"
|
||||
:class="{ 'toggle-button-active': viewMode === 'upcoming' }"
|
||||
type="button"
|
||||
@click="setView('upcoming')"
|
||||
>
|
||||
{{ t('contentItems.upcoming') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -434,17 +796,34 @@
|
||||
v-if="day.entries.length"
|
||||
class="day-entries"
|
||||
>
|
||||
<router-link
|
||||
<template
|
||||
v-for="entry in viewMode === 'month' ? day.entries.slice(0, 3) : day.entries"
|
||||
:key="`${entry.type}-${entry.id}`"
|
||||
:to="entry.route"
|
||||
class="calendar-entry"
|
||||
:class="entry.tone"
|
||||
>
|
||||
<span class="entry-time">{{ entry.timeLabel }}</span>
|
||||
<strong>{{ entry.title }}</strong>
|
||||
<span>{{ entry.subtitle }}</span>
|
||||
</router-link>
|
||||
<button
|
||||
v-if="entry.type === 'imported-calendar'"
|
||||
class="calendar-entry calendar-context-entry"
|
||||
:class="entry.tone"
|
||||
:style="entryStyle(entry)"
|
||||
type="button"
|
||||
@click="createFromImportedEvent(entry)"
|
||||
>
|
||||
<span class="entry-time">{{ entry.timeLabel }}</span>
|
||||
<strong>{{ entry.title }}</strong>
|
||||
<span>{{ entry.subtitle }}</span>
|
||||
</button>
|
||||
|
||||
<router-link
|
||||
v-else
|
||||
:to="entry.route"
|
||||
class="calendar-entry"
|
||||
:class="entry.tone"
|
||||
>
|
||||
<span class="entry-time">{{ entry.timeLabel }}</span>
|
||||
<strong>{{ entry.title }}</strong>
|
||||
<span>{{ entry.subtitle }}</span>
|
||||
</router-link>
|
||||
</template>
|
||||
|
||||
<div
|
||||
v-if="viewMode === 'month' && day.entries.length > 3"
|
||||
@@ -465,23 +844,57 @@
|
||||
</article>
|
||||
|
||||
<div
|
||||
v-else-if="upcomingItems.length"
|
||||
v-else-if="upcomingEntries.length"
|
||||
class="item-grid"
|
||||
>
|
||||
<router-link
|
||||
v-for="item in upcomingItems"
|
||||
:key="item.id"
|
||||
:to="{ name: 'content-item-detail', params: { id: item.id }, query: { returnTo: route.fullPath } }"
|
||||
class="item-card"
|
||||
<template
|
||||
v-for="entry in upcomingEntries"
|
||||
:key="`${entry.type}-${entry.id}`"
|
||||
>
|
||||
<div class="version-chip">{{ item.currentRevisionLabel }}</div>
|
||||
<strong>{{ item.title }}</strong>
|
||||
<span>{{ item.publicationTargets }}</span>
|
||||
<div class="status-row">
|
||||
<em>{{ item.status }}</em>
|
||||
<small>{{ formatDueDate(item.dueDate) }}</small>
|
||||
</div>
|
||||
</router-link>
|
||||
<button
|
||||
v-if="entry.type === 'imported-calendar'"
|
||||
class="item-card calendar-upcoming-card"
|
||||
:style="entryStyle(entry)"
|
||||
type="button"
|
||||
@click="createFromImportedEvent(entry)"
|
||||
>
|
||||
<div class="version-chip">{{ t('contentItems.calendar.context') }}</div>
|
||||
<strong>{{ entry.title }}</strong>
|
||||
<span>{{ entry.subtitle }}</span>
|
||||
<div class="status-row">
|
||||
<em>{{ entry.timeLabel }}</em>
|
||||
<small>{{ formatEntryDate(entry.scheduledAt) }}</small>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<router-link
|
||||
v-else-if="entry.type === 'content'"
|
||||
:to="entry.route"
|
||||
class="item-card"
|
||||
>
|
||||
<div class="version-chip">{{ contentItemsStore.items.find(item => item.id === entry.id)?.currentRevisionLabel }}</div>
|
||||
<strong>{{ entry.title }}</strong>
|
||||
<span>{{ contentItemsStore.items.find(item => item.id === entry.id)?.publicationTargets }}</span>
|
||||
<div class="status-row">
|
||||
<em>{{ contentItemsStore.items.find(item => item.id === entry.id)?.status }}</em>
|
||||
<small>{{ formatEntryDate(entry.scheduledAt) }}</small>
|
||||
</div>
|
||||
</router-link>
|
||||
|
||||
<router-link
|
||||
v-else
|
||||
:to="entry.route"
|
||||
class="item-card"
|
||||
>
|
||||
<div class="version-chip">{{ t('dashboard.campaignDeadline') }}</div>
|
||||
<strong>{{ entry.title }}</strong>
|
||||
<span>{{ entry.subtitle }}</span>
|
||||
<div class="status-row">
|
||||
<em>{{ entry.timeLabel }}</em>
|
||||
<small>{{ formatEntryDate(entry.scheduledAt) }}</small>
|
||||
</div>
|
||||
</router-link>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div
|
||||
@@ -490,6 +903,155 @@
|
||||
>
|
||||
{{ t('contentItems.empty') }}
|
||||
</div>
|
||||
|
||||
<v-dialog
|
||||
v-model="isAddCalendarOpen"
|
||||
max-width="760"
|
||||
>
|
||||
<div class="calendar-dialog">
|
||||
<div class="dialog-header">
|
||||
<strong>{{ t('contentItems.calendar.addCalendar') }}</strong>
|
||||
<button
|
||||
class="icon-button"
|
||||
type="button"
|
||||
@click="isAddCalendarOpen = false"
|
||||
>
|
||||
<v-icon :icon="mdiClose" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="add-mode-toggle">
|
||||
<button
|
||||
class="toggle-button"
|
||||
:class="{ 'toggle-button-active': activeAddMode === 'catalog' }"
|
||||
type="button"
|
||||
@click="activeAddMode = 'catalog'"
|
||||
>
|
||||
{{ t('contentItems.calendar.catalog') }}
|
||||
</button>
|
||||
<button
|
||||
class="toggle-button"
|
||||
:class="{ 'toggle-button-active': activeAddMode === 'custom' }"
|
||||
type="button"
|
||||
@click="activeAddMode = 'custom'"
|
||||
>
|
||||
{{ t('contentItems.calendar.customIcs') }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="scope-row">
|
||||
<label
|
||||
v-for="option in addScopeOptions"
|
||||
:key="option.value"
|
||||
class="scope-option"
|
||||
>
|
||||
<input
|
||||
v-model="customCalendarForm.scope"
|
||||
type="radio"
|
||||
:value="option.value"
|
||||
>
|
||||
<span>{{ option.label }}</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="activeAddMode === 'catalog'"
|
||||
class="catalog-panel"
|
||||
>
|
||||
<div class="catalog-search">
|
||||
<input
|
||||
v-model="catalogFilters.search"
|
||||
type="search"
|
||||
:placeholder="t('contentItems.calendar.searchCatalog')"
|
||||
>
|
||||
<input
|
||||
v-model="catalogFilters.country"
|
||||
type="text"
|
||||
maxlength="2"
|
||||
:placeholder="t('contentItems.calendar.country')"
|
||||
>
|
||||
<input
|
||||
v-model="catalogFilters.category"
|
||||
type="text"
|
||||
:placeholder="t('contentItems.calendar.category')"
|
||||
>
|
||||
<button
|
||||
class="text-button"
|
||||
type="button"
|
||||
@click="searchCatalog"
|
||||
>
|
||||
<v-icon :icon="mdiMagnify" />
|
||||
<span>{{ t('contentItems.calendar.search') }}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="catalog-results">
|
||||
<button
|
||||
v-for="entry in calendarStore.catalogEntries"
|
||||
:key="entry.id"
|
||||
class="catalog-entry"
|
||||
:class="{ 'catalog-entry-disabled': catalogEntryAlreadyAdded(entry) }"
|
||||
type="button"
|
||||
:disabled="catalogEntryAlreadyAdded(entry)"
|
||||
@click="addCatalogSource(entry)"
|
||||
>
|
||||
<span
|
||||
class="source-swatch"
|
||||
:style="{ background: entry.defaultColor }"
|
||||
/>
|
||||
<strong>{{ entry.title }}</strong>
|
||||
<span>
|
||||
{{ catalogEntryAlreadyAdded(entry)
|
||||
? t('contentItems.calendar.alreadyAdded')
|
||||
: `${entry.providerName} · ${entry.category}` }}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form
|
||||
v-else
|
||||
class="custom-calendar-form"
|
||||
@submit.prevent="addCustomSource"
|
||||
>
|
||||
<input
|
||||
v-model="customCalendarForm.title"
|
||||
type="text"
|
||||
:placeholder="t('contentItems.calendar.calendarName')"
|
||||
>
|
||||
<input
|
||||
v-model="customCalendarForm.sourceUrl"
|
||||
type="url"
|
||||
:placeholder="t('contentItems.calendar.icsUrl')"
|
||||
>
|
||||
<div class="custom-form-row">
|
||||
<input
|
||||
v-model="customCalendarForm.color"
|
||||
type="color"
|
||||
>
|
||||
<input
|
||||
v-model="customCalendarForm.category"
|
||||
type="text"
|
||||
:placeholder="t('contentItems.calendar.category')"
|
||||
>
|
||||
<button
|
||||
class="text-button"
|
||||
type="submit"
|
||||
>
|
||||
<v-icon :icon="mdiPlus" />
|
||||
<span>{{ t('contentItems.calendar.addCalendar') }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<p
|
||||
v-if="addCalendarError || calendarStore.error"
|
||||
class="dialog-error"
|
||||
>
|
||||
{{ addCalendarError || calendarStore.error }}
|
||||
</p>
|
||||
</div>
|
||||
</v-dialog>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
@@ -514,12 +1076,84 @@
|
||||
color: #526178;
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
@apply flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-end;
|
||||
}
|
||||
|
||||
.view-toggle {
|
||||
@apply inline-flex w-fit rounded-full border p-1;
|
||||
background: #f8fafc;
|
||||
border-color: rgba(23, 32, 51, 0.1);
|
||||
}
|
||||
|
||||
.calendar-selector {
|
||||
@apply relative w-full sm:w-auto;
|
||||
}
|
||||
|
||||
.calendar-selector-button {
|
||||
@apply inline-flex min-h-11 w-full items-center justify-between gap-2 rounded-full border px-4 py-2 text-sm font-bold transition sm:w-auto;
|
||||
background: #ffffff;
|
||||
border-color: rgba(23, 32, 51, 0.1);
|
||||
color: #172033;
|
||||
}
|
||||
|
||||
.calendar-selector-button strong {
|
||||
@apply rounded-full px-2 py-0.5 text-xs;
|
||||
background: rgba(15, 118, 110, 0.1);
|
||||
color: #0f766e;
|
||||
}
|
||||
|
||||
.calendar-selector-menu {
|
||||
@apply absolute right-0 top-[calc(100%+0.5rem)] z-30 flex w-full min-w-72 flex-col gap-1 rounded-[1rem] border p-2 shadow-xl sm:w-80;
|
||||
background: #ffffff;
|
||||
border-color: rgba(23, 32, 51, 0.1);
|
||||
}
|
||||
|
||||
.calendar-selector-row,
|
||||
.calendar-selector-add {
|
||||
@apply flex min-h-11 w-full items-center gap-3 rounded-[0.75rem] px-3 text-left text-sm font-semibold transition;
|
||||
color: #172033;
|
||||
}
|
||||
|
||||
.calendar-selector-row:hover,
|
||||
.calendar-selector-add:hover {
|
||||
background: #f8fafc;
|
||||
}
|
||||
|
||||
.calendar-selector-title {
|
||||
@apply min-w-0 flex-1 truncate;
|
||||
}
|
||||
|
||||
.calendar-selector-empty {
|
||||
@apply px-3 py-2 text-sm;
|
||||
color: #526178;
|
||||
}
|
||||
|
||||
.calendar-selector-add {
|
||||
@apply border-t;
|
||||
border-color: rgba(23, 32, 51, 0.08);
|
||||
color: #0f766e;
|
||||
}
|
||||
|
||||
.visibility-switch {
|
||||
@apply relative h-6 w-10 shrink-0 rounded-full transition;
|
||||
background: rgba(148, 163, 184, 0.35);
|
||||
}
|
||||
|
||||
.visibility-switch::after {
|
||||
@apply absolute left-1 top-1 h-4 w-4 rounded-full bg-white transition;
|
||||
content: '';
|
||||
box-shadow: 0 1px 4px rgba(23, 32, 51, 0.2);
|
||||
}
|
||||
|
||||
.visibility-switch.active {
|
||||
background: #0f766e;
|
||||
}
|
||||
|
||||
.visibility-switch.active::after {
|
||||
transform: translateX(1rem);
|
||||
}
|
||||
|
||||
.toggle-button,
|
||||
.icon-button,
|
||||
.text-button {
|
||||
@@ -584,6 +1218,10 @@
|
||||
color: #172033;
|
||||
}
|
||||
|
||||
.source-swatch {
|
||||
@apply h-3 w-3 shrink-0 rounded-full;
|
||||
}
|
||||
|
||||
.calendar-grid {
|
||||
@apply grid gap-3;
|
||||
grid-template-columns: repeat(7, minmax(0, 1fr));
|
||||
@@ -631,6 +1269,11 @@
|
||||
@apply flex flex-col gap-0.5 rounded-[1rem] border px-3 py-2 no-underline transition;
|
||||
}
|
||||
|
||||
button.calendar-entry,
|
||||
button.item-card {
|
||||
@apply w-full text-left;
|
||||
}
|
||||
|
||||
.calendar-entry:hover,
|
||||
.item-card:hover {
|
||||
transform: translateY(-1px);
|
||||
@@ -690,6 +1333,105 @@
|
||||
border-color: rgba(148, 163, 184, 0.18);
|
||||
}
|
||||
|
||||
.calendar-context-entry {
|
||||
border-left-width: 4px;
|
||||
background: #ffffff;
|
||||
opacity: 0.86;
|
||||
}
|
||||
|
||||
.calendar-context-entry strong {
|
||||
color: #334155;
|
||||
}
|
||||
|
||||
.calendar-upcoming-card {
|
||||
border-left-width: 4px;
|
||||
}
|
||||
|
||||
.calendar-dialog {
|
||||
@apply flex flex-col gap-4 rounded-[1.5rem] border bg-white p-5;
|
||||
border-color: rgba(23, 32, 51, 0.1);
|
||||
}
|
||||
|
||||
.dialog-header,
|
||||
.add-mode-toggle,
|
||||
.scope-row,
|
||||
.catalog-search,
|
||||
.custom-form-row {
|
||||
@apply flex flex-wrap items-center gap-3;
|
||||
}
|
||||
|
||||
.dialog-header {
|
||||
@apply justify-between;
|
||||
}
|
||||
|
||||
.dialog-header strong {
|
||||
@apply text-lg font-black;
|
||||
color: #172033;
|
||||
}
|
||||
|
||||
.scope-option {
|
||||
@apply inline-flex items-center gap-2 rounded-full border px-3 py-2 text-sm font-semibold;
|
||||
border-color: rgba(23, 32, 51, 0.1);
|
||||
color: #172033;
|
||||
}
|
||||
|
||||
.catalog-panel,
|
||||
.custom-calendar-form,
|
||||
.catalog-results {
|
||||
@apply flex flex-col gap-3;
|
||||
}
|
||||
|
||||
.catalog-search input,
|
||||
.custom-calendar-form input {
|
||||
@apply min-h-11 rounded-[0.75rem] border px-3 text-sm;
|
||||
border-color: rgba(23, 32, 51, 0.12);
|
||||
color: #172033;
|
||||
}
|
||||
|
||||
.catalog-search input[type='search'],
|
||||
.custom-calendar-form input[type='url'],
|
||||
.custom-calendar-form input[type='text'] {
|
||||
@apply min-w-0 flex-1;
|
||||
}
|
||||
|
||||
.catalog-results {
|
||||
@apply max-h-[22rem] overflow-auto;
|
||||
}
|
||||
|
||||
.catalog-entry {
|
||||
@apply grid min-h-14 grid-cols-[auto_minmax(0,1fr)] items-center gap-x-3 rounded-[0.75rem] border px-3 py-2 text-left transition;
|
||||
border-color: rgba(23, 32, 51, 0.08);
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
.catalog-entry:hover {
|
||||
background: #f8fafc;
|
||||
}
|
||||
|
||||
.catalog-entry-disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.58;
|
||||
}
|
||||
|
||||
.catalog-entry-disabled:hover {
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
.catalog-entry strong {
|
||||
@apply text-sm font-bold;
|
||||
color: #172033;
|
||||
}
|
||||
|
||||
.catalog-entry span:last-child {
|
||||
@apply col-start-2 text-xs;
|
||||
color: #526178;
|
||||
}
|
||||
|
||||
.dialog-error {
|
||||
@apply text-sm font-semibold;
|
||||
color: #b91c1c;
|
||||
}
|
||||
|
||||
.item-grid {
|
||||
@apply grid gap-4 md:grid-cols-2 xl:grid-cols-3;
|
||||
}
|
||||
|
||||
@@ -22,11 +22,19 @@ export const useOrganizationStore = defineStore('organization', () => {
|
||||
const detailsById = ref({});
|
||||
const isLoading = ref(false);
|
||||
const isLoadingDetails = ref(false);
|
||||
const isSaving = ref(false);
|
||||
const isAddingMember = ref(false);
|
||||
const isUploadingLogo = ref(false);
|
||||
const error = ref(null);
|
||||
|
||||
const activeOrganization = computed(() =>
|
||||
organizations.value.find(organization => organization.id === selectedOrganizationId.value) ?? null
|
||||
);
|
||||
const activeOrganization = computed(() => {
|
||||
const organization = organizations.value.find(candidate => candidate.id === selectedOrganizationId.value) ?? null;
|
||||
const details = selectedOrganizationId.value ? detailsById.value[selectedOrganizationId.value] : null;
|
||||
|
||||
return organization || details
|
||||
? { ...(organization ?? {}), ...(details ?? {}) }
|
||||
: null;
|
||||
});
|
||||
|
||||
function userCan(organization, permission) {
|
||||
return Boolean(organization?.currentUserPermissions?.includes(permission));
|
||||
@@ -109,6 +117,129 @@ export const useOrganizationStore = defineStore('organization', () => {
|
||||
}
|
||||
}
|
||||
|
||||
async function updateOrganization(organizationId, payload) {
|
||||
if (!authStore.isAuthenticated || !organizationId) {
|
||||
throw new Error('You must be authenticated to update an organization.');
|
||||
}
|
||||
|
||||
isSaving.value = true;
|
||||
error.value = null;
|
||||
|
||||
try {
|
||||
const response = await client.put(`/api/organizations/${organizationId}`, payload);
|
||||
const organization = response.data;
|
||||
|
||||
if (organization) {
|
||||
const currentDetails = detailsById.value[organizationId];
|
||||
detailsById.value = {
|
||||
...detailsById.value,
|
||||
[organizationId]: {
|
||||
...(currentDetails ?? {}),
|
||||
...organization,
|
||||
members: currentDetails?.members ?? organization.members ?? [],
|
||||
workspaces: currentDetails?.workspaces ?? organization.workspaces ?? [],
|
||||
},
|
||||
};
|
||||
organizations.value = organizations.value.map(candidate =>
|
||||
candidate.id === organizationId
|
||||
? { ...candidate, ...organization }
|
||||
: candidate
|
||||
);
|
||||
}
|
||||
|
||||
return organization;
|
||||
} catch (updateError) {
|
||||
console.error('Failed to update organization:', updateError);
|
||||
error.value = 'Failed to update organization.';
|
||||
throw updateError;
|
||||
} finally {
|
||||
isSaving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function addMember(organizationId, payload) {
|
||||
if (!authStore.isAuthenticated || !organizationId) {
|
||||
throw new Error('You must be authenticated to add an organization member.');
|
||||
}
|
||||
|
||||
isAddingMember.value = true;
|
||||
error.value = null;
|
||||
|
||||
try {
|
||||
const response = await client.post(`/api/organizations/${organizationId}/members`, payload);
|
||||
const member = response.data;
|
||||
|
||||
if (member) {
|
||||
const currentDetails = detailsById.value[organizationId];
|
||||
if (currentDetails) {
|
||||
detailsById.value = {
|
||||
...detailsById.value,
|
||||
[organizationId]: {
|
||||
...currentDetails,
|
||||
members: [...(currentDetails.members ?? []), member],
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return member;
|
||||
} catch (addError) {
|
||||
console.error('Failed to add organization member:', addError);
|
||||
error.value = 'Failed to add organization member.';
|
||||
throw addError;
|
||||
} finally {
|
||||
isAddingMember.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function uploadLogo(organizationId, file) {
|
||||
if (!authStore.isAuthenticated || !organizationId) {
|
||||
throw new Error('You must be authenticated to upload an organization logo.');
|
||||
}
|
||||
|
||||
if (isUploadingLogo.value) {
|
||||
throw new Error('An organization logo upload is already in progress.');
|
||||
}
|
||||
|
||||
isUploadingLogo.value = true;
|
||||
error.value = null;
|
||||
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file, file.name || 'organization-logo.png');
|
||||
|
||||
const response = await client.post(`/api/organizations/${organizationId}/logo`, formData);
|
||||
const blobUrl = response.data?.blobUrl;
|
||||
|
||||
if (blobUrl) {
|
||||
const logoUrl = `${blobUrl}?${Date.now()}`;
|
||||
const currentDetails = detailsById.value[organizationId];
|
||||
if (currentDetails) {
|
||||
detailsById.value = {
|
||||
...detailsById.value,
|
||||
[organizationId]: {
|
||||
...currentDetails,
|
||||
logoUrl,
|
||||
},
|
||||
};
|
||||
}
|
||||
organizations.value = organizations.value.map(organization =>
|
||||
organization.id === organizationId
|
||||
? { ...organization, logoUrl }
|
||||
: organization
|
||||
);
|
||||
}
|
||||
|
||||
return response.data;
|
||||
} catch (uploadError) {
|
||||
console.error('Failed to upload organization logo:', uploadError);
|
||||
error.value = 'Failed to upload organization logo.';
|
||||
throw uploadError;
|
||||
} finally {
|
||||
isUploadingLogo.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => authStore.isAuthenticated,
|
||||
async isAuthenticated => {
|
||||
@@ -132,11 +263,17 @@ export const useOrganizationStore = defineStore('organization', () => {
|
||||
detailsById,
|
||||
isLoading,
|
||||
isLoadingDetails,
|
||||
isSaving,
|
||||
isAddingMember,
|
||||
isUploadingLogo,
|
||||
error,
|
||||
userCan,
|
||||
setSelectedOrganization,
|
||||
setSelectedOrganizationFromWorkspace,
|
||||
fetchOrganizations,
|
||||
fetchOrganization,
|
||||
updateOrganization,
|
||||
addMember,
|
||||
uploadLogo,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -1,26 +1,41 @@
|
||||
<script setup>
|
||||
import { computed, onMounted, ref, watch } from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import { computed, onMounted, reactive, ref, watch } from 'vue';
|
||||
import { useRoute } from 'vue-router';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import AppAvatar from '@/components/AppAvatar.vue';
|
||||
import ImageCropperDialog from '@/components/ImageCropperDialog.vue';
|
||||
import {
|
||||
mdiAccountGroupOutline,
|
||||
mdiBriefcaseOutline,
|
||||
mdiCogOutline,
|
||||
mdiChartBar,
|
||||
mdiCheck,
|
||||
mdiClose,
|
||||
mdiCreditCardOutline,
|
||||
mdiLanConnect,
|
||||
mdiPencilOutline,
|
||||
} from '@mdi/js';
|
||||
import {
|
||||
organizationPermissions,
|
||||
useOrganizationStore,
|
||||
} from '@/features/organizations/stores/organizationStore.js';
|
||||
import { useWorkspaceStore } from '@/features/workspaces/stores/workspaceStore.js';
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const { t } = useI18n();
|
||||
const organizationStore = useOrganizationStore();
|
||||
const workspaceStore = useWorkspaceStore();
|
||||
const activeSectionKey = ref('profile');
|
||||
const activeSectionKey = ref('members');
|
||||
const settingsError = ref(null);
|
||||
const settingsStatus = ref(null);
|
||||
const logoError = ref(null);
|
||||
const logoStatus = ref(null);
|
||||
const isLogoDialogOpen = ref(false);
|
||||
const isEditingName = ref(false);
|
||||
const profileForm = reactive({
|
||||
name: '',
|
||||
});
|
||||
const memberForm = reactive({
|
||||
email: '',
|
||||
role: 'Member',
|
||||
});
|
||||
const memberRoleOptions = ['Member', 'Admin', 'BillingManager', 'ConnectorManager'];
|
||||
|
||||
const organizationId = computed(() => route.params.organizationId);
|
||||
const organization = computed(() =>
|
||||
@@ -32,22 +47,26 @@
|
||||
const canViewMembers = computed(() =>
|
||||
permissions.value.includes(organizationPermissions.manageOrganizationMembers)
|
||||
);
|
||||
const canManageSettings = computed(() =>
|
||||
permissions.value.includes(organizationPermissions.manageOrganizationSettings)
|
||||
);
|
||||
const canViewBilling = computed(() =>
|
||||
permissions.value.includes(organizationPermissions.manageBilling)
|
||||
);
|
||||
const canViewConnections = computed(() =>
|
||||
permissions.value.includes(organizationPermissions.manageConnectors)
|
||||
);
|
||||
const canViewWorkspaces = computed(() =>
|
||||
const canViewUsage = computed(() =>
|
||||
permissions.value.includes(organizationPermissions.manageWorkspaces) ||
|
||||
permissions.value.includes(organizationPermissions.manageBilling) ||
|
||||
permissions.value.includes(organizationPermissions.createWorkspaces)
|
||||
);
|
||||
const usageItems = computed(() => organization.value?.usage?.items ?? []);
|
||||
const visibleSections = computed(() => [
|
||||
{ key: 'profile', icon: mdiCogOutline, visible: true },
|
||||
{ key: 'members', icon: mdiAccountGroupOutline, visible: canViewMembers.value },
|
||||
{ key: 'usage', icon: mdiChartBar, visible: canViewUsage.value },
|
||||
{ key: 'billing', icon: mdiCreditCardOutline, visible: canViewBilling.value },
|
||||
{ key: 'connections', icon: mdiLanConnect, visible: canViewConnections.value },
|
||||
{ key: 'workspaces', icon: mdiBriefcaseOutline, visible: canViewWorkspaces.value },
|
||||
].filter(section => section.visible));
|
||||
const activeSection = computed(() =>
|
||||
visibleSections.value.find(section => section.key === activeSectionKey.value) ??
|
||||
@@ -55,10 +74,6 @@
|
||||
null
|
||||
);
|
||||
|
||||
function hasPermission(permission) {
|
||||
return permissions.value.includes(permission);
|
||||
}
|
||||
|
||||
async function loadOrganization() {
|
||||
if (!organizationId.value) {
|
||||
return;
|
||||
@@ -67,22 +82,103 @@
|
||||
await organizationStore.fetchOrganization(organizationId.value);
|
||||
}
|
||||
|
||||
async function openWorkspace(workspaceId) {
|
||||
const workspace = organization.value?.workspaces?.find(candidate => candidate.id === workspaceId);
|
||||
if (workspace) {
|
||||
workspaceStore.setActiveWorkspace(workspace.id);
|
||||
await router.push({ name: 'workspace-dashboard' });
|
||||
async function submitProfile() {
|
||||
settingsError.value = null;
|
||||
settingsStatus.value = null;
|
||||
|
||||
const name = profileForm.name.trim();
|
||||
if (!name) {
|
||||
settingsError.value = t('organizationSettings.errors.nameRequired');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await organizationStore.updateOrganization(organizationId.value, { name });
|
||||
settingsStatus.value = t('organizationSettings.profileSaved');
|
||||
isEditingName.value = false;
|
||||
} catch (error) {
|
||||
console.error('Failed to save organization profile:', error);
|
||||
settingsError.value = t('organizationSettings.errors.profileSaveFailed');
|
||||
}
|
||||
}
|
||||
|
||||
function startEditingName() {
|
||||
profileForm.name = organization.value?.name ?? '';
|
||||
settingsError.value = null;
|
||||
settingsStatus.value = null;
|
||||
isEditingName.value = true;
|
||||
}
|
||||
|
||||
function cancelEditingName() {
|
||||
profileForm.name = organization.value?.name ?? '';
|
||||
settingsError.value = null;
|
||||
isEditingName.value = false;
|
||||
}
|
||||
|
||||
async function saveOrganizationLogo(result) {
|
||||
if (!organization.value || organizationStore.isUploadingLogo) {
|
||||
return;
|
||||
}
|
||||
|
||||
logoError.value = null;
|
||||
logoStatus.value = null;
|
||||
|
||||
try {
|
||||
await organizationStore.uploadLogo(organizationId.value, result.file);
|
||||
logoStatus.value = t('organizationSettings.logo.saved');
|
||||
isLogoDialogOpen.value = false;
|
||||
} catch (error) {
|
||||
console.error('Failed to update organization logo:', error);
|
||||
logoError.value = t('organizationSettings.errors.logoUploadFailed');
|
||||
}
|
||||
}
|
||||
|
||||
async function submitMember() {
|
||||
settingsError.value = null;
|
||||
settingsStatus.value = null;
|
||||
|
||||
if (!memberForm.email.trim() || !memberForm.role) {
|
||||
settingsError.value = t('organizationSettings.errors.memberRequired');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await organizationStore.addMember(organizationId.value, {
|
||||
email: memberForm.email.trim(),
|
||||
role: memberForm.role,
|
||||
});
|
||||
memberForm.email = '';
|
||||
memberForm.role = 'Member';
|
||||
settingsStatus.value = t('organizationSettings.memberAdded');
|
||||
} catch (error) {
|
||||
console.error('Failed to add organization member:', error);
|
||||
settingsError.value = t('organizationSettings.errors.memberAddFailed');
|
||||
}
|
||||
}
|
||||
|
||||
function usagePercent(item) {
|
||||
if (!item.limit) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return Math.min(100, Math.round((item.used / item.limit) * 100));
|
||||
}
|
||||
|
||||
onMounted(loadOrganization);
|
||||
|
||||
watch(organizationId, loadOrganization);
|
||||
watch(
|
||||
organization,
|
||||
currentOrganization => {
|
||||
profileForm.name = currentOrganization?.name ?? '';
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
watch(
|
||||
visibleSections,
|
||||
sections => {
|
||||
if (!sections.some(section => section.key === activeSectionKey.value)) {
|
||||
activeSectionKey.value = sections[0]?.key ?? 'profile';
|
||||
activeSectionKey.value = sections[0]?.key ?? null;
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
@@ -92,10 +188,88 @@
|
||||
<template>
|
||||
<section class="organization-settings-shell">
|
||||
<div class="settings-hero">
|
||||
<div>
|
||||
<div class="settings-hero-copy">
|
||||
<div class="eyebrow">{{ t('organizationSettings.eyebrow') }}</div>
|
||||
<h1>{{ organization?.name ?? t('organizationSettings.title') }}</h1>
|
||||
<p>{{ t('organizationSettings.description') }}</p>
|
||||
<div class="organization-title-line">
|
||||
<button
|
||||
v-if="organization"
|
||||
class="organization-logo-button"
|
||||
type="button"
|
||||
:disabled="!canManageSettings || organizationStore.isUploadingLogo"
|
||||
:aria-label="t('organizationSettings.logo.changeAction')"
|
||||
:title="t('organizationSettings.logo.changeAction')"
|
||||
@click="isLogoDialogOpen = true"
|
||||
>
|
||||
<AppAvatar
|
||||
:name="profileForm.name || organization.name"
|
||||
:src="organization.logoUrl"
|
||||
size="lg"
|
||||
/>
|
||||
</button>
|
||||
<form
|
||||
v-if="organization && isEditingName"
|
||||
class="title-edit-form"
|
||||
@submit.prevent="submitProfile"
|
||||
>
|
||||
<input
|
||||
v-model="profileForm.name"
|
||||
type="text"
|
||||
maxlength="256"
|
||||
autocomplete="organization"
|
||||
:aria-label="t('organizationSettings.fields.name')"
|
||||
>
|
||||
<button
|
||||
class="icon-action"
|
||||
type="submit"
|
||||
:disabled="organizationStore.isSaving"
|
||||
:aria-label="t('organizationSettings.saveName')"
|
||||
:title="t('organizationSettings.saveName')"
|
||||
>
|
||||
<v-icon :icon="mdiCheck" />
|
||||
</button>
|
||||
<button
|
||||
class="icon-action secondary"
|
||||
type="button"
|
||||
:disabled="organizationStore.isSaving"
|
||||
:aria-label="t('organizationSettings.cancelNameEdit')"
|
||||
:title="t('organizationSettings.cancelNameEdit')"
|
||||
@click="cancelEditingName"
|
||||
>
|
||||
<v-icon :icon="mdiClose" />
|
||||
</button>
|
||||
</form>
|
||||
<div
|
||||
v-else
|
||||
class="title-row"
|
||||
>
|
||||
<h1>{{ organization?.name ?? t('organizationSettings.title') }}</h1>
|
||||
<button
|
||||
v-if="organization && canManageSettings"
|
||||
class="icon-action secondary"
|
||||
type="button"
|
||||
:aria-label="t('organizationSettings.editName')"
|
||||
:title="t('organizationSettings.editName')"
|
||||
@click="startEditingName"
|
||||
>
|
||||
<v-icon :icon="mdiPencilOutline" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="hero-status">
|
||||
<small
|
||||
v-if="logoError"
|
||||
class="field-error"
|
||||
>
|
||||
{{ logoError }}
|
||||
</small>
|
||||
<small
|
||||
v-if="logoStatus"
|
||||
class="field-success"
|
||||
>
|
||||
{{ logoStatus }}
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -145,35 +319,57 @@
|
||||
|
||||
<article class="content-card">
|
||||
<div
|
||||
v-if="activeSection.key === 'profile'"
|
||||
class="detail-list"
|
||||
v-if="settingsError"
|
||||
class="settings-alert error"
|
||||
>
|
||||
<div>
|
||||
<span>{{ t('organizationSettings.fields.name') }}</span>
|
||||
<strong>{{ organization.name }}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>{{ t('organizationSettings.fields.createdAt') }}</span>
|
||||
<strong>{{ new Date(organization.createdAt).toLocaleDateString() }}</strong>
|
||||
</div>
|
||||
<div class="permissions-panel">
|
||||
<span>{{ t('organizationSettings.permissions.title') }}</span>
|
||||
<div class="permission-grid">
|
||||
<span
|
||||
v-for="permission in Object.values(organizationPermissions)"
|
||||
:key="permission"
|
||||
:class="{ enabled: hasPermission(permission) }"
|
||||
>
|
||||
{{ t(`organizationSettings.permissions.items.${permission}`) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{{ settingsError }}
|
||||
</div>
|
||||
<div
|
||||
v-if="settingsStatus"
|
||||
class="settings-alert success"
|
||||
>
|
||||
{{ settingsStatus }}
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else-if="activeSection.key === 'members'"
|
||||
v-if="activeSection.key === 'members'"
|
||||
class="table-list"
|
||||
>
|
||||
<form
|
||||
class="settings-form invite-form"
|
||||
@submit.prevent="submitMember"
|
||||
>
|
||||
<label>
|
||||
<span>{{ t('organizationSettings.fields.memberEmail') }}</span>
|
||||
<input
|
||||
v-model="memberForm.email"
|
||||
type="email"
|
||||
maxlength="256"
|
||||
autocomplete="email"
|
||||
>
|
||||
</label>
|
||||
<label>
|
||||
<span>{{ t('organizationSettings.fields.memberRole') }}</span>
|
||||
<select v-model="memberForm.role">
|
||||
<option
|
||||
v-for="role in memberRoleOptions"
|
||||
:key="role"
|
||||
:value="role"
|
||||
>
|
||||
{{ t(`organizationSettings.roles.${role}`, role) }}
|
||||
</option>
|
||||
</select>
|
||||
</label>
|
||||
<div class="form-actions">
|
||||
<button
|
||||
class="primary-action"
|
||||
type="submit"
|
||||
:disabled="organizationStore.isAddingMember"
|
||||
>
|
||||
{{ organizationStore.isAddingMember ? t('organizationSettings.addingMember') : t('organizationSettings.addMember') }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
<div
|
||||
v-for="member in organization.members"
|
||||
:key="member.userId"
|
||||
@@ -210,31 +406,48 @@
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else-if="activeSection.key === 'workspaces'"
|
||||
class="table-list"
|
||||
v-else-if="activeSection.key === 'usage'"
|
||||
class="usage-list"
|
||||
>
|
||||
<button
|
||||
v-for="workspace in organization.workspaces"
|
||||
:key="workspace.id"
|
||||
class="table-row table-row-button"
|
||||
type="button"
|
||||
@click="openWorkspace(workspace.id)"
|
||||
>
|
||||
<div>
|
||||
<strong>{{ workspace.name }}</strong>
|
||||
<span>{{ workspace.timeZone }}</span>
|
||||
</div>
|
||||
</button>
|
||||
<div class="usage-plan">
|
||||
<strong>{{ t('organizationSettings.sections.usage.planLabel') }}</strong>
|
||||
<span>{{ organization.usage?.planName ?? t('organizationSettings.sections.usage.planFallback') }}</span>
|
||||
</div>
|
||||
<div
|
||||
v-if="!organization.workspaces?.length"
|
||||
v-for="item in usageItems"
|
||||
:key="item.key"
|
||||
class="usage-row"
|
||||
>
|
||||
<div class="usage-row-heading">
|
||||
<strong>{{ t(`organizationSettings.usage.items.${item.key}`) }}</strong>
|
||||
<span>
|
||||
{{ item.used }} / {{ item.limit ?? t('organizationSettings.usage.unlimited') }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="usage-meter">
|
||||
<span :style="{ width: `${usagePercent(item)}%` }" />
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="!usageItems.length"
|
||||
class="empty-state"
|
||||
>
|
||||
{{ t('organizationSettings.sections.workspaces.empty') }}
|
||||
{{ t('organizationSettings.sections.usage.empty') }}
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ImageCropperDialog
|
||||
v-model="isLogoDialogOpen"
|
||||
:title="t('organizationSettings.logo.cropperTitle')"
|
||||
:confirm-label="t('organizationSettings.logo.saveAction')"
|
||||
:upload-label="t('organizationSettings.logo.chooseAction')"
|
||||
:initial-url="organization?.logoUrl"
|
||||
:is-saving="organizationStore.isUploadingLogo"
|
||||
@save="saveOrganizationLogo"
|
||||
/>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
@@ -243,19 +456,35 @@
|
||||
@apply mx-auto flex w-full max-w-6xl flex-col gap-6 px-5 py-8 md:px-8;
|
||||
}
|
||||
|
||||
.settings-hero {
|
||||
@apply flex flex-col;
|
||||
}
|
||||
|
||||
.settings-hero-copy {
|
||||
@apply flex min-w-0 flex-1 flex-col gap-1;
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
@apply text-xs font-bold uppercase tracking-[0.2em];
|
||||
color: #c2410c;
|
||||
}
|
||||
|
||||
.settings-hero h1 {
|
||||
@apply mt-3 text-3xl font-black md:text-4xl;
|
||||
.title-row {
|
||||
@apply flex min-w-0 items-center gap-2;
|
||||
}
|
||||
|
||||
.title-row h1 {
|
||||
@apply min-w-0 break-words;
|
||||
}
|
||||
|
||||
.settings-hero h1,
|
||||
.title-edit-form input {
|
||||
@apply min-w-0 text-3xl font-black md:text-4xl;
|
||||
color: #172033;
|
||||
}
|
||||
|
||||
.settings-hero p,
|
||||
.section-heading p,
|
||||
.detail-list span,
|
||||
.table-row span,
|
||||
.placeholder-panel span,
|
||||
.empty-state {
|
||||
@@ -263,6 +492,62 @@
|
||||
color: #526178;
|
||||
}
|
||||
|
||||
.organization-title-line {
|
||||
@apply mt-5 flex min-w-0 items-center gap-3;
|
||||
}
|
||||
|
||||
.organization-logo-button {
|
||||
@apply inline-flex size-14 flex-shrink-0 items-center justify-center rounded-[0.75rem] border bg-white transition-colors md:size-16;
|
||||
border-color: rgba(23, 32, 51, 0.12);
|
||||
}
|
||||
|
||||
.organization-logo-button:hover:not(:disabled) {
|
||||
border-color: #0f766e;
|
||||
box-shadow: 0 0 0 3px rgba(15, 118, 110, 0.12);
|
||||
}
|
||||
|
||||
.organization-logo-button:disabled {
|
||||
@apply cursor-default opacity-80;
|
||||
}
|
||||
|
||||
.title-edit-form {
|
||||
@apply flex min-w-0 flex-1 flex-wrap items-center gap-2;
|
||||
}
|
||||
|
||||
.title-edit-form input {
|
||||
@apply h-12 min-w-0 flex-1 rounded-[0.5rem] border bg-white px-3 outline-none transition-colors md:h-14;
|
||||
border-color: rgba(23, 32, 51, 0.14);
|
||||
}
|
||||
|
||||
.title-edit-form input:focus {
|
||||
border-color: #0f766e;
|
||||
box-shadow: 0 0 0 3px rgba(15, 118, 110, 0.12);
|
||||
}
|
||||
|
||||
.icon-action {
|
||||
@apply inline-flex size-9 flex-shrink-0 items-center justify-center rounded-[0.5rem] transition-colors;
|
||||
background: #172033;
|
||||
color: #fffaf2;
|
||||
}
|
||||
|
||||
.icon-action.secondary {
|
||||
background: rgba(23, 32, 51, 0.06);
|
||||
color: #172033;
|
||||
}
|
||||
|
||||
.icon-action:hover:not(:disabled) {
|
||||
background: #0f766e;
|
||||
color: #fffaf2;
|
||||
}
|
||||
|
||||
.icon-action:disabled {
|
||||
@apply cursor-not-allowed opacity-60;
|
||||
}
|
||||
|
||||
.hero-status {
|
||||
@apply flex min-h-5 flex-col;
|
||||
}
|
||||
|
||||
.settings-page {
|
||||
@apply flex flex-col gap-5;
|
||||
}
|
||||
@@ -305,17 +590,15 @@
|
||||
}
|
||||
|
||||
.content-card {
|
||||
@apply rounded-[0.75rem] border p-5;
|
||||
@apply flex flex-col gap-4 rounded-[0.75rem] border p-5;
|
||||
background: rgba(255, 255, 255, 0.94);
|
||||
border-color: rgba(23, 32, 51, 0.08);
|
||||
}
|
||||
|
||||
.detail-list,
|
||||
.table-list {
|
||||
@apply flex flex-col gap-2;
|
||||
}
|
||||
|
||||
.detail-list div,
|
||||
.table-row {
|
||||
@apply flex items-center justify-between gap-4 rounded-[0.75rem] px-4 py-3;
|
||||
background: rgba(23, 32, 51, 0.04);
|
||||
@@ -337,7 +620,6 @@
|
||||
@apply flex min-w-0 flex-col;
|
||||
}
|
||||
|
||||
.detail-list strong,
|
||||
.table-row strong,
|
||||
.placeholder-panel strong {
|
||||
@apply font-semibold;
|
||||
@@ -349,33 +631,120 @@
|
||||
color: #c2410c;
|
||||
}
|
||||
|
||||
.permissions-panel,
|
||||
.placeholder-panel,
|
||||
.empty-state {
|
||||
@apply rounded-[0.75rem] px-4 py-4;
|
||||
background: rgba(23, 32, 51, 0.04);
|
||||
}
|
||||
|
||||
.permissions-panel {
|
||||
@apply flex-col items-start gap-3;
|
||||
.settings-form {
|
||||
@apply flex flex-col gap-4 rounded-[0.75rem] p-4;
|
||||
background: rgba(23, 32, 51, 0.04);
|
||||
}
|
||||
|
||||
.usage-plan span,
|
||||
.usage-row-heading span {
|
||||
@apply text-sm;
|
||||
color: #526178;
|
||||
}
|
||||
|
||||
.field-error {
|
||||
color: #991b1b !important;
|
||||
}
|
||||
|
||||
.field-success {
|
||||
color: #0f766e !important;
|
||||
}
|
||||
|
||||
.invite-form {
|
||||
@apply md:grid md:grid-cols-[minmax(0,1fr)_14rem_auto] md:items-end;
|
||||
}
|
||||
|
||||
.settings-form label {
|
||||
@apply flex min-w-0 flex-col gap-2 text-sm font-semibold;
|
||||
color: #172033;
|
||||
}
|
||||
|
||||
.settings-form input,
|
||||
.settings-form select {
|
||||
@apply h-11 w-full rounded-[0.5rem] border px-3 text-sm outline-none transition-colors;
|
||||
background: #ffffff;
|
||||
border-color: rgba(23, 32, 51, 0.14);
|
||||
color: #172033;
|
||||
}
|
||||
|
||||
.settings-form input:focus,
|
||||
.settings-form select:focus {
|
||||
border-color: #0f766e;
|
||||
box-shadow: 0 0 0 3px rgba(15, 118, 110, 0.12);
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
@apply flex justify-end;
|
||||
}
|
||||
|
||||
.primary-action {
|
||||
@apply inline-flex h-11 items-center justify-center rounded-[0.5rem] px-4 text-sm font-bold transition-colors;
|
||||
background: #172033;
|
||||
color: #fffaf2;
|
||||
}
|
||||
|
||||
.primary-action:hover:not(:disabled) {
|
||||
background: #0f766e;
|
||||
}
|
||||
|
||||
.primary-action:disabled {
|
||||
@apply cursor-not-allowed opacity-60;
|
||||
}
|
||||
|
||||
.settings-alert {
|
||||
@apply rounded-[0.5rem] px-4 py-3 text-sm font-semibold;
|
||||
}
|
||||
|
||||
.settings-alert.error {
|
||||
background: rgba(185, 28, 28, 0.1);
|
||||
color: #991b1b;
|
||||
}
|
||||
|
||||
.settings-alert.success {
|
||||
background: rgba(15, 118, 110, 0.12);
|
||||
color: #0f766e;
|
||||
}
|
||||
|
||||
.placeholder-panel {
|
||||
@apply flex flex-col gap-1;
|
||||
}
|
||||
|
||||
.permission-grid {
|
||||
@apply flex flex-wrap gap-2;
|
||||
.usage-list {
|
||||
@apply flex flex-col gap-3;
|
||||
}
|
||||
|
||||
.permission-grid span {
|
||||
@apply rounded-full px-3 py-2 text-xs font-bold;
|
||||
background: rgba(23, 32, 51, 0.06);
|
||||
color: #526178;
|
||||
.usage-plan,
|
||||
.usage-row {
|
||||
@apply rounded-[0.75rem] p-4;
|
||||
background: rgba(23, 32, 51, 0.04);
|
||||
}
|
||||
|
||||
.permission-grid span.enabled {
|
||||
background: rgba(15, 118, 110, 0.12);
|
||||
color: #0f766e;
|
||||
.usage-plan {
|
||||
@apply flex items-center justify-between gap-4;
|
||||
}
|
||||
|
||||
.usage-row {
|
||||
@apply flex flex-col gap-3;
|
||||
}
|
||||
|
||||
.usage-row-heading {
|
||||
@apply flex items-center justify-between gap-4;
|
||||
}
|
||||
|
||||
.usage-meter {
|
||||
@apply h-2 overflow-hidden rounded-full;
|
||||
background: rgba(23, 32, 51, 0.1);
|
||||
}
|
||||
|
||||
.usage-meter span {
|
||||
@apply block h-full rounded-full;
|
||||
background: #0f766e;
|
||||
}
|
||||
|
||||
</style>
|
||||
|
||||
@@ -11,6 +11,9 @@ export const useUserProfileStore = defineStore(
|
||||
const authStore = useAuthStore()
|
||||
const isUpdating = ref(false)
|
||||
const isUploadingPortrait = ref(false)
|
||||
const isLoadingCalendarFeed = ref(false)
|
||||
const isUpdatingCalendarFeed = ref(false)
|
||||
const calendarExportFeed = ref(null)
|
||||
const error = ref(null)
|
||||
|
||||
const authWatcher = watch(
|
||||
@@ -18,8 +21,10 @@ export const useUserProfileStore = defineStore(
|
||||
async (newValue) => {
|
||||
if (newValue) {
|
||||
await fetchCurrentUserProfile()
|
||||
await fetchCalendarExportFeed()
|
||||
} else if (!authStore.isRefreshing) {
|
||||
value.value = undefined
|
||||
calendarExportFeed.value = null
|
||||
}
|
||||
})
|
||||
|
||||
@@ -202,6 +207,56 @@ export const useUserProfileStore = defineStore(
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchCalendarExportFeed() {
|
||||
if (!authStore.isAuthenticated) {
|
||||
calendarExportFeed.value = null
|
||||
return
|
||||
}
|
||||
|
||||
isLoadingCalendarFeed.value = true
|
||||
|
||||
try {
|
||||
const client = useClient()
|
||||
const response = await client.get('/api/calendar-integrations/export-feed')
|
||||
calendarExportFeed.value = response.data
|
||||
} catch (fetchError) {
|
||||
console.error(fetchError)
|
||||
error.value = 'Failed to load calendar export feed.'
|
||||
} finally {
|
||||
isLoadingCalendarFeed.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function enableCalendarExportFeed() {
|
||||
return updateCalendarExportFeed('/api/calendar-integrations/export-feed/enable', 'post')
|
||||
}
|
||||
|
||||
async function regenerateCalendarExportFeed() {
|
||||
return updateCalendarExportFeed('/api/calendar-integrations/export-feed/regenerate', 'post')
|
||||
}
|
||||
|
||||
async function revokeCalendarExportFeed() {
|
||||
return updateCalendarExportFeed('/api/calendar-integrations/export-feed', 'delete')
|
||||
}
|
||||
|
||||
async function updateCalendarExportFeed(url, method) {
|
||||
isUpdatingCalendarFeed.value = true
|
||||
error.value = null
|
||||
|
||||
try {
|
||||
const client = useClient()
|
||||
const response = await client[method](url)
|
||||
calendarExportFeed.value = response.data
|
||||
return response.data
|
||||
} catch (updateError) {
|
||||
console.error(updateError)
|
||||
error.value = 'Failed to update calendar export feed.'
|
||||
throw updateError
|
||||
} finally {
|
||||
isUpdatingCalendarFeed.value = false
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
user: value,
|
||||
alias,
|
||||
@@ -209,6 +264,9 @@ export const useUserProfileStore = defineStore(
|
||||
portraitUrl,
|
||||
isUpdating,
|
||||
isUploadingPortrait,
|
||||
isLoadingCalendarFeed,
|
||||
isUpdatingCalendarFeed,
|
||||
calendarExportFeed,
|
||||
error,
|
||||
roles,
|
||||
persona,
|
||||
@@ -221,6 +279,10 @@ export const useUserProfileStore = defineStore(
|
||||
changePhone,
|
||||
changeEmail,
|
||||
changeAddress,
|
||||
changePortrait
|
||||
changePortrait,
|
||||
fetchCalendarExportFeed,
|
||||
enableCalendarExportFeed,
|
||||
regenerateCalendarExportFeed,
|
||||
revokeCalendarExportFeed
|
||||
}
|
||||
})
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
import AppAvatar from '@/components/AppAvatar.vue';
|
||||
import ImageCropperDialog from '@/components/ImageCropperDialog.vue';
|
||||
import { useUserProfileStore } from '@/features/user-profile/stores/userProfileStore.js';
|
||||
import config from '@/config.js';
|
||||
|
||||
const userProfileStore = useUserProfileStore();
|
||||
const { t } = useI18n();
|
||||
@@ -11,6 +12,8 @@
|
||||
const isSavingPortrait = ref(false);
|
||||
const settingsError = ref(null);
|
||||
const settingsStatus = ref(null);
|
||||
const calendarFeedStatus = ref(null);
|
||||
const calendarFeedError = ref(null);
|
||||
const form = reactive({
|
||||
firstname: '',
|
||||
lastname: '',
|
||||
@@ -22,6 +25,17 @@
|
||||
const alias = computed(() => userProfileStore.alias);
|
||||
const fullname = computed(() => userProfileStore.fullname);
|
||||
const canSave = computed(() => Boolean(form.email.trim()) && !userProfileStore.isUpdating);
|
||||
const calendarFeedUrl = computed(() => {
|
||||
const feedUrl = userProfileStore.calendarExportFeed?.feedUrl;
|
||||
|
||||
if (!feedUrl) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return feedUrl.startsWith('http')
|
||||
? feedUrl
|
||||
: `${config.apiUrl.replace(/\/$/, '')}${feedUrl}`;
|
||||
});
|
||||
|
||||
function syncFormFromUser(user) {
|
||||
form.firstname = user?.firstname ?? '';
|
||||
@@ -84,11 +98,54 @@
|
||||
}
|
||||
}
|
||||
|
||||
async function enableCalendarFeed() {
|
||||
await updateCalendarFeed(() => userProfileStore.enableCalendarExportFeed(), t('userSettings.calendarFeed.enabled'));
|
||||
}
|
||||
|
||||
async function regenerateCalendarFeed() {
|
||||
await updateCalendarFeed(() => userProfileStore.regenerateCalendarExportFeed(), t('userSettings.calendarFeed.regenerated'));
|
||||
}
|
||||
|
||||
async function revokeCalendarFeed() {
|
||||
await updateCalendarFeed(() => userProfileStore.revokeCalendarExportFeed(), t('userSettings.calendarFeed.revoked'));
|
||||
}
|
||||
|
||||
async function copyCalendarFeedUrl() {
|
||||
if (!calendarFeedUrl.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await navigator.clipboard.writeText(calendarFeedUrl.value);
|
||||
calendarFeedStatus.value = t('userSettings.calendarFeed.copied');
|
||||
calendarFeedError.value = null;
|
||||
} catch (error) {
|
||||
console.error('Failed to copy calendar feed URL:', error);
|
||||
calendarFeedStatus.value = null;
|
||||
calendarFeedError.value = t('userSettings.calendarFeed.errors.copyFailed');
|
||||
}
|
||||
}
|
||||
|
||||
async function updateCalendarFeed(action, successMessage) {
|
||||
calendarFeedStatus.value = null;
|
||||
calendarFeedError.value = null;
|
||||
|
||||
try {
|
||||
await action();
|
||||
calendarFeedStatus.value = successMessage;
|
||||
} catch (error) {
|
||||
console.error('Failed to update calendar feed:', error);
|
||||
calendarFeedError.value = t('userSettings.calendarFeed.errors.updateFailed');
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => userProfileStore.user,
|
||||
syncFormFromUser,
|
||||
{ immediate: true, deep: true }
|
||||
);
|
||||
|
||||
userProfileStore.fetchCalendarExportFeed();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -201,6 +258,81 @@
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="panel">
|
||||
<div class="panel-heading">
|
||||
<strong>{{ t('userSettings.calendarFeed.title') }}</strong>
|
||||
<span>{{ t('userSettings.calendarFeed.description') }}</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="calendarFeedError"
|
||||
class="page-message error"
|
||||
>
|
||||
{{ calendarFeedError }}
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="calendarFeedStatus"
|
||||
class="page-message success"
|
||||
>
|
||||
{{ calendarFeedStatus }}
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="userProfileStore.calendarExportFeed?.isEnabled && calendarFeedUrl"
|
||||
class="calendar-feed-box"
|
||||
>
|
||||
<span>{{ t('userSettings.calendarFeed.feedUrl') }}</span>
|
||||
<code>{{ calendarFeedUrl }}</code>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else
|
||||
class="calendar-feed-empty"
|
||||
>
|
||||
{{ t('userSettings.calendarFeed.empty') }}
|
||||
</div>
|
||||
|
||||
<div class="calendar-feed-actions">
|
||||
<button
|
||||
v-if="!userProfileStore.calendarExportFeed?.isEnabled"
|
||||
class="primary-button"
|
||||
type="button"
|
||||
:disabled="userProfileStore.isUpdatingCalendarFeed"
|
||||
@click="enableCalendarFeed"
|
||||
>
|
||||
{{ t('userSettings.calendarFeed.enable') }}
|
||||
</button>
|
||||
|
||||
<template v-else>
|
||||
<button
|
||||
class="secondary-button"
|
||||
type="button"
|
||||
:disabled="!calendarFeedUrl"
|
||||
@click="copyCalendarFeedUrl"
|
||||
>
|
||||
{{ t('userSettings.calendarFeed.copy') }}
|
||||
</button>
|
||||
<button
|
||||
class="secondary-button"
|
||||
type="button"
|
||||
:disabled="userProfileStore.isUpdatingCalendarFeed"
|
||||
@click="regenerateCalendarFeed"
|
||||
>
|
||||
{{ t('userSettings.calendarFeed.regenerate') }}
|
||||
</button>
|
||||
<button
|
||||
class="danger-button"
|
||||
type="button"
|
||||
:disabled="userProfileStore.isUpdatingCalendarFeed"
|
||||
@click="revokeCalendarFeed"
|
||||
>
|
||||
{{ t('userSettings.calendarFeed.revoke') }}
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ImageCropperDialog
|
||||
v-model="isPortraitDialogOpen"
|
||||
:title="t('userSettings.cropperTitle')"
|
||||
@@ -318,8 +450,47 @@
|
||||
color: #fffaf2;
|
||||
}
|
||||
|
||||
.primary-button:disabled {
|
||||
.secondary-button,
|
||||
.danger-button {
|
||||
@apply inline-flex items-center justify-center gap-2 rounded-full px-5 py-3 text-sm font-bold transition;
|
||||
}
|
||||
|
||||
.secondary-button {
|
||||
background: rgba(23, 32, 51, 0.06);
|
||||
color: #172033;
|
||||
}
|
||||
|
||||
.danger-button {
|
||||
background: rgba(185, 28, 28, 0.08);
|
||||
color: #b91c1c;
|
||||
}
|
||||
|
||||
.primary-button:disabled,
|
||||
.secondary-button:disabled,
|
||||
.danger-button:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
.calendar-feed-box {
|
||||
@apply flex flex-col gap-2 rounded-[1rem] border p-4;
|
||||
background: #fffaf2;
|
||||
border-color: rgba(23, 32, 51, 0.08);
|
||||
}
|
||||
|
||||
.calendar-feed-box span,
|
||||
.calendar-feed-empty {
|
||||
@apply text-sm leading-6;
|
||||
color: #526178;
|
||||
}
|
||||
|
||||
.calendar-feed-box code {
|
||||
@apply overflow-x-auto rounded-[0.75rem] px-3 py-2 text-sm;
|
||||
background: rgba(23, 32, 51, 0.06);
|
||||
color: #172033;
|
||||
}
|
||||
|
||||
.calendar-feed-actions {
|
||||
@apply flex flex-wrap gap-3;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -69,6 +69,33 @@
|
||||
{ key: 'workflow', label: t('workspaceSettings.tabs.workflow'), icon: mdiTuneVariant },
|
||||
{ key: 'connectors', label: t('workspaceSettings.tabs.connectors'), icon: mdiFolderGoogleDrive },
|
||||
]);
|
||||
const activeTabDetail = computed(() => {
|
||||
if (activeTab.value === 'members') {
|
||||
return {
|
||||
title: t('workspaceSettings.tabs.members'),
|
||||
description: t('workspaceSettings.inviteDescription'),
|
||||
};
|
||||
}
|
||||
|
||||
if (activeTab.value === 'workflow') {
|
||||
return {
|
||||
title: t('workspaceSettings.tabs.workflow'),
|
||||
description: t('workspaceSettings.approvals.flowDescription'),
|
||||
};
|
||||
}
|
||||
|
||||
if (activeTab.value === 'connectors') {
|
||||
return {
|
||||
title: t('workspaceSettings.tabs.connectors'),
|
||||
description: t('workspaceSettings.connectors.description'),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
title: t('workspaceSettings.tabs.general'),
|
||||
description: t('workspaceSettings.general.detailsDescription'),
|
||||
};
|
||||
});
|
||||
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') },
|
||||
@@ -380,8 +407,16 @@
|
||||
<h1>{{ workspaceStore.activeWorkspace?.name || t('workspaceSettings.noWorkspaceSelected') }}</h1>
|
||||
<p>{{ t('workspaceSettings.description') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="tab-strip">
|
||||
<div
|
||||
v-if="workspaceStore.activeWorkspace"
|
||||
class="workspace-settings-page"
|
||||
>
|
||||
<nav
|
||||
class="tab-strip"
|
||||
aria-label="Workspace settings sections"
|
||||
>
|
||||
<button
|
||||
v-for="tab in settingsTabs"
|
||||
:key="tab.key"
|
||||
@@ -393,38 +428,42 @@
|
||||
<v-icon :icon="tab.icon" />
|
||||
<span>{{ tab.label }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<div
|
||||
v-if="activeTab === 'general'"
|
||||
class="workspace-settings-grid workspace-settings-grid-single"
|
||||
>
|
||||
<article class="settings-card">
|
||||
<div class="section-copy">
|
||||
<span class="section-kicker">{{ t('workspaceSettings.general.detailsTitle') }}</span>
|
||||
<p>{{ t('workspaceSettings.general.detailsDescription') }}</p>
|
||||
<div class="tab-content">
|
||||
<div class="tab-heading">
|
||||
<h2>{{ activeTabDetail.title }}</h2>
|
||||
<p>{{ activeTabDetail.description }}</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="settingsError"
|
||||
class="page-message error"
|
||||
v-if="activeTab === 'general'"
|
||||
class="workspace-settings-grid workspace-settings-grid-single"
|
||||
>
|
||||
{{ settingsError }}
|
||||
</div>
|
||||
<article class="settings-card">
|
||||
<div class="section-copy">
|
||||
<span class="section-kicker">{{ t('workspaceSettings.general.detailsTitle') }}</span>
|
||||
<p>{{ t('workspaceSettings.general.detailsDescription') }}</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="settingsStatus"
|
||||
class="page-message success"
|
||||
>
|
||||
{{ settingsStatus }}
|
||||
</div>
|
||||
<div
|
||||
v-if="settingsError"
|
||||
class="page-message error"
|
||||
>
|
||||
{{ settingsError }}
|
||||
</div>
|
||||
|
||||
<form
|
||||
v-if="workspaceStore.activeWorkspace"
|
||||
class="form-stack"
|
||||
@submit.prevent="submitWorkspaceSettings"
|
||||
>
|
||||
<div
|
||||
v-if="settingsStatus"
|
||||
class="page-message success"
|
||||
>
|
||||
{{ settingsStatus }}
|
||||
</div>
|
||||
|
||||
<form
|
||||
class="form-stack"
|
||||
@submit.prevent="submitWorkspaceSettings"
|
||||
>
|
||||
<div class="logo-picker-card">
|
||||
<AppAvatar
|
||||
:name="settingsForm.name || workspaceStore.activeWorkspace.name"
|
||||
@@ -481,22 +520,16 @@
|
||||
>
|
||||
{{ workspaceStore.isUpdating ? t('common.saving') : t('workspaceSettings.general.saveAction') }}
|
||||
</button>
|
||||
</form>
|
||||
</form>
|
||||
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else
|
||||
class="empty-state"
|
||||
v-else-if="activeTab === 'members'"
|
||||
class="workspace-settings-grid workspace-settings-grid-single"
|
||||
>
|
||||
{{ t('workspaceSettings.noWorkspaceSelected') }}
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else-if="activeTab === 'members'"
|
||||
class="workspace-settings-grid workspace-settings-grid-single"
|
||||
>
|
||||
<article class="settings-card">
|
||||
<article class="settings-card">
|
||||
<div class="section-copy">
|
||||
<span class="section-kicker">{{ t('workspaceSettings.members.inviteTitle') }}</span>
|
||||
<p>{{ t('workspaceSettings.inviteDescription') }}</p>
|
||||
@@ -530,9 +563,9 @@
|
||||
{{ workspaceStore.isInviting ? t('common.creating') : t('workspaceSettings.sendInvite') }}
|
||||
</button>
|
||||
</form>
|
||||
</article>
|
||||
</article>
|
||||
|
||||
<article class="settings-card">
|
||||
<article class="settings-card">
|
||||
<div class="section-copy">
|
||||
<span class="section-kicker">{{ t('workspaceSettings.members.pendingTitle') }}</span>
|
||||
<p>{{ t('workspaceSettings.members.pendingDescription') }}</p>
|
||||
@@ -568,9 +601,9 @@
|
||||
>
|
||||
{{ t('workspaceSettings.inviteEmpty') }}
|
||||
</div>
|
||||
</article>
|
||||
</article>
|
||||
|
||||
<article class="settings-card">
|
||||
<article class="settings-card">
|
||||
<div class="section-copy">
|
||||
<span class="section-kicker">{{ t('workspaceSettings.members.activeTitle') }}</span>
|
||||
<p>{{ t('workspaceSettings.members.activeDescription') }}</p>
|
||||
@@ -606,14 +639,14 @@
|
||||
>
|
||||
{{ t('workspaceSettings.members.activeEmpty') }}
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else-if="activeTab === 'workflow'"
|
||||
class="workflow-grid"
|
||||
>
|
||||
<article class="settings-card">
|
||||
<div
|
||||
v-else-if="activeTab === 'workflow'"
|
||||
class="workflow-grid"
|
||||
>
|
||||
<article class="settings-card">
|
||||
<div class="section-copy">
|
||||
<span class="section-kicker">{{ t('workspaceSettings.approvals.flowTitle') }}</span>
|
||||
<p>{{ t('workspaceSettings.approvals.flowDescription') }}</p>
|
||||
@@ -709,9 +742,9 @@
|
||||
{{ workspaceStore.isUpdating ? t('common.saving') : t('workspaceSettings.approvals.saveAction') }}
|
||||
</button>
|
||||
</div>
|
||||
</article>
|
||||
</article>
|
||||
|
||||
<article class="settings-card">
|
||||
<article class="settings-card">
|
||||
<div class="section-copy">
|
||||
<span class="section-kicker">{{ t('workspaceSettings.approvals.previewTitle') }}</span>
|
||||
<p>{{ t('workspaceSettings.approvals.previewDescription') }}</p>
|
||||
@@ -732,14 +765,14 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else
|
||||
class="workspace-settings-grid"
|
||||
>
|
||||
<article class="settings-card">
|
||||
<div
|
||||
v-else
|
||||
class="workspace-settings-grid"
|
||||
>
|
||||
<article class="settings-card">
|
||||
<div class="section-copy">
|
||||
<span class="section-kicker">{{ t('workspaceSettings.connectors.title') }}</span>
|
||||
<p>{{ t('workspaceSettings.connectors.description') }}</p>
|
||||
@@ -771,7 +804,16 @@
|
||||
<v-icon :icon="mdiImageMultipleOutline" />
|
||||
<span>{{ t('workspaceSettings.connectors.openMediaLibrary') }}</span>
|
||||
</router-link>
|
||||
</article>
|
||||
</article>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else
|
||||
class="empty-state"
|
||||
>
|
||||
{{ t('workspaceSettings.noWorkspaceSelected') }}
|
||||
</div>
|
||||
|
||||
<ImageCropperDialog
|
||||
@@ -792,11 +834,12 @@
|
||||
}
|
||||
|
||||
.workspace-settings-hero {
|
||||
@apply flex flex-col gap-5 rounded-[1.75rem] border p-5 md:p-6;
|
||||
background:
|
||||
radial-gradient(circle at top left, rgba(15, 118, 110, 0.16), transparent 38%),
|
||||
linear-gradient(135deg, rgba(255, 255, 255, 0.98), rgba(248, 250, 252, 0.94));
|
||||
border-color: rgba(23, 32, 51, 0.08);
|
||||
@apply flex flex-col;
|
||||
}
|
||||
|
||||
.workspace-settings-page,
|
||||
.tab-content {
|
||||
@apply flex flex-col gap-4;
|
||||
}
|
||||
|
||||
.workspace-settings-grid {
|
||||
@@ -812,10 +855,9 @@
|
||||
}
|
||||
|
||||
.settings-card {
|
||||
@apply flex flex-col gap-5 rounded-[1.75rem] border p-5;
|
||||
background: rgba(255, 255, 255, 0.92);
|
||||
@apply flex flex-col gap-5 rounded-[0.75rem] border p-5;
|
||||
background: rgba(255, 255, 255, 0.94);
|
||||
border-color: rgba(23, 32, 51, 0.08);
|
||||
box-shadow: 0 18px 40px rgba(23, 32, 51, 0.06);
|
||||
}
|
||||
|
||||
.section-copy {
|
||||
@@ -823,26 +865,45 @@
|
||||
}
|
||||
|
||||
.tab-strip {
|
||||
@apply flex flex-wrap gap-3;
|
||||
@apply flex flex-wrap gap-2 border-b pb-3;
|
||||
border-color: rgba(23, 32, 51, 0.1);
|
||||
}
|
||||
|
||||
.tab-button {
|
||||
@apply inline-flex items-center gap-3 rounded-full px-4 py-3 text-sm font-semibold transition;
|
||||
background: rgba(23, 32, 51, 0.06);
|
||||
@apply inline-flex h-10 items-center gap-2 rounded-[0.75rem] px-3 text-sm font-semibold transition-colors;
|
||||
color: #526178;
|
||||
}
|
||||
|
||||
.tab-button:hover {
|
||||
background: rgba(23, 32, 51, 0.06);
|
||||
color: #172033;
|
||||
}
|
||||
|
||||
.tab-button-active {
|
||||
background: #172033;
|
||||
color: #fffaf2;
|
||||
}
|
||||
|
||||
.tab-button :deep(.v-icon) {
|
||||
@apply text-lg;
|
||||
}
|
||||
|
||||
.tab-heading {
|
||||
@apply flex flex-col gap-1;
|
||||
}
|
||||
|
||||
.tab-heading h2 {
|
||||
@apply text-2xl font-black;
|
||||
color: #172033;
|
||||
}
|
||||
|
||||
.section-kicker {
|
||||
@apply text-xs font-bold uppercase tracking-[0.2em];
|
||||
color: #0f766e;
|
||||
color: #c2410c;
|
||||
}
|
||||
|
||||
.section-copy h1,
|
||||
.tab-heading h2,
|
||||
.invite-row strong,
|
||||
.connector-copy strong,
|
||||
.connector-status,
|
||||
@@ -852,10 +913,11 @@
|
||||
}
|
||||
|
||||
.section-copy h1 {
|
||||
@apply text-3xl font-black;
|
||||
@apply text-3xl font-black md:text-4xl;
|
||||
}
|
||||
|
||||
.section-copy p,
|
||||
.tab-heading p,
|
||||
.invite-row span,
|
||||
.invite-row small,
|
||||
.empty-state,
|
||||
@@ -868,8 +930,8 @@
|
||||
}
|
||||
|
||||
.logo-picker-card {
|
||||
@apply flex flex-col gap-4 rounded-[1rem] border p-4 sm:flex-row sm:items-center;
|
||||
background: #fffaf2;
|
||||
@apply flex flex-col gap-4 rounded-[0.75rem] border p-4 sm:flex-row sm:items-center;
|
||||
background: rgba(23, 32, 51, 0.04);
|
||||
border-color: rgba(23, 32, 51, 0.08);
|
||||
}
|
||||
|
||||
@@ -910,25 +972,40 @@
|
||||
|
||||
.field input,
|
||||
.field select {
|
||||
@apply rounded-[1rem] border px-4 py-3 text-sm;
|
||||
background: #fffdf8;
|
||||
@apply h-11 rounded-[0.5rem] border px-3 text-sm outline-none transition-colors;
|
||||
background: #ffffff;
|
||||
border-color: rgba(23, 32, 51, 0.1);
|
||||
color: #172033;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.field input:focus,
|
||||
.field select:focus {
|
||||
border-color: #0f766e;
|
||||
box-shadow: 0 0 0 3px rgba(15, 118, 110, 0.12);
|
||||
}
|
||||
|
||||
.primary-button {
|
||||
@apply inline-flex items-center justify-center rounded-full px-5 py-3 text-sm font-semibold;
|
||||
@apply inline-flex h-11 items-center justify-center rounded-[0.5rem] px-4 text-sm font-bold transition-colors;
|
||||
background: #172033;
|
||||
color: #fffaf2;
|
||||
}
|
||||
|
||||
.secondary-button {
|
||||
@apply inline-flex items-center justify-center rounded-full px-4 py-2 text-sm font-semibold;
|
||||
background: rgba(23, 32, 51, 0.08);
|
||||
@apply inline-flex h-11 items-center justify-center rounded-[0.5rem] border px-4 text-sm font-bold transition-colors;
|
||||
background: #ffffff;
|
||||
border-color: rgba(23, 32, 51, 0.14);
|
||||
color: #172033;
|
||||
}
|
||||
|
||||
.primary-button:hover:not(:disabled) {
|
||||
background: #0f766e;
|
||||
}
|
||||
|
||||
.secondary-button:hover:not(:disabled) {
|
||||
border-color: #0f766e;
|
||||
color: #0f766e;
|
||||
}
|
||||
|
||||
.primary-button:disabled,
|
||||
.secondary-button:disabled {
|
||||
cursor: not-allowed;
|
||||
@@ -948,8 +1025,8 @@
|
||||
.workflow-rule,
|
||||
.workflow-toggle,
|
||||
.workflow-step {
|
||||
@apply rounded-[1rem] border px-4 py-4;
|
||||
background: #fffaf2;
|
||||
@apply rounded-[0.75rem] border px-4 py-4;
|
||||
background: rgba(23, 32, 51, 0.04);
|
||||
border-color: rgba(23, 32, 51, 0.08);
|
||||
}
|
||||
|
||||
@@ -984,7 +1061,7 @@
|
||||
|
||||
.connector-icon,
|
||||
.workflow-step-icon {
|
||||
@apply inline-flex h-11 w-11 flex-shrink-0 items-center justify-center rounded-2xl;
|
||||
@apply inline-flex h-11 w-11 flex-shrink-0 items-center justify-center rounded-[0.75rem];
|
||||
background: rgba(15, 118, 110, 0.1);
|
||||
color: #0f766e;
|
||||
}
|
||||
@@ -995,12 +1072,12 @@
|
||||
}
|
||||
|
||||
.connector-link {
|
||||
@apply inline-flex w-fit items-center gap-3 rounded-full px-5 py-3 text-sm font-semibold no-underline transition;
|
||||
@apply inline-flex h-11 w-fit items-center gap-3 rounded-[0.5rem] px-4 text-sm font-bold no-underline transition;
|
||||
background: #172033;
|
||||
color: #fffaf2;
|
||||
}
|
||||
|
||||
.connector-link:hover {
|
||||
background: #0f172a;
|
||||
background: #0f766e;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -267,7 +267,11 @@
|
||||
type="button"
|
||||
@click="openOrganizationSettings(activeOrganization.id)"
|
||||
>
|
||||
<span class="organization-mark">{{ activeOrganization.name.slice(0, 1).toUpperCase() }}</span>
|
||||
<AppAvatar
|
||||
:name="activeOrganization.name"
|
||||
:src="activeOrganization.logoUrl"
|
||||
size="sm"
|
||||
/>
|
||||
<span class="user-menu-item-copy">
|
||||
<span>{{ activeOrganizationName }}</span>
|
||||
<small>{{ t('workspaceSelector.organizationLabel') }}</small>
|
||||
@@ -301,7 +305,11 @@
|
||||
type="button"
|
||||
@click="chooseOrganization(organization.id)"
|
||||
>
|
||||
<span class="organization-mark">{{ organization.name.slice(0, 1).toUpperCase() }}</span>
|
||||
<AppAvatar
|
||||
:name="organization.name"
|
||||
:src="organization.logoUrl"
|
||||
size="sm"
|
||||
/>
|
||||
<span class="user-menu-item-copy">
|
||||
<span>{{ organization.name }}</span>
|
||||
<small>{{ t('workspaceSelector.organizationLabel') }}</small>
|
||||
@@ -464,12 +472,6 @@
|
||||
background: rgba(23, 32, 51, 0.07);
|
||||
}
|
||||
|
||||
.organization-mark {
|
||||
@apply flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-[0.8rem] text-xs font-black;
|
||||
background: rgba(23, 32, 51, 0.08);
|
||||
color: #172033;
|
||||
}
|
||||
|
||||
.organization-action-icon {
|
||||
@apply flex-shrink-0 text-base;
|
||||
color: #526178;
|
||||
|
||||
@@ -392,12 +392,40 @@
|
||||
"organizationSettings": {
|
||||
"eyebrow": "Organization",
|
||||
"title": "Organization settings",
|
||||
"description": "Manage the SaaS account boundary for members, billing access, connections, and owned workspaces.",
|
||||
"description": "Manage the SaaS account boundary for members, usage, billing access, and connections.",
|
||||
"loading": "Loading organization settings...",
|
||||
"saving": "Saving...",
|
||||
"saveProfile": "Save profile",
|
||||
"editName": "Edit organization name",
|
||||
"saveName": "Save organization name",
|
||||
"cancelNameEdit": "Cancel name edit",
|
||||
"profileSaved": "Organization profile saved.",
|
||||
"addMember": "Add member",
|
||||
"addingMember": "Adding...",
|
||||
"memberAdded": "Organization member added.",
|
||||
"logo": {
|
||||
"title": "Organization logo",
|
||||
"description": "Shown in organization settings and switchers.",
|
||||
"changeAction": "Change logo",
|
||||
"chooseAction": "Choose logo",
|
||||
"cropperTitle": "Edit organization logo",
|
||||
"saveAction": "Save logo",
|
||||
"saving": "Saving...",
|
||||
"saved": "Organization logo saved."
|
||||
},
|
||||
"fields": {
|
||||
"name": "Name",
|
||||
"memberEmail": "Member email",
|
||||
"memberRole": "Role",
|
||||
"createdAt": "Created"
|
||||
},
|
||||
"errors": {
|
||||
"nameRequired": "Organization name is required.",
|
||||
"profileSaveFailed": "The organization profile could not be saved.",
|
||||
"memberRequired": "Email and role are required to add a member.",
|
||||
"memberAddFailed": "The organization member could not be added. Existing users can be added by email.",
|
||||
"logoUploadFailed": "The organization logo could not be saved."
|
||||
},
|
||||
"sections": {
|
||||
"profile": {
|
||||
"title": "Profile",
|
||||
@@ -408,6 +436,13 @@
|
||||
"description": "Organization-level users and their inherited account permissions.",
|
||||
"empty": "No organization members found."
|
||||
},
|
||||
"usage": {
|
||||
"title": "Usage",
|
||||
"description": "Current organization usage against plan limits.",
|
||||
"planLabel": "Current plan",
|
||||
"planFallback": "No plan configured",
|
||||
"empty": "No usage data is available yet."
|
||||
},
|
||||
"billing": {
|
||||
"title": "Billing",
|
||||
"description": "Subscription and billing access for this organization.",
|
||||
@@ -426,6 +461,14 @@
|
||||
"empty": "No workspaces belong to this organization yet."
|
||||
}
|
||||
},
|
||||
"usage": {
|
||||
"unlimited": "Unlimited",
|
||||
"items": {
|
||||
"users": "Users",
|
||||
"workspaces": "Workspaces",
|
||||
"activeContent": "Active content"
|
||||
}
|
||||
},
|
||||
"roles": {
|
||||
"Owner": "Owner",
|
||||
"Admin": "Admin",
|
||||
@@ -820,6 +863,35 @@
|
||||
"empty": "No content items are available for the active workspace.",
|
||||
"noDueDate": "No due date",
|
||||
"assetsHelper": "Google Drive assets are now linked from the content item detail page after creation.",
|
||||
"calendar": {
|
||||
"organization": "Organization",
|
||||
"workspace": "Workspace",
|
||||
"mine": "My calendars",
|
||||
"calendars": "Calendars",
|
||||
"noCalendars": "No calendars available.",
|
||||
"addCalendar": "Add calendar",
|
||||
"alreadyAdded": "Already added",
|
||||
"catalog": "Catalog",
|
||||
"customIcs": "Custom ICS",
|
||||
"searchCatalog": "Search calendars",
|
||||
"search": "Search",
|
||||
"country": "Country",
|
||||
"category": "Category",
|
||||
"calendarName": "Calendar name",
|
||||
"icsUrl": "ICS URL",
|
||||
"allDay": "All day",
|
||||
"context": "Calendar context",
|
||||
"importedEvent": "Imported calendar",
|
||||
"errors": {
|
||||
"required": "Calendar name and URL are required.",
|
||||
"duplicate": "This calendar has already been added.",
|
||||
"createFailed": "The calendar could not be added."
|
||||
}
|
||||
},
|
||||
"dateContext": {
|
||||
"noEvents": "No visible calendar events for this date.",
|
||||
"viewDay": "View day"
|
||||
},
|
||||
"errors": {
|
||||
"required": "Title, campaign, message, and targets are required.",
|
||||
"workspaceAccountRequired": "This workspace needs an operational account before content can be created.",
|
||||
@@ -849,6 +921,24 @@
|
||||
"saveDetails": "Save details",
|
||||
"saved": "Profile details saved",
|
||||
"portraitSaved": "Portrait saved",
|
||||
"calendarFeed": {
|
||||
"title": "Private calendar feed",
|
||||
"description": "Subscribe to your Socialize work dates from external calendar apps.",
|
||||
"empty": "The private calendar feed is disabled.",
|
||||
"feedUrl": "Subscription URL",
|
||||
"enable": "Enable feed",
|
||||
"copy": "Copy URL",
|
||||
"regenerate": "Regenerate URL",
|
||||
"revoke": "Revoke feed",
|
||||
"enabled": "Calendar feed enabled",
|
||||
"regenerated": "Calendar feed URL regenerated",
|
||||
"revoked": "Calendar feed revoked",
|
||||
"copied": "Calendar feed URL copied",
|
||||
"errors": {
|
||||
"copyFailed": "The URL could not be copied.",
|
||||
"updateFailed": "The calendar feed could not be updated."
|
||||
}
|
||||
},
|
||||
"alias": "Alias",
|
||||
"firstname": "First name",
|
||||
"lastname": "Last name",
|
||||
|
||||
@@ -392,12 +392,40 @@
|
||||
"organizationSettings": {
|
||||
"eyebrow": "Organisation",
|
||||
"title": "Parametres de l'organisation",
|
||||
"description": "Gerez le compte SaaS pour les membres, la facturation, les connexions et les espaces detenus.",
|
||||
"description": "Gerez le compte SaaS pour les membres, l'utilisation, la facturation et les connexions.",
|
||||
"loading": "Chargement des parametres de l'organisation...",
|
||||
"saving": "Enregistrement...",
|
||||
"saveProfile": "Enregistrer le profil",
|
||||
"editName": "Modifier le nom de l'organisation",
|
||||
"saveName": "Enregistrer le nom de l'organisation",
|
||||
"cancelNameEdit": "Annuler la modification du nom",
|
||||
"profileSaved": "Profil de l'organisation enregistre.",
|
||||
"addMember": "Ajouter un membre",
|
||||
"addingMember": "Ajout...",
|
||||
"memberAdded": "Membre de l'organisation ajoute.",
|
||||
"logo": {
|
||||
"title": "Logo de l'organisation",
|
||||
"description": "Affiche dans les parametres et les selecteurs d'organisation.",
|
||||
"changeAction": "Changer le logo",
|
||||
"chooseAction": "Choisir un logo",
|
||||
"cropperTitle": "Modifier le logo de l'organisation",
|
||||
"saveAction": "Enregistrer le logo",
|
||||
"saving": "Enregistrement...",
|
||||
"saved": "Logo de l'organisation enregistre."
|
||||
},
|
||||
"fields": {
|
||||
"name": "Nom",
|
||||
"memberEmail": "Email du membre",
|
||||
"memberRole": "Role",
|
||||
"createdAt": "Cree"
|
||||
},
|
||||
"errors": {
|
||||
"nameRequired": "Le nom de l'organisation est requis.",
|
||||
"profileSaveFailed": "Le profil de l'organisation n'a pas pu etre enregistre.",
|
||||
"memberRequired": "L'email et le role sont requis pour ajouter un membre.",
|
||||
"memberAddFailed": "Le membre de l'organisation n'a pas pu etre ajoute. Les utilisateurs existants peuvent etre ajoutes par email.",
|
||||
"logoUploadFailed": "Le logo de l'organisation n'a pas pu etre enregistre."
|
||||
},
|
||||
"sections": {
|
||||
"profile": {
|
||||
"title": "Profil",
|
||||
@@ -408,6 +436,13 @@
|
||||
"description": "Utilisateurs de l'organisation et leurs permissions heritees.",
|
||||
"empty": "Aucun membre d'organisation trouve."
|
||||
},
|
||||
"usage": {
|
||||
"title": "Utilisation",
|
||||
"description": "Utilisation actuelle de l'organisation par rapport aux limites du forfait.",
|
||||
"planLabel": "Forfait actuel",
|
||||
"planFallback": "Aucun forfait configure",
|
||||
"empty": "Aucune donnee d'utilisation n'est disponible pour le moment."
|
||||
},
|
||||
"billing": {
|
||||
"title": "Facturation",
|
||||
"description": "Acces a l'abonnement et a la facturation de cette organisation.",
|
||||
@@ -426,6 +461,14 @@
|
||||
"empty": "Aucun espace n'appartient encore a cette organisation."
|
||||
}
|
||||
},
|
||||
"usage": {
|
||||
"unlimited": "Illimite",
|
||||
"items": {
|
||||
"users": "Utilisateurs",
|
||||
"workspaces": "Espaces",
|
||||
"activeContent": "Contenu actif"
|
||||
}
|
||||
},
|
||||
"roles": {
|
||||
"Owner": "Proprietaire",
|
||||
"Admin": "Administrateur",
|
||||
@@ -820,6 +863,35 @@
|
||||
"empty": "Aucun élément de contenu n'est disponible pour l'espace actif.",
|
||||
"noDueDate": "Aucune échéance",
|
||||
"assetsHelper": "Les ressources Google Drive sont maintenant liées depuis la page de détail de l'élément après sa création.",
|
||||
"calendar": {
|
||||
"organization": "Organisation",
|
||||
"workspace": "Espace",
|
||||
"mine": "Mes calendriers",
|
||||
"calendars": "Calendriers",
|
||||
"noCalendars": "Aucun calendrier disponible.",
|
||||
"addCalendar": "Ajouter un calendrier",
|
||||
"alreadyAdded": "Déjà ajouté",
|
||||
"catalog": "Catalogue",
|
||||
"customIcs": "ICS personnalisé",
|
||||
"searchCatalog": "Rechercher des calendriers",
|
||||
"search": "Rechercher",
|
||||
"country": "Pays",
|
||||
"category": "Catégorie",
|
||||
"calendarName": "Nom du calendrier",
|
||||
"icsUrl": "URL ICS",
|
||||
"allDay": "Toute la journée",
|
||||
"context": "Contexte calendrier",
|
||||
"importedEvent": "Calendrier importé",
|
||||
"errors": {
|
||||
"required": "Le nom et l'URL du calendrier sont requis.",
|
||||
"duplicate": "Ce calendrier a déjà été ajouté.",
|
||||
"createFailed": "Le calendrier n'a pas pu être ajouté."
|
||||
}
|
||||
},
|
||||
"dateContext": {
|
||||
"noEvents": "Aucun événement de calendrier visible pour cette date.",
|
||||
"viewDay": "Voir la journée"
|
||||
},
|
||||
"errors": {
|
||||
"required": "Le titre, la campagne, le message et les cibles sont requis.",
|
||||
"workspaceAccountRequired": "Cet espace a besoin d'un compte opérationnel avant de créer du contenu.",
|
||||
@@ -849,6 +921,24 @@
|
||||
"saveDetails": "Enregistrer les détails",
|
||||
"saved": "Informations de profil enregistrées",
|
||||
"portraitSaved": "Portrait enregistré",
|
||||
"calendarFeed": {
|
||||
"title": "Flux calendrier privé",
|
||||
"description": "Abonnez vos apps calendrier externes à vos dates de travail Socialize.",
|
||||
"empty": "Le flux calendrier privé est désactivé.",
|
||||
"feedUrl": "URL d'abonnement",
|
||||
"enable": "Activer le flux",
|
||||
"copy": "Copier l'URL",
|
||||
"regenerate": "Régénérer l'URL",
|
||||
"revoke": "Révoquer le flux",
|
||||
"enabled": "Flux calendrier activé",
|
||||
"regenerated": "URL du flux calendrier régénérée",
|
||||
"revoked": "Flux calendrier révoqué",
|
||||
"copied": "URL du flux calendrier copiée",
|
||||
"errors": {
|
||||
"copyFailed": "L'URL n'a pas pu être copiée.",
|
||||
"updateFailed": "Le flux calendrier n'a pas pu être mis à jour."
|
||||
}
|
||||
},
|
||||
"alias": "Alias",
|
||||
"firstname": "Prénom",
|
||||
"lastname": "Nom",
|
||||
|
||||
Reference in New Issue
Block a user