refactor: organize frontend by feature
Some checks failed
Backend CI/CD / build_and_deploy (push) Has been cancelled
Frontend CI/CD / build_and_deploy (push) Has been cancelled

This commit is contained in:
2026-04-25 01:05:50 -04:00
parent b6eb692c27
commit 121757546a
60 changed files with 107 additions and 183 deletions

View 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>