refactor: organize frontend by feature
This commit is contained in:
208
frontend/src/features/workspaces/stores/workspaceStore.js
Normal file
208
frontend/src/features/workspaces/stores/workspaceStore.js
Normal file
@@ -0,0 +1,208 @@
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import { defineStore } from 'pinia';
|
||||
import { useAuthStore } from '@/features/auth/stores/authStore.js';
|
||||
import { useClient } from '@/plugins/api.js';
|
||||
|
||||
export const useWorkspaceStore = defineStore('workspace', () => {
|
||||
const authStore = useAuthStore();
|
||||
const client = useClient();
|
||||
|
||||
const workspaces = ref([]);
|
||||
const activeWorkspaceId = ref(null);
|
||||
const isLoading = ref(false);
|
||||
const isCreating = ref(false);
|
||||
const invitesByWorkspace = ref({});
|
||||
const membersByWorkspace = ref({});
|
||||
const isInvitesLoading = ref(false);
|
||||
const isMembersLoading = ref(false);
|
||||
const isInviting = ref(false);
|
||||
const error = ref(null);
|
||||
|
||||
const activeWorkspace = computed(() =>
|
||||
workspaces.value.find(workspace => workspace.id === activeWorkspaceId.value) ?? null
|
||||
);
|
||||
|
||||
async function fetchWorkspaces() {
|
||||
if (!authStore.isAuthenticated) {
|
||||
workspaces.value = [];
|
||||
activeWorkspaceId.value = null;
|
||||
error.value = null;
|
||||
return;
|
||||
}
|
||||
|
||||
isLoading.value = true;
|
||||
error.value = null;
|
||||
|
||||
try {
|
||||
const response = await client.get('/api/workspaces');
|
||||
workspaces.value = response.data ?? [];
|
||||
|
||||
if (!workspaces.value.some(workspace => workspace.id === activeWorkspaceId.value)) {
|
||||
activeWorkspaceId.value = workspaces.value[0]?.id ?? null;
|
||||
}
|
||||
} catch (fetchError) {
|
||||
console.error('Failed to fetch workspaces:', fetchError);
|
||||
workspaces.value = [];
|
||||
activeWorkspaceId.value = null;
|
||||
error.value = 'Failed to load workspaces.';
|
||||
} finally {
|
||||
isLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function createWorkspace(payload) {
|
||||
if (!authStore.isAuthenticated) {
|
||||
throw new Error('You must be authenticated to create a workspace.');
|
||||
}
|
||||
|
||||
if (isCreating.value) {
|
||||
throw new Error('A workspace creation request is already in progress.');
|
||||
}
|
||||
|
||||
isCreating.value = true;
|
||||
error.value = null;
|
||||
|
||||
try {
|
||||
const response = await client.post('/api/workspaces', payload);
|
||||
|
||||
if (response.data) {
|
||||
workspaces.value = [...workspaces.value, response.data]
|
||||
.sort((left, right) => left.name.localeCompare(right.name));
|
||||
activeWorkspaceId.value = response.data.id;
|
||||
|
||||
try {
|
||||
await client.post('/api/clients', {
|
||||
workspaceId: response.data.id,
|
||||
name: response.data.name,
|
||||
});
|
||||
} catch (hiddenClientError) {
|
||||
console.error('Failed to provision operational client for workspace:', hiddenClientError);
|
||||
}
|
||||
}
|
||||
|
||||
return response.data;
|
||||
} catch (createError) {
|
||||
console.error('Failed to create workspace:', createError);
|
||||
error.value = 'Failed to create workspace.';
|
||||
throw createError;
|
||||
} finally {
|
||||
isCreating.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function setActiveWorkspace(workspaceId) {
|
||||
if (workspaces.value.some(workspace => workspace.id === workspaceId)) {
|
||||
activeWorkspaceId.value = workspaceId;
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchInvites(workspaceId = activeWorkspaceId.value) {
|
||||
if (!authStore.isAuthenticated || !workspaceId) {
|
||||
invitesByWorkspace.value = {};
|
||||
return [];
|
||||
}
|
||||
|
||||
isInvitesLoading.value = true;
|
||||
|
||||
try {
|
||||
const response = await client.get(`/api/workspaces/${workspaceId}/invites`);
|
||||
invitesByWorkspace.value = {
|
||||
...invitesByWorkspace.value,
|
||||
[workspaceId]: response.data ?? [],
|
||||
};
|
||||
|
||||
return invitesByWorkspace.value[workspaceId];
|
||||
} catch (fetchError) {
|
||||
console.error('Failed to fetch workspace invites:', fetchError);
|
||||
throw fetchError;
|
||||
} finally {
|
||||
isInvitesLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchMembers(workspaceId = activeWorkspaceId.value) {
|
||||
if (!authStore.isAuthenticated || !workspaceId) {
|
||||
membersByWorkspace.value = {};
|
||||
return [];
|
||||
}
|
||||
|
||||
isMembersLoading.value = true;
|
||||
|
||||
try {
|
||||
const response = await client.get(`/api/workspaces/${workspaceId}/members`);
|
||||
membersByWorkspace.value = {
|
||||
...membersByWorkspace.value,
|
||||
[workspaceId]: response.data ?? [],
|
||||
};
|
||||
|
||||
return membersByWorkspace.value[workspaceId];
|
||||
} catch (fetchError) {
|
||||
console.error('Failed to fetch workspace members:', fetchError);
|
||||
throw fetchError;
|
||||
} finally {
|
||||
isMembersLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function inviteMember(payload) {
|
||||
if (!authStore.isAuthenticated || !activeWorkspaceId.value) {
|
||||
throw new Error('You must be authenticated to invite a workspace member.');
|
||||
}
|
||||
|
||||
if (isInviting.value) {
|
||||
throw new Error('A workspace invite request is already in progress.');
|
||||
}
|
||||
|
||||
isInviting.value = true;
|
||||
|
||||
try {
|
||||
const response = await client.post(`/api/workspaces/${activeWorkspaceId.value}/invites`, payload);
|
||||
invitesByWorkspace.value = {
|
||||
...invitesByWorkspace.value,
|
||||
[activeWorkspaceId.value]: [response.data, ...(invitesByWorkspace.value[activeWorkspaceId.value] ?? [])],
|
||||
};
|
||||
|
||||
return response.data;
|
||||
} catch (inviteError) {
|
||||
console.error('Failed to create workspace invite:', inviteError);
|
||||
throw inviteError;
|
||||
} finally {
|
||||
isInviting.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => authStore.isAuthenticated,
|
||||
async isAuthenticated => {
|
||||
if (!isAuthenticated) {
|
||||
workspaces.value = [];
|
||||
activeWorkspaceId.value = null;
|
||||
error.value = null;
|
||||
return;
|
||||
}
|
||||
|
||||
await fetchWorkspaces();
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
return {
|
||||
workspaces,
|
||||
activeWorkspaceId,
|
||||
activeWorkspace,
|
||||
isLoading,
|
||||
isCreating,
|
||||
invitesByWorkspace,
|
||||
membersByWorkspace,
|
||||
isInvitesLoading,
|
||||
isMembersLoading,
|
||||
isInviting,
|
||||
error,
|
||||
fetchWorkspaces,
|
||||
createWorkspace,
|
||||
fetchInvites,
|
||||
fetchMembers,
|
||||
inviteMember,
|
||||
setActiveWorkspace,
|
||||
};
|
||||
});
|
||||
620
frontend/src/features/workspaces/views/DashboardView.vue
Normal file
620
frontend/src/features/workspaces/views/DashboardView.vue
Normal file
@@ -0,0 +1,620 @@
|
||||
<script setup>
|
||||
import { computed, ref } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { mdiChevronLeft, mdiChevronRight } from '@mdi/js';
|
||||
import { useWorkspaceStore } from '@/features/workspaces/stores/workspaceStore.js';
|
||||
import { useProjectsStore } from '@/features/projects/stores/projectsStore.js';
|
||||
import { useContentItemsStore } from '@/features/content/stores/contentItemsStore.js';
|
||||
|
||||
const { t, locale } = useI18n();
|
||||
const workspaceStore = useWorkspaceStore();
|
||||
const projectsStore = useProjectsStore();
|
||||
const contentItemsStore = useContentItemsStore();
|
||||
|
||||
const today = startOfDay(new Date());
|
||||
const viewMode = ref('month');
|
||||
const cursorDate = ref(today);
|
||||
|
||||
const contentStatusMeta = {
|
||||
Draft: { tone: 'production', readiness: 'building' },
|
||||
'In internal review': { tone: 'approval', readiness: 'approval' },
|
||||
'Changes requested internally': { tone: 'risk', readiness: 'rework' },
|
||||
'Internal changes in progress': { tone: 'production', readiness: 'building' },
|
||||
'Ready for client review': { tone: 'approval', readiness: 'approval' },
|
||||
'In client review': { tone: 'approval', readiness: 'approval' },
|
||||
'Changes requested by client': { tone: 'risk', readiness: 'rework' },
|
||||
'Client changes in progress': { tone: 'production', readiness: 'building' },
|
||||
Approved: { tone: 'ready', readiness: 'ready' },
|
||||
'Ready to publish': { tone: 'ready', readiness: 'ready' },
|
||||
Published: { tone: 'published', readiness: 'published' },
|
||||
Rejected: { tone: 'risk', readiness: 'blocked' },
|
||||
Archived: { tone: 'muted', readiness: 'archived' },
|
||||
};
|
||||
|
||||
const contentItemsByProjectId = computed(() => {
|
||||
const grouped = new Map();
|
||||
|
||||
for (const item of contentItemsStore.items) {
|
||||
const existing = grouped.get(item.projectId) ?? [];
|
||||
existing.push(item);
|
||||
grouped.set(item.projectId, existing);
|
||||
}
|
||||
|
||||
return grouped;
|
||||
});
|
||||
|
||||
const calendarEntries = computed(() => {
|
||||
const projectEntries = projectsStore.projects
|
||||
.filter(project => project.endDate || project.startDate)
|
||||
.map(project => buildProjectEntry(project));
|
||||
|
||||
const contentEntries = contentItemsStore.items
|
||||
.filter(item => item.dueDate && item.status !== 'Archived')
|
||||
.map(item => buildContentEntry(item));
|
||||
|
||||
return [...projectEntries, ...contentEntries].sort(sortByDate);
|
||||
});
|
||||
|
||||
const entriesByDay = computed(() => {
|
||||
const grouped = new Map();
|
||||
|
||||
for (const entry of calendarEntries.value) {
|
||||
const existing = grouped.get(entry.dayKey) ?? [];
|
||||
existing.push(entry);
|
||||
grouped.set(entry.dayKey, existing);
|
||||
}
|
||||
|
||||
return grouped;
|
||||
});
|
||||
|
||||
const visibleDays = computed(() => {
|
||||
if (viewMode.value === 'week') {
|
||||
const start = startOfWeek(cursorDate.value);
|
||||
return Array.from({ length: 7 }, (_, index) => {
|
||||
const date = addDays(start, index);
|
||||
|
||||
return buildDay(date, false);
|
||||
});
|
||||
}
|
||||
|
||||
const start = startOfWeek(startOfMonth(cursorDate.value));
|
||||
const end = endOfWeek(endOfMonth(cursorDate.value));
|
||||
const days = [];
|
||||
let current = start;
|
||||
|
||||
while (current <= end) {
|
||||
days.push(buildDay(current, current.getMonth() !== cursorDate.value.getMonth()));
|
||||
current = addDays(current, 1);
|
||||
}
|
||||
|
||||
return days;
|
||||
});
|
||||
|
||||
const weekdayLabels = computed(() => {
|
||||
const base = startOfWeek(cursorDate.value);
|
||||
|
||||
return Array.from({ length: 7 }, (_, index) =>
|
||||
new Intl.DateTimeFormat(locale.value, { weekday: 'short' }).format(addDays(base, index))
|
||||
);
|
||||
});
|
||||
|
||||
const periodLabel = computed(() => {
|
||||
if (viewMode.value === 'week') {
|
||||
const start = startOfWeek(cursorDate.value);
|
||||
const end = addDays(start, 6);
|
||||
const sameMonth = start.getMonth() === end.getMonth() && start.getFullYear() === end.getFullYear();
|
||||
|
||||
if (sameMonth) {
|
||||
return new Intl.DateTimeFormat(locale.value, {
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
}).formatRange(start, end);
|
||||
}
|
||||
|
||||
return new Intl.DateTimeFormat(locale.value, {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
}).formatRange(start, end);
|
||||
}
|
||||
|
||||
return new Intl.DateTimeFormat(locale.value, {
|
||||
month: 'long',
|
||||
year: 'numeric',
|
||||
}).format(cursorDate.value);
|
||||
});
|
||||
|
||||
const isLoading = computed(() =>
|
||||
workspaceStore.isLoading || projectsStore.isLoading || contentItemsStore.isLoading
|
||||
);
|
||||
|
||||
const pageError = computed(() =>
|
||||
workspaceStore.error || projectsStore.error || contentItemsStore.error
|
||||
);
|
||||
|
||||
function buildDay(date, isOutsideMonth) {
|
||||
const key = dateKey(date);
|
||||
|
||||
return {
|
||||
key,
|
||||
date,
|
||||
entries: entriesByDay.value.get(key) ?? [],
|
||||
isOutsideMonth,
|
||||
isToday: key === dateKey(today),
|
||||
};
|
||||
}
|
||||
|
||||
function buildContentEntry(item) {
|
||||
const statusMeta = contentStatusMeta[item.status] ?? { tone: 'production', readiness: 'building' };
|
||||
const project = projectsStore.projects.find(candidate => candidate.id === item.projectId);
|
||||
|
||||
return {
|
||||
id: item.id,
|
||||
type: 'content',
|
||||
title: item.title,
|
||||
subtitle: project?.name ?? t('dashboard.labels.unassignedProject'),
|
||||
scheduledAt: new Date(item.dueDate),
|
||||
dayKey: dateKey(item.dueDate),
|
||||
timeLabel: formatHour(item.dueDate),
|
||||
tone: statusMeta.tone,
|
||||
route: { name: 'content-item-detail', params: { id: item.id } },
|
||||
};
|
||||
}
|
||||
|
||||
function buildProjectEntry(project) {
|
||||
const projectItems = contentItemsByProjectId.value.get(project.id) ?? [];
|
||||
const approvedCount = projectItems.filter(item => ['Approved', 'Ready to publish', 'Published'].includes(item.status)).length;
|
||||
|
||||
return {
|
||||
id: project.id,
|
||||
type: 'project',
|
||||
title: project.name,
|
||||
subtitle: projectItems.length
|
||||
? t('dashboard.projectProgress', { scheduled: projectItems.length, approved: approvedCount })
|
||||
: t('dashboard.readiness.missing'),
|
||||
scheduledAt: new Date(project.endDate ?? project.startDate),
|
||||
dayKey: dateKey(project.endDate ?? project.startDate),
|
||||
timeLabel: t('dashboard.campaignDeadline'),
|
||||
tone: projectItems.length ? 'project' : 'risk',
|
||||
route: { name: 'campaign-detail', params: { projectId: project.id } },
|
||||
};
|
||||
}
|
||||
|
||||
function setView(mode) {
|
||||
viewMode.value = mode;
|
||||
cursorDate.value = mode === 'month' ? startOfMonth(cursorDate.value) : startOfWeek(cursorDate.value);
|
||||
}
|
||||
|
||||
function shiftPeriod(direction) {
|
||||
cursorDate.value = viewMode.value === 'month'
|
||||
? addMonths(cursorDate.value, direction)
|
||||
: addDays(cursorDate.value, direction * 7);
|
||||
}
|
||||
|
||||
function jumpToToday() {
|
||||
cursorDate.value = today;
|
||||
}
|
||||
|
||||
function formatDayNumber(date) {
|
||||
return new Intl.DateTimeFormat(locale.value, { day: 'numeric' }).format(date);
|
||||
}
|
||||
|
||||
function formatHour(value) {
|
||||
return new Intl.DateTimeFormat(locale.value, {
|
||||
hour: 'numeric',
|
||||
minute: '2-digit',
|
||||
}).format(new Date(value));
|
||||
}
|
||||
|
||||
function startOfDay(value) {
|
||||
const date = new Date(value);
|
||||
date.setHours(0, 0, 0, 0);
|
||||
return date;
|
||||
}
|
||||
|
||||
function startOfWeek(value) {
|
||||
const date = startOfDay(value);
|
||||
const day = date.getDay();
|
||||
const diff = day === 0 ? -6 : 1 - day;
|
||||
return addDays(date, diff);
|
||||
}
|
||||
|
||||
function endOfWeek(value) {
|
||||
return addDays(startOfWeek(value), 6);
|
||||
}
|
||||
|
||||
function startOfMonth(value) {
|
||||
const date = startOfDay(value);
|
||||
date.setDate(1);
|
||||
return date;
|
||||
}
|
||||
|
||||
function endOfMonth(value) {
|
||||
const date = startOfMonth(value);
|
||||
date.setMonth(date.getMonth() + 1);
|
||||
date.setDate(0);
|
||||
return date;
|
||||
}
|
||||
|
||||
function addDays(value, amount) {
|
||||
const date = startOfDay(value);
|
||||
date.setDate(date.getDate() + amount);
|
||||
return date;
|
||||
}
|
||||
|
||||
function addMonths(value, amount) {
|
||||
const date = startOfMonth(value);
|
||||
date.setMonth(date.getMonth() + amount);
|
||||
return date;
|
||||
}
|
||||
|
||||
function dateKey(value) {
|
||||
const date = new Date(value);
|
||||
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
function sortByDate(left, right) {
|
||||
return left.scheduledAt.getTime() - right.scheduledAt.getTime();
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="calendar-shell">
|
||||
<div
|
||||
v-if="isLoading"
|
||||
class="page-message"
|
||||
>
|
||||
{{ t('dashboard.loading') }}
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else-if="pageError"
|
||||
class="page-message error"
|
||||
>
|
||||
{{ pageError }}
|
||||
</div>
|
||||
|
||||
<article
|
||||
v-else
|
||||
class="calendar-card"
|
||||
>
|
||||
<div class="calendar-toolbar">
|
||||
<div class="calendar-nav">
|
||||
<button
|
||||
class="icon-button"
|
||||
type="button"
|
||||
@click="shiftPeriod(-1)"
|
||||
>
|
||||
<v-icon :icon="mdiChevronLeft" />
|
||||
</button>
|
||||
|
||||
<div class="calendar-period">{{ periodLabel }}</div>
|
||||
|
||||
<button
|
||||
class="icon-button"
|
||||
type="button"
|
||||
@click="shiftPeriod(1)"
|
||||
>
|
||||
<v-icon :icon="mdiChevronRight" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="calendar-controls">
|
||||
<button
|
||||
class="text-button"
|
||||
type="button"
|
||||
@click="jumpToToday"
|
||||
>
|
||||
{{ t('today') }}
|
||||
</button>
|
||||
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="calendar-grid calendar-grid-head">
|
||||
<div
|
||||
v-for="label in weekdayLabels"
|
||||
:key="label"
|
||||
class="weekday-label"
|
||||
>
|
||||
{{ label }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="calendar-grid"
|
||||
:class="viewMode === 'week' ? 'calendar-grid-week' : 'calendar-grid-month'"
|
||||
>
|
||||
<div
|
||||
v-for="day in visibleDays"
|
||||
:key="day.key"
|
||||
class="calendar-day"
|
||||
:class="{
|
||||
'calendar-day-outside': day.isOutsideMonth,
|
||||
'calendar-day-today': day.isToday,
|
||||
'calendar-day-week': viewMode === 'week',
|
||||
}"
|
||||
>
|
||||
<div class="day-number">
|
||||
{{ formatDayNumber(day.date) }}
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="day.entries.length"
|
||||
class="day-entries"
|
||||
>
|
||||
<router-link
|
||||
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>
|
||||
|
||||
<div
|
||||
v-if="viewMode === 'month' && day.entries.length > 3"
|
||||
class="entry-more"
|
||||
>
|
||||
{{ t('dashboard.moreItems', { count: day.entries.length - 3 }) }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else-if="viewMode === 'week'"
|
||||
class="day-empty"
|
||||
>
|
||||
{{ t('dashboard.emptyPeriod') }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.calendar-shell {
|
||||
@apply mx-auto w-full max-w-7xl px-5 py-8 md:px-8;
|
||||
}
|
||||
|
||||
.page-message {
|
||||
@apply rounded-[1.25rem] border p-4 text-sm font-medium;
|
||||
background: rgba(255, 255, 255, 0.88);
|
||||
border-color: rgba(23, 32, 51, 0.08);
|
||||
color: #526178;
|
||||
}
|
||||
|
||||
.page-message.error {
|
||||
color: #b91c1c;
|
||||
}
|
||||
|
||||
.calendar-card {
|
||||
@apply rounded-[1.75rem] border p-4 md: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);
|
||||
}
|
||||
|
||||
.calendar-toolbar {
|
||||
@apply mb-4 flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between;
|
||||
}
|
||||
|
||||
.calendar-nav,
|
||||
.calendar-controls {
|
||||
@apply flex items-center gap-2;
|
||||
}
|
||||
|
||||
.calendar-controls {
|
||||
@apply flex-wrap justify-end;
|
||||
}
|
||||
|
||||
.calendar-period {
|
||||
@apply min-w-0 px-2 text-base font-bold md:text-lg;
|
||||
color: #172033;
|
||||
}
|
||||
|
||||
.icon-button,
|
||||
.text-button,
|
||||
.toggle-button {
|
||||
@apply inline-flex items-center justify-center rounded-full border px-3 py-2 text-sm font-semibold transition;
|
||||
background: #f8fafc;
|
||||
border-color: rgba(23, 32, 51, 0.1);
|
||||
color: #172033;
|
||||
}
|
||||
|
||||
.icon-button {
|
||||
@apply h-10 w-10 px-0 py-0;
|
||||
}
|
||||
|
||||
.icon-button:hover,
|
||||
.text-button:hover,
|
||||
.toggle-button:hover {
|
||||
background: #eef4ff;
|
||||
}
|
||||
|
||||
.view-toggle {
|
||||
@apply inline-flex rounded-full border p-1;
|
||||
background: #f8fafc;
|
||||
border-color: rgba(23, 32, 51, 0.1);
|
||||
}
|
||||
|
||||
.toggle-button {
|
||||
@apply border-0 bg-transparent;
|
||||
}
|
||||
|
||||
.toggle-button-active {
|
||||
background: #172033;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.calendar-grid {
|
||||
@apply grid gap-3;
|
||||
grid-template-columns: repeat(7, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.calendar-grid-head {
|
||||
@apply mb-3;
|
||||
}
|
||||
|
||||
.weekday-label {
|
||||
@apply px-2 text-xs font-bold uppercase tracking-[0.16em];
|
||||
color: #526178;
|
||||
}
|
||||
|
||||
.calendar-day {
|
||||
@apply min-h-[8.5rem] rounded-[1.25rem] border p-3;
|
||||
background: linear-gradient(180deg, rgba(255, 253, 248, 0.8) 0%, rgba(255, 255, 255, 0.96) 100%);
|
||||
border-color: rgba(23, 32, 51, 0.08);
|
||||
}
|
||||
|
||||
.calendar-day-week {
|
||||
@apply min-h-[22rem];
|
||||
}
|
||||
|
||||
.calendar-day-outside {
|
||||
opacity: 0.48;
|
||||
}
|
||||
|
||||
.calendar-day-today {
|
||||
border-color: rgba(15, 118, 110, 0.22);
|
||||
box-shadow: inset 0 0 0 1px rgba(15, 118, 110, 0.18);
|
||||
}
|
||||
|
||||
.day-number {
|
||||
@apply mb-3 text-sm font-bold;
|
||||
color: #172033;
|
||||
}
|
||||
|
||||
.day-entries {
|
||||
@apply flex flex-col gap-2;
|
||||
}
|
||||
|
||||
.calendar-entry {
|
||||
@apply flex flex-col gap-0.5 rounded-[1rem] border px-3 py-2 no-underline transition;
|
||||
}
|
||||
|
||||
.calendar-entry:hover {
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.calendar-entry strong {
|
||||
@apply text-sm font-bold;
|
||||
color: #172033;
|
||||
}
|
||||
|
||||
.calendar-entry span {
|
||||
@apply text-xs leading-5;
|
||||
color: #526178;
|
||||
}
|
||||
|
||||
.entry-time {
|
||||
@apply text-[0.7rem] font-bold uppercase tracking-[0.12em];
|
||||
color: #0f766e;
|
||||
}
|
||||
|
||||
.entry-more,
|
||||
.day-empty {
|
||||
@apply px-1 text-xs font-semibold;
|
||||
color: #526178;
|
||||
}
|
||||
|
||||
.calendar-entry.production {
|
||||
background: #fff7ed;
|
||||
border-color: rgba(249, 115, 22, 0.18);
|
||||
}
|
||||
|
||||
.calendar-entry.approval {
|
||||
background: #eff6ff;
|
||||
border-color: rgba(37, 99, 235, 0.16);
|
||||
}
|
||||
|
||||
.calendar-entry.ready {
|
||||
background: #ecfdf5;
|
||||
border-color: rgba(5, 150, 105, 0.16);
|
||||
}
|
||||
|
||||
.calendar-entry.risk {
|
||||
background: #fef2f2;
|
||||
border-color: rgba(220, 38, 38, 0.16);
|
||||
}
|
||||
|
||||
.calendar-entry.project {
|
||||
background: #f8fafc;
|
||||
border-color: rgba(71, 85, 105, 0.18);
|
||||
border-style: dashed;
|
||||
}
|
||||
|
||||
.calendar-entry.published,
|
||||
.calendar-entry.muted {
|
||||
background: #f8fafc;
|
||||
border-color: rgba(148, 163, 184, 0.18);
|
||||
}
|
||||
|
||||
@media (max-width: 960px) {
|
||||
.calendar-shell {
|
||||
@apply px-4 py-6;
|
||||
}
|
||||
|
||||
.calendar-grid {
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.weekday-label {
|
||||
@apply text-[0.65rem];
|
||||
}
|
||||
|
||||
.calendar-day {
|
||||
@apply min-h-[7rem] p-2;
|
||||
}
|
||||
|
||||
.calendar-day-week {
|
||||
@apply min-h-[18rem];
|
||||
}
|
||||
|
||||
.calendar-entry {
|
||||
@apply px-2 py-2;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.calendar-toolbar {
|
||||
@apply items-stretch;
|
||||
}
|
||||
|
||||
.calendar-nav,
|
||||
.calendar-controls {
|
||||
@apply justify-between;
|
||||
}
|
||||
|
||||
.calendar-grid-head,
|
||||
.calendar-grid {
|
||||
min-width: 46rem;
|
||||
}
|
||||
|
||||
.calendar-card {
|
||||
overflow-x: auto;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
418
frontend/src/features/workspaces/views/OverviewView.vue
Normal file
418
frontend/src/features/workspaces/views/OverviewView.vue
Normal file
@@ -0,0 +1,418 @@
|
||||
<script setup>
|
||||
import { computed, onMounted, ref, watch } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useAuthStore } from '@/features/auth/stores/authStore.js';
|
||||
import { useWorkspaceStore } from '@/features/workspaces/stores/workspaceStore.js';
|
||||
import { useClient } from '@/plugins/api.js';
|
||||
|
||||
const { locale, t } = useI18n();
|
||||
const authStore = useAuthStore();
|
||||
const workspaceStore = useWorkspaceStore();
|
||||
const client = useClient();
|
||||
|
||||
const isLoading = ref(false);
|
||||
const error = ref(null);
|
||||
const projects = ref([]);
|
||||
const contentItems = ref([]);
|
||||
const notifications = ref([]);
|
||||
|
||||
const workspaceMap = computed(() =>
|
||||
new Map(workspaceStore.workspaces.map(workspace => [workspace.id, workspace]))
|
||||
);
|
||||
|
||||
const workspaceStats = computed(() =>
|
||||
workspaceStore.workspaces.map(workspace => {
|
||||
const workspaceProjects = projects.value.filter(project => project.workspaceId === workspace.id);
|
||||
const workspaceContent = contentItems.value.filter(item => item.workspaceId === workspace.id);
|
||||
const upcomingCount = workspaceContent.filter(item => {
|
||||
if (!item.dueDate) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return startOfDay(item.dueDate) >= today.value;
|
||||
}).length;
|
||||
|
||||
const blockingCount = workspaceContent.filter(item =>
|
||||
['In internal review', 'Ready for client review', 'In client review', 'Changes requested by client'].includes(item.status)
|
||||
).length;
|
||||
|
||||
return {
|
||||
id: workspace.id,
|
||||
name: workspace.name,
|
||||
timeZone: workspace.timeZone,
|
||||
projectCount: workspaceProjects.length,
|
||||
contentCount: workspaceContent.length,
|
||||
upcomingCount,
|
||||
blockingCount,
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
const today = computed(() => startOfDay(new Date()));
|
||||
|
||||
const upcomingEvents = computed(() =>
|
||||
contentItems.value
|
||||
.filter(item => item.dueDate)
|
||||
.map(item => ({
|
||||
id: item.id,
|
||||
title: item.title,
|
||||
date: startOfDay(item.dueDate),
|
||||
status: item.status,
|
||||
workspaceId: item.workspaceId,
|
||||
workspaceName: workspaceMap.value.get(item.workspaceId)?.name ?? t('nav.noWorkspace'),
|
||||
route: { name: 'content-item-detail', params: { id: item.id } },
|
||||
}))
|
||||
.filter(item => item.date >= today.value)
|
||||
.sort((left, right) => left.date.getTime() - right.date.getTime())
|
||||
.slice(0, 10)
|
||||
);
|
||||
|
||||
const crossWorkspaceRisks = computed(() =>
|
||||
contentItems.value
|
||||
.filter(item => item.dueDate)
|
||||
.map(item => ({
|
||||
id: item.id,
|
||||
title: item.title,
|
||||
date: startOfDay(item.dueDate),
|
||||
status: item.status,
|
||||
workspaceName: workspaceMap.value.get(item.workspaceId)?.name ?? t('nav.noWorkspace'),
|
||||
route: { name: 'content-item-detail', params: { id: item.id } },
|
||||
}))
|
||||
.filter(item =>
|
||||
item.date < today.value && !['Approved', 'Ready to publish', 'Published', 'Archived'].includes(item.status)
|
||||
)
|
||||
.sort((left, right) => left.date.getTime() - right.date.getTime())
|
||||
.slice(0, 6)
|
||||
);
|
||||
|
||||
const activityFeed = computed(() =>
|
||||
notifications.value
|
||||
.map(item => ({
|
||||
...item,
|
||||
workspaceName: workspaceMap.value.get(item.workspaceId)?.name ?? t('nav.noWorkspace'),
|
||||
}))
|
||||
.slice(0, 8)
|
||||
);
|
||||
|
||||
const overviewStats = computed(() => [
|
||||
{ label: t('overview.stats.workspaces'), value: workspaceStore.workspaces.length },
|
||||
{ label: t('overview.stats.projects'), value: projects.value.length },
|
||||
{ label: t('overview.stats.upcoming'), value: upcomingEvents.value.length },
|
||||
{ label: t('overview.stats.blockers'), value: crossWorkspaceRisks.value.length },
|
||||
]);
|
||||
|
||||
async function loadOverview() {
|
||||
if (!authStore.isAuthenticated) {
|
||||
projects.value = [];
|
||||
contentItems.value = [];
|
||||
notifications.value = [];
|
||||
return;
|
||||
}
|
||||
|
||||
isLoading.value = true;
|
||||
error.value = null;
|
||||
|
||||
try {
|
||||
const [projectsResponse, contentItemsResponse, notificationsResponse] = await Promise.all([
|
||||
client.get('/api/projects'),
|
||||
client.get('/api/content-items'),
|
||||
client.get('/api/notifications'),
|
||||
]);
|
||||
|
||||
projects.value = projectsResponse.data ?? [];
|
||||
contentItems.value = contentItemsResponse.data ?? [];
|
||||
notifications.value = notificationsResponse.data ?? [];
|
||||
} catch (loadError) {
|
||||
console.error('Failed to load cross-workspace overview:', loadError);
|
||||
error.value = 'Failed to load overview data.';
|
||||
projects.value = [];
|
||||
contentItems.value = [];
|
||||
notifications.value = [];
|
||||
} finally {
|
||||
isLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function formatDate(value) {
|
||||
return new Intl.DateTimeFormat(locale.value, {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
}).format(new Date(value));
|
||||
}
|
||||
|
||||
function formatDateTime(value) {
|
||||
return new Intl.DateTimeFormat(locale.value, {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: 'numeric',
|
||||
minute: '2-digit',
|
||||
}).format(new Date(value));
|
||||
}
|
||||
|
||||
function startOfDay(value) {
|
||||
const date = new Date(value);
|
||||
date.setHours(0, 0, 0, 0);
|
||||
return date;
|
||||
}
|
||||
|
||||
watch(
|
||||
() => authStore.isAuthenticated,
|
||||
async isAuthenticated => {
|
||||
if (isAuthenticated) {
|
||||
await loadOverview();
|
||||
} else {
|
||||
projects.value = [];
|
||||
contentItems.value = [];
|
||||
notifications.value = [];
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
watch(
|
||||
() => workspaceStore.workspaces.length,
|
||||
async () => {
|
||||
if (authStore.isAuthenticated) {
|
||||
await loadOverview();
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
onMounted(async () => {
|
||||
if (authStore.isAuthenticated) {
|
||||
await loadOverview();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="page-shell">
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<div class="eyebrow">{{ t('overview.eyebrow') }}</div>
|
||||
<h1>{{ t('overview.title') }}</h1>
|
||||
<p>{{ t('overview.description') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="isLoading"
|
||||
class="page-message"
|
||||
>
|
||||
{{ t('overview.loading') }}
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else-if="error"
|
||||
class="page-message error"
|
||||
>
|
||||
{{ error }}
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
<div class="stats-grid">
|
||||
<article
|
||||
v-for="stat in overviewStats"
|
||||
:key="stat.label"
|
||||
class="stat-card"
|
||||
>
|
||||
<span>{{ stat.label }}</span>
|
||||
<strong>{{ stat.value }}</strong>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<div class="overview-grid">
|
||||
<article class="panel">
|
||||
<div class="panel-kicker">{{ t('overview.workspacesKicker') }}</div>
|
||||
<div class="panel-title">{{ t('overview.workspaceRollup') }}</div>
|
||||
<div class="workspace-stack">
|
||||
<button
|
||||
v-for="workspace in workspaceStats"
|
||||
:key="workspace.id"
|
||||
class="workspace-row"
|
||||
type="button"
|
||||
@click="workspaceStore.setActiveWorkspace(workspace.id)"
|
||||
>
|
||||
<div>
|
||||
<strong>{{ workspace.name }}</strong>
|
||||
<span>{{ workspace.timeZone }}</span>
|
||||
</div>
|
||||
<div class="workspace-meta">
|
||||
<small>{{ workspace.projectCount }} {{ t('overview.labels.projects') }}</small>
|
||||
<small>{{ workspace.upcomingCount }} {{ t('overview.labels.upcoming') }}</small>
|
||||
<small>{{ workspace.blockingCount }} {{ t('overview.labels.blocked') }}</small>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article class="panel">
|
||||
<div class="panel-kicker">{{ t('overview.timelineKicker') }}</div>
|
||||
<div class="panel-title">{{ t('overview.upcomingTitle') }}</div>
|
||||
<router-link
|
||||
v-for="item in upcomingEvents"
|
||||
:key="item.id"
|
||||
:to="item.route"
|
||||
class="list-row"
|
||||
>
|
||||
<div>
|
||||
<strong>{{ item.title }}</strong>
|
||||
<span>{{ item.workspaceName }} · {{ item.status }}</span>
|
||||
</div>
|
||||
<em>{{ formatDate(item.date) }}</em>
|
||||
</router-link>
|
||||
<div
|
||||
v-if="!upcomingEvents.length"
|
||||
class="empty-state"
|
||||
>
|
||||
{{ t('overview.emptyUpcoming') }}
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article class="panel">
|
||||
<div class="panel-kicker">{{ t('overview.riskKicker') }}</div>
|
||||
<div class="panel-title">{{ t('overview.risksTitle') }}</div>
|
||||
<router-link
|
||||
v-for="item in crossWorkspaceRisks"
|
||||
:key="item.id"
|
||||
:to="item.route"
|
||||
class="list-row alert"
|
||||
>
|
||||
<div>
|
||||
<strong>{{ item.title }}</strong>
|
||||
<span>{{ item.workspaceName }} · {{ item.status }}</span>
|
||||
</div>
|
||||
<em>{{ formatDate(item.date) }}</em>
|
||||
</router-link>
|
||||
<div
|
||||
v-if="!crossWorkspaceRisks.length"
|
||||
class="empty-state"
|
||||
>
|
||||
{{ t('overview.emptyRisks') }}
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article class="panel">
|
||||
<div class="panel-kicker">{{ t('overview.activityKicker') }}</div>
|
||||
<div class="panel-title">{{ t('overview.activityTitle') }}</div>
|
||||
<div
|
||||
v-for="item in activityFeed"
|
||||
:key="item.id"
|
||||
class="list-row"
|
||||
>
|
||||
<div>
|
||||
<strong>{{ item.workspaceName }}</strong>
|
||||
<span>{{ item.message }}</span>
|
||||
</div>
|
||||
<em>{{ formatDateTime(item.createdAt) }}</em>
|
||||
</div>
|
||||
<div
|
||||
v-if="!activityFeed.length"
|
||||
class="empty-state"
|
||||
>
|
||||
{{ t('overview.emptyActivity') }}
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
</template>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.page-shell {
|
||||
@apply mx-auto flex w-full max-w-7xl flex-col gap-6 px-5 py-8 md:px-8;
|
||||
}
|
||||
|
||||
.page-header h1 {
|
||||
@apply mt-2 text-4xl font-black;
|
||||
color: #172033;
|
||||
}
|
||||
|
||||
.page-header p,
|
||||
.stat-card span,
|
||||
.list-row span,
|
||||
.workspace-row span,
|
||||
.empty-state {
|
||||
@apply text-sm leading-6;
|
||||
color: #526178;
|
||||
}
|
||||
|
||||
.eyebrow,
|
||||
.panel-kicker {
|
||||
@apply text-xs font-bold uppercase tracking-[0.24em];
|
||||
color: #0f766e;
|
||||
}
|
||||
|
||||
.stats-grid {
|
||||
@apply grid gap-4 md:grid-cols-2 xl:grid-cols-4;
|
||||
}
|
||||
|
||||
.overview-grid {
|
||||
@apply grid gap-4 xl:grid-cols-2;
|
||||
}
|
||||
|
||||
.stat-card,
|
||||
.panel {
|
||||
@apply rounded-[1.75rem] border p-5;
|
||||
background: rgba(255, 255, 255, 0.92);
|
||||
border-color: rgba(23, 32, 51, 0.08);
|
||||
box-shadow: 0 18px 40px rgba(23, 32, 51, 0.06);
|
||||
}
|
||||
|
||||
.panel {
|
||||
@apply flex flex-col gap-4;
|
||||
}
|
||||
|
||||
.panel-title,
|
||||
.workspace-row strong,
|
||||
.list-row strong {
|
||||
color: #172033;
|
||||
}
|
||||
|
||||
.panel-title {
|
||||
@apply text-2xl font-black;
|
||||
}
|
||||
|
||||
.stat-card strong {
|
||||
@apply mt-3 block text-4xl font-black;
|
||||
color: #172033;
|
||||
}
|
||||
|
||||
.workspace-stack {
|
||||
@apply flex flex-col gap-3;
|
||||
}
|
||||
|
||||
.workspace-row,
|
||||
.list-row {
|
||||
@apply flex items-start justify-between gap-4 rounded-[1.1rem] border p-4 text-left no-underline;
|
||||
background: #fffaf2;
|
||||
border-color: rgba(23, 32, 51, 0.08);
|
||||
}
|
||||
|
||||
.workspace-row.alert,
|
||||
.list-row.alert {
|
||||
background: #fff1f2;
|
||||
border-color: rgba(225, 29, 72, 0.14);
|
||||
}
|
||||
|
||||
.workspace-meta {
|
||||
@apply flex flex-col items-end gap-1;
|
||||
}
|
||||
|
||||
.workspace-meta small,
|
||||
.list-row em {
|
||||
@apply text-sm font-semibold not-italic;
|
||||
color: #172033;
|
||||
}
|
||||
|
||||
.page-message,
|
||||
.empty-state {
|
||||
@apply rounded-[1.25rem] border p-4 text-sm font-medium;
|
||||
background: rgba(255, 255, 255, 0.84);
|
||||
border-color: rgba(23, 32, 51, 0.08);
|
||||
}
|
||||
|
||||
.page-message.error {
|
||||
color: #b91c1c;
|
||||
}
|
||||
</style>
|
||||
271
frontend/src/features/workspaces/views/WorkspaceCreateView.vue
Normal file
271
frontend/src/features/workspaces/views/WorkspaceCreateView.vue
Normal file
@@ -0,0 +1,271 @@
|
||||
<script setup>
|
||||
import { computed, reactive, ref } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useWorkspaceStore } from '@/features/workspaces/stores/workspaceStore.js';
|
||||
|
||||
const router = useRouter();
|
||||
const { t } = useI18n();
|
||||
const workspaceStore = useWorkspaceStore();
|
||||
|
||||
const form = reactive({
|
||||
name: '',
|
||||
slug: '',
|
||||
timeZone: computedDefaultTimeZone(),
|
||||
});
|
||||
const formError = ref(null);
|
||||
|
||||
const previewSlug = computed(() => {
|
||||
if (form.slug.trim()) {
|
||||
return slugify(form.slug);
|
||||
}
|
||||
|
||||
return slugify(form.name);
|
||||
});
|
||||
|
||||
function computedDefaultTimeZone() {
|
||||
return workspaceStore.activeWorkspace?.timeZone || 'America/Montreal';
|
||||
}
|
||||
|
||||
function slugify(value) {
|
||||
return (value ?? '')
|
||||
.toLowerCase()
|
||||
.trim()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '')
|
||||
.slice(0, 80);
|
||||
}
|
||||
|
||||
async function submitForm() {
|
||||
if (workspaceStore.isCreating) {
|
||||
return;
|
||||
}
|
||||
|
||||
formError.value = null;
|
||||
|
||||
const name = form.name.trim();
|
||||
const slug = slugify(form.slug || form.name);
|
||||
const timeZone = form.timeZone.trim();
|
||||
|
||||
if (!name || !slug || !timeZone) {
|
||||
formError.value = t('workspaceCreate.errors.required');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await workspaceStore.createWorkspace({
|
||||
name,
|
||||
slug,
|
||||
timeZone,
|
||||
});
|
||||
|
||||
await router.push({ name: 'workspace-settings' });
|
||||
} catch (error) {
|
||||
formError.value = t('workspaceCreate.errors.createFailed');
|
||||
}
|
||||
}
|
||||
|
||||
async function cancel() {
|
||||
await router.push({ name: 'workspace-settings' });
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="page-shell">
|
||||
<div class="hero">
|
||||
<div class="hero-copy">
|
||||
<div class="eyebrow">{{ t('workspaceCreate.eyebrow') }}</div>
|
||||
<h1>{{ t('workspaceCreate.title') }}</h1>
|
||||
<p>{{ t('workspaceCreate.description') }}</p>
|
||||
</div>
|
||||
|
||||
<div class="hero-note">
|
||||
<strong>{{ t('workspaceCreate.previewTitle') }}</strong>
|
||||
<span>{{ t('workspaceCreate.previewDescription') }}</span>
|
||||
<code>{{ previewSlug || 'workspace-slug' }}</code>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<article class="create-card">
|
||||
<div class="card-header">
|
||||
<strong>{{ t('workspaceCreate.formTitle') }}</strong>
|
||||
<span>{{ t('workspaceCreate.formDescription') }}</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="formError"
|
||||
class="page-message error"
|
||||
>
|
||||
{{ formError }}
|
||||
</div>
|
||||
|
||||
<form
|
||||
class="form-grid"
|
||||
@submit.prevent="submitForm"
|
||||
>
|
||||
<label class="field field-wide">
|
||||
<span>{{ t('workspaceCreate.fields.name') }}</span>
|
||||
<input
|
||||
v-model="form.name"
|
||||
type="text"
|
||||
:placeholder="t('workspaceCreate.fields.namePlaceholder')"
|
||||
:disabled="workspaceStore.isCreating"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label class="field">
|
||||
<span>{{ t('workspaceCreate.fields.slug') }}</span>
|
||||
<input
|
||||
v-model="form.slug"
|
||||
type="text"
|
||||
:placeholder="t('workspaceCreate.fields.slugPlaceholder')"
|
||||
:disabled="workspaceStore.isCreating"
|
||||
/>
|
||||
<small>{{ t('workspaceCreate.slugHint', { slug: previewSlug || 'workspace-slug' }) }}</small>
|
||||
</label>
|
||||
|
||||
<label class="field">
|
||||
<span>{{ t('workspaceCreate.fields.timeZone') }}</span>
|
||||
<input
|
||||
v-model="form.timeZone"
|
||||
type="text"
|
||||
:disabled="workspaceStore.isCreating"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<div class="panel-actions field-wide">
|
||||
<button
|
||||
class="secondary"
|
||||
type="button"
|
||||
:disabled="workspaceStore.isCreating"
|
||||
@click="cancel"
|
||||
>
|
||||
{{ t('common.cancel') }}
|
||||
</button>
|
||||
<button
|
||||
class="primary"
|
||||
type="submit"
|
||||
:disabled="workspaceStore.isCreating"
|
||||
>
|
||||
{{ workspaceStore.isCreating ? t('common.creating') : t('workspaceCreate.createAction') }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</article>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.page-shell {
|
||||
@apply mx-auto flex w-full max-w-6xl flex-col gap-6 px-5 py-8 md:px-8;
|
||||
}
|
||||
|
||||
.hero {
|
||||
@apply grid gap-4 lg:grid-cols-[minmax(0,1.3fr)_minmax(18rem,0.8fr)];
|
||||
}
|
||||
|
||||
.hero-copy,
|
||||
.hero-note,
|
||||
.create-card {
|
||||
@apply rounded-[1.75rem] border;
|
||||
border-color: rgba(23, 32, 51, 0.08);
|
||||
background: rgba(255, 255, 255, 0.92);
|
||||
}
|
||||
|
||||
.hero-copy {
|
||||
@apply p-6 md:p-8;
|
||||
background:
|
||||
radial-gradient(circle at top left, rgba(255, 138, 61, 0.16), transparent 38%),
|
||||
linear-gradient(135deg, rgba(255, 255, 255, 0.98), rgba(255, 247, 237, 0.92));
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
@apply text-xs font-bold uppercase tracking-[0.24em];
|
||||
color: #c2410c;
|
||||
}
|
||||
|
||||
.hero-copy h1 {
|
||||
@apply mt-3 text-4xl font-black;
|
||||
color: #172033;
|
||||
}
|
||||
|
||||
.hero-copy p,
|
||||
.hero-note span,
|
||||
.card-header span,
|
||||
.field small {
|
||||
@apply text-sm leading-6;
|
||||
color: #526178;
|
||||
}
|
||||
|
||||
.hero-note,
|
||||
.create-card {
|
||||
@apply flex flex-col gap-4 p-6;
|
||||
}
|
||||
|
||||
.hero-note strong,
|
||||
.card-header strong {
|
||||
color: #172033;
|
||||
}
|
||||
|
||||
.hero-note strong {
|
||||
@apply text-xl font-black;
|
||||
}
|
||||
|
||||
.hero-note code {
|
||||
@apply rounded-[1rem] px-3 py-2 text-sm;
|
||||
background: rgba(23, 32, 51, 0.06);
|
||||
color: #172033;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
@apply flex flex-col gap-2;
|
||||
}
|
||||
|
||||
.card-header strong {
|
||||
@apply text-2xl font-black;
|
||||
}
|
||||
|
||||
.form-grid {
|
||||
@apply grid gap-4 md:grid-cols-2;
|
||||
}
|
||||
|
||||
.field {
|
||||
@apply flex flex-col gap-2;
|
||||
}
|
||||
|
||||
.field-wide {
|
||||
@apply md:col-span-2;
|
||||
}
|
||||
|
||||
.field span {
|
||||
@apply text-sm font-semibold;
|
||||
color: #172033;
|
||||
}
|
||||
|
||||
.field input {
|
||||
@apply rounded-[1rem] border px-4 py-3 text-sm;
|
||||
background: #fffdf8;
|
||||
border-color: rgba(23, 32, 51, 0.1);
|
||||
color: #172033;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.panel-actions {
|
||||
@apply flex flex-wrap justify-end gap-3 pt-2;
|
||||
}
|
||||
|
||||
.primary,
|
||||
.secondary {
|
||||
@apply inline-flex items-center justify-center rounded-full px-5 py-3 text-sm font-semibold transition;
|
||||
}
|
||||
|
||||
.primary {
|
||||
background: #172033;
|
||||
color: #fffaf2;
|
||||
}
|
||||
|
||||
.secondary {
|
||||
background: rgba(23, 32, 51, 0.06);
|
||||
color: #172033;
|
||||
}
|
||||
</style>
|
||||
559
frontend/src/features/workspaces/views/WorkspaceSettingsView.vue
Normal file
559
frontend/src/features/workspaces/views/WorkspaceSettingsView.vue
Normal file
@@ -0,0 +1,559 @@
|
||||
<script setup>
|
||||
import { computed, reactive, ref, watch } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useWorkspaceStore } from '@/features/workspaces/stores/workspaceStore.js';
|
||||
import {
|
||||
mdiAccountGroupOutline,
|
||||
mdiCheckCircleOutline,
|
||||
mdiCogOutline,
|
||||
mdiFolderGoogleDrive,
|
||||
mdiImageMultipleOutline,
|
||||
mdiTuneVariant,
|
||||
} from '@mdi/js';
|
||||
|
||||
const { t } = useI18n();
|
||||
const workspaceStore = useWorkspaceStore();
|
||||
const activeTab = ref('general');
|
||||
|
||||
const inviteForm = reactive({
|
||||
email: '',
|
||||
role: 'workspaceMember',
|
||||
});
|
||||
|
||||
const pendingInvites = computed(() =>
|
||||
workspaceStore.invitesByWorkspace[workspaceStore.activeWorkspaceId] ?? []
|
||||
);
|
||||
const workspaceMembers = computed(() =>
|
||||
workspaceStore.membersByWorkspace[workspaceStore.activeWorkspaceId] ?? []
|
||||
);
|
||||
const settingsTabs = computed(() => [
|
||||
{ key: 'general', label: t('workspaceSettings.tabs.general'), icon: mdiCogOutline },
|
||||
{ key: 'members', label: t('workspaceSettings.tabs.members'), icon: mdiAccountGroupOutline },
|
||||
{ key: 'workflow', label: t('workspaceSettings.tabs.workflow'), icon: mdiTuneVariant },
|
||||
{ key: 'connectors', label: t('workspaceSettings.tabs.connectors'), icon: mdiFolderGoogleDrive },
|
||||
]);
|
||||
const workflowSteps = computed(() => [
|
||||
{
|
||||
key: 'internal',
|
||||
title: t('workspaceSettings.approvals.steps.internal'),
|
||||
detail: t('workspaceSettings.approvals.stepDetail.approverCount', { count: 1 }),
|
||||
},
|
||||
{
|
||||
key: 'client',
|
||||
title: t('workspaceSettings.approvals.steps.client'),
|
||||
detail: t('workspaceSettings.approvals.stepDetail.approverCount', { count: 1 }),
|
||||
},
|
||||
{
|
||||
key: 'publish',
|
||||
title: t('workspaceSettings.approvals.steps.publish'),
|
||||
detail: t('workspaceSettings.approvals.stepDetail.manualPublish'),
|
||||
},
|
||||
]);
|
||||
|
||||
watch(
|
||||
() => workspaceStore.activeWorkspaceId,
|
||||
async workspaceId => {
|
||||
if (!workspaceId) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await workspaceStore.fetchInvites(workspaceId);
|
||||
await workspaceStore.fetchMembers(workspaceId);
|
||||
} catch (error) {
|
||||
console.error('Failed to load workspace people data:', error);
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
async function submitInvite() {
|
||||
if (!inviteForm.email.trim() || !inviteForm.role) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await workspaceStore.inviteMember({
|
||||
email: inviteForm.email.trim(),
|
||||
role: inviteForm.role,
|
||||
});
|
||||
|
||||
inviteForm.email = '';
|
||||
inviteForm.role = 'workspaceMember';
|
||||
} catch (error) {
|
||||
console.error('Failed to invite workspace member:', error);
|
||||
}
|
||||
}
|
||||
|
||||
function formatDate(value) {
|
||||
if (!value) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return new Date(value).toLocaleString();
|
||||
}
|
||||
|
||||
function translateRole(role) {
|
||||
if (!role) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const normalizedRole = role.charAt(0).toLowerCase() + role.slice(1);
|
||||
return t(`workspaceSettings.roles.${normalizedRole}`, role);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="workspace-settings-shell">
|
||||
<div class="workspace-settings-hero">
|
||||
<div class="section-copy">
|
||||
<span class="section-kicker">{{ t('workspaceSettings.currentWorkspace') }}</span>
|
||||
<h1>{{ workspaceStore.activeWorkspace?.name || t('workspaceSettings.noWorkspaceSelected') }}</h1>
|
||||
<p>{{ t('workspaceSettings.description') }}</p>
|
||||
</div>
|
||||
|
||||
<div class="tab-strip">
|
||||
<button
|
||||
v-for="tab in settingsTabs"
|
||||
:key="tab.key"
|
||||
type="button"
|
||||
class="tab-button"
|
||||
:class="{ 'tab-button-active': activeTab === tab.key }"
|
||||
@click="activeTab = tab.key"
|
||||
>
|
||||
<v-icon :icon="tab.icon" />
|
||||
<span>{{ tab.label }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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.summaryTitle') }}</span>
|
||||
<p>{{ t('workspaceSettings.general.summaryDescription') }}</p>
|
||||
</div>
|
||||
|
||||
<dl
|
||||
v-if="workspaceStore.activeWorkspace"
|
||||
class="summary-grid"
|
||||
>
|
||||
<div>
|
||||
<dt>{{ t('workspaceSettings.summary.name') }}</dt>
|
||||
<dd>{{ workspaceStore.activeWorkspace.name }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>{{ t('workspaceSettings.summary.slug') }}</dt>
|
||||
<dd>{{ workspaceStore.activeWorkspace.slug }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>{{ t('workspaceSettings.summary.timeZone') }}</dt>
|
||||
<dd>{{ workspaceStore.activeWorkspace.timeZone }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>{{ t('workspaceSettings.summary.created') }}</dt>
|
||||
<dd>{{ formatDate(workspaceStore.activeWorkspace.createdAt) }}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else-if="activeTab === 'members'"
|
||||
class="workspace-settings-grid workspace-settings-grid-single"
|
||||
>
|
||||
<article class="settings-card">
|
||||
<div class="section-copy">
|
||||
<span class="section-kicker">{{ t('workspaceSettings.members.inviteTitle') }}</span>
|
||||
<p>{{ t('workspaceSettings.inviteDescription') }}</p>
|
||||
</div>
|
||||
|
||||
<form
|
||||
class="form-stack"
|
||||
@submit.prevent="submitInvite"
|
||||
>
|
||||
<label class="field">
|
||||
<span>{{ t('workspaceSettings.fields.memberEmail') }}</span>
|
||||
<input
|
||||
v-model="inviteForm.email"
|
||||
type="email"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label class="field">
|
||||
<span>{{ t('workspaceSettings.fields.memberRole') }}</span>
|
||||
<select v-model="inviteForm.role">
|
||||
<option value="workspaceMember">{{ t('workspaceSettings.roles.workspaceMember') }}</option>
|
||||
<option value="client">{{ t('workspaceSettings.roles.client') }}</option>
|
||||
<option value="provider">{{ t('workspaceSettings.roles.provider') }}</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<button
|
||||
class="primary-button"
|
||||
type="submit"
|
||||
>
|
||||
{{ workspaceStore.isInviting ? t('common.creating') : t('workspaceSettings.sendInvite') }}
|
||||
</button>
|
||||
</form>
|
||||
</article>
|
||||
|
||||
<article class="settings-card">
|
||||
<div class="section-copy">
|
||||
<span class="section-kicker">{{ t('workspaceSettings.members.pendingTitle') }}</span>
|
||||
<p>{{ t('workspaceSettings.members.pendingDescription') }}</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="workspaceStore.isInvitesLoading"
|
||||
class="empty-state"
|
||||
>
|
||||
{{ t('loading') }}
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else-if="pendingInvites.length"
|
||||
class="invite-list"
|
||||
>
|
||||
<div
|
||||
v-for="invite in pendingInvites"
|
||||
:key="invite.id"
|
||||
class="invite-row"
|
||||
>
|
||||
<div>
|
||||
<strong>{{ invite.email }}</strong>
|
||||
<span>{{ t(`workspaceSettings.roles.${invite.role}`) }}</span>
|
||||
</div>
|
||||
<small>{{ formatDate(invite.createdAt) }}</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else
|
||||
class="empty-state"
|
||||
>
|
||||
{{ t('workspaceSettings.inviteEmpty') }}
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article class="settings-card">
|
||||
<div class="section-copy">
|
||||
<span class="section-kicker">{{ t('workspaceSettings.members.activeTitle') }}</span>
|
||||
<p>{{ t('workspaceSettings.members.activeDescription') }}</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="workspaceStore.isMembersLoading"
|
||||
class="empty-state"
|
||||
>
|
||||
{{ t('loading') }}
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else-if="workspaceMembers.length"
|
||||
class="invite-list"
|
||||
>
|
||||
<div
|
||||
v-for="member in workspaceMembers"
|
||||
:key="member.id"
|
||||
class="invite-row"
|
||||
>
|
||||
<div>
|
||||
<strong>{{ member.displayName }}</strong>
|
||||
<span>{{ member.email }}</span>
|
||||
<span>{{ member.roles.map(translateRole).join(' · ') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else
|
||||
class="empty-state"
|
||||
>
|
||||
{{ t('workspaceSettings.members.activeEmpty') }}
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<div class="workflow-rule-list">
|
||||
<div class="workflow-rule">
|
||||
<strong>{{ t('workspaceSettings.approvals.fields.requireInternalReview') }}</strong>
|
||||
<span>{{ t('workspaceSettings.approvals.fieldHelp.requireInternalReview') }}</span>
|
||||
</div>
|
||||
<div class="workflow-rule">
|
||||
<strong>{{ t('workspaceSettings.approvals.fields.requireClientReview') }}</strong>
|
||||
<span>{{ t('workspaceSettings.approvals.fieldHelp.requireClientReview') }}</span>
|
||||
</div>
|
||||
<div class="workflow-rule">
|
||||
<strong>{{ t('workspaceSettings.approvals.fields.publishBehaviour') }}</strong>
|
||||
<span>{{ t('workspaceSettings.approvals.publishBehaviour.manual') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article class="settings-card">
|
||||
<div class="section-copy">
|
||||
<span class="section-kicker">{{ t('workspaceSettings.approvals.previewTitle') }}</span>
|
||||
<p>{{ t('workspaceSettings.approvals.previewDescription') }}</p>
|
||||
</div>
|
||||
|
||||
<div class="workflow-steps">
|
||||
<div
|
||||
v-for="step in workflowSteps"
|
||||
:key="step.key"
|
||||
class="workflow-step"
|
||||
>
|
||||
<div class="workflow-step-icon">
|
||||
<v-icon :icon="mdiCheckCircleOutline" />
|
||||
</div>
|
||||
<div class="workflow-step-copy">
|
||||
<strong>{{ step.title }}</strong>
|
||||
<span>{{ step.detail }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<div class="connector-list">
|
||||
<div class="connector-row">
|
||||
<div class="connector-main">
|
||||
<div class="connector-icon">
|
||||
<v-icon :icon="mdiFolderGoogleDrive" />
|
||||
</div>
|
||||
|
||||
<div class="connector-copy">
|
||||
<strong>{{ t('workspaceSettings.connectors.googleDrive.title') }}</strong>
|
||||
<span>{{ t('workspaceSettings.connectors.googleDrive.description') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="connector-status">
|
||||
{{ t('workspaceSettings.connectors.googleDrive.status') }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<router-link
|
||||
:to="{ name: 'media-library' }"
|
||||
class="connector-link"
|
||||
>
|
||||
<v-icon :icon="mdiImageMultipleOutline" />
|
||||
<span>{{ t('workspaceSettings.connectors.openMediaLibrary') }}</span>
|
||||
</router-link>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.workspace-settings-shell {
|
||||
@apply mx-auto flex w-full max-w-6xl flex-col gap-6 px-5 py-8 md:px-8;
|
||||
}
|
||||
|
||||
.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);
|
||||
}
|
||||
|
||||
.workspace-settings-grid {
|
||||
@apply grid gap-4 lg:grid-cols-2;
|
||||
}
|
||||
|
||||
.workflow-grid {
|
||||
@apply grid gap-4 xl:grid-cols-[minmax(0,0.95fr)_minmax(0,1.05fr)];
|
||||
}
|
||||
|
||||
.workspace-settings-grid-single {
|
||||
@apply lg:grid-cols-1;
|
||||
}
|
||||
|
||||
.settings-card {
|
||||
@apply flex flex-col gap-5 rounded-[1.75rem] border p-5;
|
||||
background: rgba(255, 255, 255, 0.92);
|
||||
border-color: rgba(23, 32, 51, 0.08);
|
||||
box-shadow: 0 18px 40px rgba(23, 32, 51, 0.06);
|
||||
}
|
||||
|
||||
.section-copy {
|
||||
@apply flex flex-col gap-2;
|
||||
}
|
||||
|
||||
.tab-strip {
|
||||
@apply flex flex-wrap gap-3;
|
||||
}
|
||||
|
||||
.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);
|
||||
color: #526178;
|
||||
}
|
||||
|
||||
.tab-button-active {
|
||||
background: #172033;
|
||||
color: #fffaf2;
|
||||
}
|
||||
|
||||
.section-kicker {
|
||||
@apply text-xs font-bold uppercase tracking-[0.2em];
|
||||
color: #0f766e;
|
||||
}
|
||||
|
||||
.section-copy h1,
|
||||
.summary-grid dd,
|
||||
.invite-row strong,
|
||||
.connector-copy strong,
|
||||
.connector-status,
|
||||
.workflow-rule strong,
|
||||
.workflow-step-copy strong {
|
||||
color: #172033;
|
||||
}
|
||||
|
||||
.section-copy h1 {
|
||||
@apply text-3xl font-black;
|
||||
}
|
||||
|
||||
.section-copy p,
|
||||
.summary-grid dt,
|
||||
.invite-row span,
|
||||
.invite-row small,
|
||||
.empty-state,
|
||||
.connector-copy span,
|
||||
.connector-link span,
|
||||
.workflow-rule span,
|
||||
.workflow-step-copy span {
|
||||
@apply text-sm leading-6;
|
||||
color: #526178;
|
||||
}
|
||||
|
||||
.summary-grid {
|
||||
@apply grid gap-4 sm:grid-cols-2;
|
||||
}
|
||||
|
||||
.summary-grid div {
|
||||
@apply rounded-[1rem] border p-4;
|
||||
background: #f8fafc;
|
||||
border-color: rgba(23, 32, 51, 0.08);
|
||||
}
|
||||
|
||||
.summary-grid dt {
|
||||
@apply text-xs font-bold uppercase tracking-[0.16em];
|
||||
}
|
||||
|
||||
.summary-grid dd {
|
||||
@apply mt-2 text-base font-semibold;
|
||||
}
|
||||
|
||||
.form-stack {
|
||||
@apply flex flex-col gap-4;
|
||||
}
|
||||
|
||||
.field {
|
||||
@apply flex flex-col gap-2;
|
||||
}
|
||||
|
||||
.field span {
|
||||
@apply text-sm font-semibold;
|
||||
color: #172033;
|
||||
}
|
||||
|
||||
.field input,
|
||||
.field select {
|
||||
@apply rounded-[1rem] border px-4 py-3 text-sm;
|
||||
background: #fffdf8;
|
||||
border-color: rgba(23, 32, 51, 0.1);
|
||||
color: #172033;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.primary-button {
|
||||
@apply inline-flex items-center justify-center rounded-full px-5 py-3 text-sm font-semibold;
|
||||
background: #172033;
|
||||
color: #fffaf2;
|
||||
}
|
||||
|
||||
.invite-list,
|
||||
.connector-list,
|
||||
.workflow-rule-list,
|
||||
.workflow-steps {
|
||||
@apply flex flex-col gap-3;
|
||||
}
|
||||
|
||||
.invite-row,
|
||||
.empty-state,
|
||||
.connector-row,
|
||||
.workflow-rule,
|
||||
.workflow-step {
|
||||
@apply rounded-[1rem] border px-4 py-4;
|
||||
background: #fffaf2;
|
||||
border-color: rgba(23, 32, 51, 0.08);
|
||||
}
|
||||
|
||||
.invite-row {
|
||||
@apply flex items-start justify-between gap-4;
|
||||
}
|
||||
|
||||
.invite-row div,
|
||||
.connector-copy,
|
||||
.workflow-rule,
|
||||
.workflow-step-copy {
|
||||
@apply flex flex-col gap-1;
|
||||
}
|
||||
|
||||
.connector-row {
|
||||
@apply flex flex-col gap-4 md:flex-row md:items-center md:justify-between;
|
||||
}
|
||||
|
||||
.connector-main,
|
||||
.workflow-step {
|
||||
@apply flex items-start gap-4;
|
||||
}
|
||||
|
||||
.connector-icon,
|
||||
.workflow-step-icon {
|
||||
@apply inline-flex h-11 w-11 flex-shrink-0 items-center justify-center rounded-2xl;
|
||||
background: rgba(15, 118, 110, 0.1);
|
||||
color: #0f766e;
|
||||
}
|
||||
|
||||
.connector-status {
|
||||
@apply inline-flex w-fit items-center rounded-full px-3 py-1 text-xs font-bold uppercase tracking-[0.18em];
|
||||
background: rgba(245, 158, 11, 0.14);
|
||||
}
|
||||
|
||||
.connector-link {
|
||||
@apply inline-flex w-fit items-center gap-3 rounded-full px-5 py-3 text-sm font-semibold no-underline transition;
|
||||
background: #172033;
|
||||
color: #fffaf2;
|
||||
}
|
||||
|
||||
.connector-link:hover {
|
||||
background: #0f172a;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user