752 lines
21 KiB
Vue
752 lines
21 KiB
Vue
<script setup>
|
|
import { computed, ref, watch } from 'vue';
|
|
import { useI18n } from 'vue-i18n';
|
|
import { useRoute, useRouter } from 'vue-router';
|
|
import { mdiChevronLeft, mdiChevronRight } from '@mdi/js';
|
|
import { useCampaignsStore } from '@/features/campaigns/stores/campaignsStore.js';
|
|
import { useContentItemsStore } from '@/features/content/stores/contentItemsStore.js';
|
|
|
|
const { t, locale } = useI18n();
|
|
const route = useRoute();
|
|
const router = useRouter();
|
|
const campaignsStore = useCampaignsStore();
|
|
const contentItemsStore = useContentItemsStore();
|
|
|
|
const today = startOfDay(new Date());
|
|
const viewMode = ref(parseViewMode(route.query.view));
|
|
const cursorDate = ref(parseCursorDate(route.query.date, today));
|
|
|
|
const contentStatusMeta = {
|
|
Draft: { tone: 'production' },
|
|
'In production': { tone: 'production' },
|
|
'In approval': { tone: 'approval' },
|
|
Approved: { tone: 'ready' },
|
|
Scheduled: { tone: 'ready' },
|
|
Published: { tone: 'published' },
|
|
};
|
|
|
|
const contentItemsByCampaignId = computed(() => {
|
|
const grouped = new Map();
|
|
|
|
for (const item of contentItemsStore.items) {
|
|
const existing = grouped.get(item.campaignId) ?? [];
|
|
existing.push(item);
|
|
grouped.set(item.campaignId, existing);
|
|
}
|
|
|
|
return grouped;
|
|
});
|
|
|
|
const calendarEntries = computed(() => {
|
|
const campaignEntries = campaignsStore.campaigns
|
|
.filter(campaign => campaign.endDate || campaign.startDate)
|
|
.map(campaign => buildCampaignEntry(campaign));
|
|
|
|
const contentEntries = contentItemsStore.items
|
|
.filter(item => item.dueDate)
|
|
.map(item => buildContentEntry(item));
|
|
|
|
return [...campaignEntries, ...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) => buildDay(addDays(start, index), 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 upcomingItems = computed(() =>
|
|
[...contentItemsStore.items].sort((left, right) => {
|
|
if (!left.dueDate && !right.dueDate) {
|
|
return left.title.localeCompare(right.title);
|
|
}
|
|
|
|
if (!left.dueDate) {
|
|
return 1;
|
|
}
|
|
|
|
if (!right.dueDate) {
|
|
return -1;
|
|
}
|
|
|
|
return new Date(left.dueDate).getTime() - new Date(right.dueDate).getTime();
|
|
})
|
|
);
|
|
|
|
const isLoading = computed(() =>
|
|
contentItemsStore.isLoading || campaignsStore.isLoading
|
|
);
|
|
|
|
const pageError = computed(() =>
|
|
contentItemsStore.error || campaignsStore.error
|
|
);
|
|
|
|
const isCalendarView = computed(() => viewMode.value === 'month' || viewMode.value === 'week');
|
|
|
|
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' };
|
|
const campaign = campaignsStore.campaigns.find(candidate => candidate.id === item.campaignId);
|
|
|
|
return {
|
|
id: item.id,
|
|
type: 'content',
|
|
title: item.title,
|
|
subtitle: campaign?.name ?? t('dashboard.labels.unassignedCampaign'),
|
|
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 }, query: { returnTo: route.fullPath } },
|
|
};
|
|
}
|
|
|
|
function buildCampaignEntry(campaign) {
|
|
const campaignItems = contentItemsByCampaignId.value.get(campaign.id) ?? [];
|
|
const approvedCount = campaignItems.filter(item => ['Approved', 'Scheduled', 'Published'].includes(item.status)).length;
|
|
|
|
return {
|
|
id: campaign.id,
|
|
type: 'campaign',
|
|
title: campaign.name,
|
|
subtitle: campaignItems.length
|
|
? t('dashboard.campaignProgress', { scheduled: campaignItems.length, approved: approvedCount })
|
|
: t('dashboard.readiness.missing'),
|
|
scheduledAt: new Date(campaign.endDate ?? campaign.startDate),
|
|
dayKey: dateKey(campaign.endDate ?? campaign.startDate),
|
|
timeLabel: t('dashboard.campaignDeadline'),
|
|
tone: campaignItems.length ? 'campaign' : 'risk',
|
|
route: { name: 'campaign-detail', params: { campaignId: campaign.id } },
|
|
};
|
|
}
|
|
|
|
function setView(mode) {
|
|
viewMode.value = mode;
|
|
|
|
if (mode === 'month') {
|
|
cursorDate.value = startOfMonth(cursorDate.value);
|
|
}
|
|
|
|
if (mode === 'week') {
|
|
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 = viewMode.value === 'month' ? startOfMonth(today) : startOfWeek(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 formatDueDate(value) {
|
|
return value ? new Date(value).toLocaleDateString() : t('contentItems.noDueDate');
|
|
}
|
|
|
|
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();
|
|
}
|
|
|
|
function parseViewMode(value) {
|
|
return ['month', 'week', 'upcoming'].includes(value) ? value : 'month';
|
|
}
|
|
|
|
function parseCursorDate(value, fallback) {
|
|
if (typeof value !== 'string') {
|
|
return fallback;
|
|
}
|
|
|
|
const parsed = new Date(`${value}T00:00:00`);
|
|
return Number.isNaN(parsed.getTime()) ? fallback : startOfDay(parsed);
|
|
}
|
|
|
|
watch(
|
|
() => route.query,
|
|
query => {
|
|
viewMode.value = parseViewMode(query.view);
|
|
cursorDate.value = parseCursorDate(query.date, today);
|
|
}
|
|
);
|
|
|
|
watch(
|
|
() => [viewMode.value, dateKey(cursorDate.value)],
|
|
([view, date]) => {
|
|
if (route.query.view === view && route.query.date === date) {
|
|
return;
|
|
}
|
|
|
|
router.replace({
|
|
name: 'content-items',
|
|
query: {
|
|
...route.query,
|
|
view,
|
|
date,
|
|
},
|
|
});
|
|
}
|
|
);
|
|
</script>
|
|
|
|
<template>
|
|
<section class="page-shell">
|
|
<div class="header">
|
|
<div>
|
|
<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>
|
|
</div>
|
|
|
|
<div
|
|
v-if="isLoading"
|
|
class="page-message"
|
|
>
|
|
{{ t('contentItems.loading') }}
|
|
</div>
|
|
|
|
<div
|
|
v-else-if="pageError"
|
|
class="page-message error"
|
|
>
|
|
{{ pageError }}
|
|
</div>
|
|
|
|
<article
|
|
v-else-if="isCalendarView"
|
|
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>
|
|
|
|
<button
|
|
class="text-button"
|
|
type="button"
|
|
@click="jumpToToday"
|
|
>
|
|
{{ t('dashboard.today') }}
|
|
</button>
|
|
</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>
|
|
|
|
<div
|
|
v-else-if="upcomingItems.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"
|
|
>
|
|
<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>
|
|
</div>
|
|
|
|
<div
|
|
v-else
|
|
class="page-message"
|
|
>
|
|
{{ t('contentItems.empty') }}
|
|
</div>
|
|
</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;
|
|
}
|
|
|
|
.header {
|
|
@apply flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between;
|
|
}
|
|
|
|
.header h1 {
|
|
@apply text-4xl font-black;
|
|
color: #172033;
|
|
}
|
|
|
|
.item-card span,
|
|
.status-row em,
|
|
.status-row small {
|
|
@apply text-sm leading-6 not-italic;
|
|
color: #526178;
|
|
}
|
|
|
|
.view-toggle {
|
|
@apply inline-flex w-fit rounded-full border p-1;
|
|
background: #f8fafc;
|
|
border-color: rgba(23, 32, 51, 0.1);
|
|
}
|
|
|
|
.toggle-button,
|
|
.icon-button,
|
|
.text-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;
|
|
}
|
|
|
|
.toggle-button {
|
|
@apply border-0 bg-transparent;
|
|
}
|
|
|
|
.toggle-button-active {
|
|
background: #172033;
|
|
color: #ffffff;
|
|
}
|
|
|
|
.icon-button {
|
|
@apply h-10 w-10 px-0 py-0;
|
|
}
|
|
|
|
.icon-button:hover,
|
|
.text-button:hover,
|
|
.toggle-button:hover {
|
|
background: #eef4ff;
|
|
}
|
|
|
|
.page-message,
|
|
.item-card {
|
|
@apply rounded-[1.5rem] border;
|
|
background: rgba(255, 255, 255, 0.9);
|
|
border-color: rgba(23, 32, 51, 0.08);
|
|
}
|
|
|
|
.page-message {
|
|
@apply p-5 text-sm;
|
|
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 {
|
|
@apply flex items-center gap-2;
|
|
}
|
|
|
|
.calendar-period {
|
|
@apply min-w-0 px-2 text-base font-bold md:text-lg;
|
|
color: #172033;
|
|
}
|
|
|
|
.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,
|
|
.item-card {
|
|
@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,
|
|
.item-card:hover {
|
|
transform: translateY(-1px);
|
|
}
|
|
|
|
.calendar-entry strong,
|
|
.item-card 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.campaign {
|
|
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);
|
|
}
|
|
|
|
.item-grid {
|
|
@apply grid gap-4 md:grid-cols-2 xl:grid-cols-3;
|
|
}
|
|
|
|
.item-card {
|
|
@apply gap-4 p-5 no-underline transition;
|
|
}
|
|
|
|
.version-chip {
|
|
@apply w-fit rounded-full px-4 py-2 text-xs font-bold uppercase tracking-[0.16em];
|
|
background: rgba(23, 32, 51, 0.08);
|
|
color: #172033;
|
|
}
|
|
|
|
.status-row {
|
|
@apply flex items-center justify-between gap-3;
|
|
}
|
|
|
|
@media (max-width: 960px) {
|
|
.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 {
|
|
@apply justify-between;
|
|
}
|
|
|
|
.calendar-grid-head,
|
|
.calendar-grid {
|
|
min-width: 46rem;
|
|
}
|
|
|
|
.calendar-card {
|
|
overflow-x: auto;
|
|
}
|
|
}
|
|
</style>
|