chore: add missing multi-level editor for approval workflow, rename projects to campaings.

This commit is contained in:
2026-05-01 14:23:37 -04:00
parent 5077f557f4
commit 884ca4b96d
148 changed files with 11567 additions and 1383 deletions

View File

@@ -0,0 +1,376 @@
<script setup>
import { computed, reactive, ref, watch } from 'vue';
import { useRoute } from 'vue-router';
import { useI18n } from 'vue-i18n';
import { useAuthStore } from '@/features/auth/stores/authStore.js';
import { useClientsStore } from '@/features/clients/stores/clientsStore.js';
import { useWorkspaceStore } from '@/features/workspaces/stores/workspaceStore.js';
import { useCampaignsStore } from '@/features/campaigns/stores/campaignsStore.js';
const route = useRoute();
const authStore = useAuthStore();
const workspaceStore = useWorkspaceStore();
const clientsStore = useClientsStore();
const campaignsStore = useCampaignsStore();
const { t } = useI18n();
const isCreateFormVisible = ref(false);
const formError = ref(null);
const form = reactive({
name: '',
startDate: '',
endDate: '',
description: '',
notes: '',
});
const operationalClient = computed(() => clientsStore.operationalClient);
function resetForm() {
form.name = '';
form.startDate = '';
form.endDate = '';
form.description = '';
form.notes = '';
formError.value = null;
}
function openCreateForm() {
resetForm();
isCreateFormVisible.value = true;
}
async function submitForm() {
if (campaignsStore.isCreating) {
return;
}
formError.value = null;
if (!form.name || !form.startDate || !form.endDate) {
formError.value = t('campaigns.errors.required');
return;
}
if (new Date(form.endDate) < new Date(form.startDate)) {
formError.value = t('campaigns.errors.invalidDateRange');
return;
}
if (!operationalClient.value?.id) {
formError.value = t('campaigns.errors.workspaceAccountRequired');
return;
}
try {
await campaignsStore.createCampaign({
clientId: operationalClient.value.id,
name: form.name,
startDate: new Date(form.startDate).toISOString(),
endDate: new Date(form.endDate).toISOString(),
description: form.description,
notes: form.notes,
});
isCreateFormVisible.value = false;
resetForm();
} catch (error) {
formError.value = t('campaigns.errors.createFailed');
}
}
watch(
() => route.query.create,
createValue => {
if (createValue === 'true') {
openCreateForm();
}
},
{ immediate: true }
);
function formatCampaignDateRange(campaign) {
if (!campaign?.startDate || !campaign?.endDate) {
return t('campaigns.noDateRange');
}
const start = new Date(campaign.startDate);
const end = new Date(campaign.endDate);
return new Intl.DateTimeFormat(undefined, {
month: 'short',
day: 'numeric',
year: 'numeric',
}).formatRange(start, end);
}
</script>
<template>
<section class="page-shell">
<div class="header">
<div>
<div class="eyebrow">{{ t('campaigns.eyebrow') }}</div>
<h1>{{ t('campaigns.title') }}</h1>
<p>{{ t('campaigns.description') }}</p>
</div>
</div>
<div class="action-row">
<button
v-if="authStore.isManager"
class="create-button"
@click="openCreateForm"
>
{{ t('campaigns.newCampaign') }}
</button>
</div>
<div
v-if="isCreateFormVisible"
class="create-panel"
>
<div class="panel-header">
<strong>{{ t('campaigns.createTitle') }}</strong>
<span>{{ workspaceStore.activeWorkspace?.name }}</span>
</div>
<div
v-if="formError"
class="page-message error"
>
{{ formError }}
</div>
<div class="form-grid">
<label class="field">
<span>{{ t('campaigns.fields.startDate') }}</span>
<input
v-model="form.startDate"
type="date"
:disabled="campaignsStore.isCreating"
/>
</label>
<label class="field">
<span>{{ t('campaigns.fields.endDate') }}</span>
<input
v-model="form.endDate"
type="date"
:disabled="campaignsStore.isCreating"
/>
</label>
<label class="field field-wide">
<span>{{ t('campaigns.fields.name') }}</span>
<input
v-model="form.name"
type="text"
:disabled="campaignsStore.isCreating"
/>
</label>
<label class="field field-wide">
<span>{{ t('campaigns.fields.description') }}</span>
<textarea
v-model="form.description"
:disabled="campaignsStore.isCreating"
></textarea>
</label>
<label class="field field-wide">
<span>{{ t('campaigns.fields.notes') }}</span>
<textarea
v-model="form.notes"
:disabled="campaignsStore.isCreating"
></textarea>
</label>
</div>
<div class="panel-actions">
<button
class="secondary"
:disabled="campaignsStore.isCreating"
@click="isCreateFormVisible = false"
>
{{ t('common.cancel') }}
</button>
<button
class="primary"
:disabled="campaignsStore.isCreating"
@click="submitForm"
>
<v-progress-circular
v-if="campaignsStore.isCreating"
indeterminate
:size="16"
:width="2"
/>
<span>{{ campaignsStore.isCreating ? t('common.creating') : t('campaigns.createTitle') }}</span>
</button>
</div>
</div>
<div
v-if="campaignsStore.isLoading"
class="page-message"
>
{{ t('campaigns.loading') }}
</div>
<div
v-else-if="campaignsStore.error"
class="page-message error"
>
{{ campaignsStore.error }}
</div>
<div class="campaign-stack">
<router-link
v-for="campaign in campaignsStore.campaigns"
:key="campaign.id"
:to="{ name: 'campaign-detail', params: { campaignId: campaign.id } }"
class="campaign-row"
>
<div>
<strong>{{ campaign.name }}</strong>
<span>{{ campaign.description || campaign.status }}</span>
</div>
<div class="campaign-meta">
<span>{{ workspaceStore.activeWorkspace?.name || t('nav.noWorkspace') }}</span>
<em>{{ formatCampaignDateRange(campaign) }}</em>
</div>
</router-link>
</div>
<div
v-if="!campaignsStore.isLoading && !campaignsStore.campaigns.length"
class="page-message"
>
{{ t('campaigns.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;
}
.eyebrow {
@apply text-xs font-bold uppercase tracking-[0.24em];
color: #0f766e;
}
.header h1 {
@apply mt-2 text-4xl font-black;
color: #172033;
}
.header p,
.panel-header span,
.campaign-row span,
.campaign-meta span,
.campaign-meta em {
@apply text-sm leading-6 not-italic;
color: #526178;
}
.action-row {
@apply flex justify-end;
}
.create-button,
.primary,
.secondary {
@apply inline-flex items-center justify-center rounded-full px-5 py-3 text-sm font-bold transition;
}
.create-button,
.primary {
background: #172033;
color: #fffaf2;
}
.secondary {
background: rgba(23, 32, 51, 0.06);
color: #172033;
}
.create-panel,
.campaign-row {
@apply rounded-[1.5rem] border;
background: rgba(255, 255, 255, 0.9);
border-color: rgba(23, 32, 51, 0.08);
}
.create-panel {
@apply flex flex-col gap-5 p-5;
}
.panel-header {
@apply flex flex-col gap-2;
}
.panel-header strong,
.campaign-row strong {
color: #172033;
}
.form-grid {
@apply grid gap-4 md:grid-cols-2;
}
.field {
@apply flex flex-col gap-2 text-sm font-semibold;
color: #172033;
}
.field-wide {
@apply md:col-span-2;
}
.field input {
@apply rounded-2xl border px-4 py-3 text-sm;
border-color: rgba(23, 32, 51, 0.08);
background: rgba(255, 255, 255, 0.95);
color: #172033;
}
.field textarea {
@apply min-h-28 rounded-2xl border px-4 py-3 text-sm;
border-color: rgba(23, 32, 51, 0.08);
background: rgba(255, 255, 255, 0.95);
color: #172033;
resize: vertical;
}
.panel-actions {
@apply flex justify-end gap-3;
}
.campaign-stack {
@apply flex flex-col gap-4;
}
.campaign-row {
@apply flex flex-col justify-between gap-4 p-5 no-underline lg:flex-row lg:items-center;
}
.campaign-row strong {
@apply block text-xl font-black;
}
.campaign-meta {
@apply flex flex-col items-start gap-1 lg:items-end;
}
.page-message {
@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);
color: #526178;
}
.page-message.error {
color: #b91c1c;
}
</style>