chore: add missing multi-level editor for approval workflow, rename projects to campaings.
This commit is contained in:
376
frontend/src/features/campaigns/views/CampaignsView.vue
Normal file
376
frontend/src/features/campaigns/views/CampaignsView.vue
Normal 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>
|
||||
Reference in New Issue
Block a user