feat: add tenant and user management with migrations, handlers, and tests
- Implemented tenant and user management features including: - Tenant creation and management with associated migrations. - User creation and management with associated migrations. - Tenant membership management with associated migrations. - Platform user roles management with associated migrations. - Quota management with associated migrations. - Article and template management with associated migrations. - Added HTTP handlers for templates and workspaces. - Created tests for protected and public routes. - Introduced a script to check tenant scope in SQL queries. - Documented task plan for backend completion and frontend foundation.
This commit is contained in:
@@ -0,0 +1,937 @@
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
DeleteOutlined,
|
||||
EditOutlined,
|
||||
FileSearchOutlined,
|
||||
PlusOutlined,
|
||||
} from "@ant-design/icons-vue";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/vue-query";
|
||||
import { message } from "ant-design-vue";
|
||||
import type { Brand, Competitor, Keyword, Question } from "@geo/shared-types";
|
||||
import type { TableColumnsType } from "ant-design-vue";
|
||||
import { computed, reactive, ref, watch } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
|
||||
import PageHero from "@/components/PageHero.vue";
|
||||
import { brandsApi } from "@/lib/api";
|
||||
import { formatError } from "@/lib/errors";
|
||||
|
||||
const queryClient = useQueryClient();
|
||||
const { t } = useI18n();
|
||||
|
||||
const activeTab = ref("keywords");
|
||||
const selectedBrandId = ref<number | null>(null);
|
||||
const selectedKeywordId = ref<number | null>(null);
|
||||
const versionsDrawerOpen = ref(false);
|
||||
const selectedQuestionId = ref<number | null>(null);
|
||||
|
||||
const brandModalOpen = ref(false);
|
||||
const editingBrandId = ref<number | null>(null);
|
||||
const keywordModalOpen = ref(false);
|
||||
const editingKeywordId = ref<number | null>(null);
|
||||
const questionModalOpen = ref(false);
|
||||
const editingQuestionId = ref<number | null>(null);
|
||||
const competitorModalOpen = ref(false);
|
||||
const editingCompetitorId = ref<number | null>(null);
|
||||
|
||||
const brandForm = reactive({
|
||||
name: "",
|
||||
description: "",
|
||||
});
|
||||
|
||||
const keywordForm = reactive({
|
||||
name: "",
|
||||
});
|
||||
|
||||
const questionForm = reactive({
|
||||
keyword_id: undefined as number | undefined,
|
||||
question_text: "",
|
||||
});
|
||||
|
||||
const competitorForm = reactive({
|
||||
name: "",
|
||||
website: "",
|
||||
description: "",
|
||||
product_lines: "",
|
||||
});
|
||||
|
||||
const brandListQuery = useQuery({
|
||||
queryKey: ["brands", "list"],
|
||||
queryFn: () => brandsApi.list(),
|
||||
});
|
||||
|
||||
const brandDetailQuery = useQuery({
|
||||
queryKey: computed(() => ["brands", "detail", selectedBrandId.value]),
|
||||
enabled: computed(() => Boolean(selectedBrandId.value)),
|
||||
queryFn: () => brandsApi.detail(selectedBrandId.value as number),
|
||||
});
|
||||
|
||||
const keywordsQuery = useQuery({
|
||||
queryKey: computed(() => ["brands", selectedBrandId.value, "keywords"]),
|
||||
enabled: computed(() => Boolean(selectedBrandId.value)),
|
||||
queryFn: () => brandsApi.listKeywords(selectedBrandId.value as number),
|
||||
});
|
||||
|
||||
const allQuestionsQuery = useQuery({
|
||||
queryKey: computed(() => ["brands", selectedBrandId.value, "questions", "all"]),
|
||||
enabled: computed(() => Boolean(selectedBrandId.value)),
|
||||
queryFn: () => brandsApi.listQuestions(selectedBrandId.value as number),
|
||||
});
|
||||
|
||||
const filteredQuestionsQuery = useQuery({
|
||||
queryKey: computed(() => ["brands", selectedBrandId.value, "questions", selectedKeywordId.value]),
|
||||
enabled: computed(() => Boolean(selectedBrandId.value)),
|
||||
queryFn: () => brandsApi.listQuestions(selectedBrandId.value as number, selectedKeywordId.value),
|
||||
});
|
||||
|
||||
const questionVersionsQuery = useQuery({
|
||||
queryKey: computed(() => ["brands", selectedBrandId.value, "question-versions"]),
|
||||
enabled: computed(() => Boolean(selectedBrandId.value)),
|
||||
queryFn: () => brandsApi.listQuestionVersions(selectedBrandId.value as number),
|
||||
});
|
||||
|
||||
const competitorsQuery = useQuery({
|
||||
queryKey: computed(() => ["brands", selectedBrandId.value, "competitors"]),
|
||||
enabled: computed(() => Boolean(selectedBrandId.value)),
|
||||
queryFn: () => brandsApi.listCompetitors(selectedBrandId.value as number),
|
||||
});
|
||||
|
||||
const brandMutations = {
|
||||
create: useMutation({
|
||||
mutationFn: () =>
|
||||
brandsApi.create({
|
||||
name: brandForm.name.trim(),
|
||||
description: brandForm.description.trim() || null,
|
||||
}),
|
||||
onSuccess: async (brand) => {
|
||||
message.success(t("brands.messages.createBrand"));
|
||||
brandModalOpen.value = false;
|
||||
selectedBrandId.value = brand.id;
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: ["brands"] }),
|
||||
queryClient.invalidateQueries({ queryKey: ["workspace"] }),
|
||||
]);
|
||||
},
|
||||
onError: (error) => message.error(formatError(error)),
|
||||
}),
|
||||
update: useMutation({
|
||||
mutationFn: () =>
|
||||
brandsApi.update(editingBrandId.value as number, {
|
||||
name: brandForm.name.trim(),
|
||||
description: brandForm.description.trim() || null,
|
||||
}),
|
||||
onSuccess: async () => {
|
||||
message.success(t("brands.messages.updateBrand"));
|
||||
brandModalOpen.value = false;
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: ["brands"] }),
|
||||
queryClient.invalidateQueries({ queryKey: ["workspace"] }),
|
||||
]);
|
||||
},
|
||||
onError: (error) => message.error(formatError(error)),
|
||||
}),
|
||||
remove: useMutation({
|
||||
mutationFn: (brandId: number) => brandsApi.remove(brandId),
|
||||
onSuccess: async () => {
|
||||
message.success(t("brands.messages.deleteBrand"));
|
||||
selectedBrandId.value = null;
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: ["brands"] }),
|
||||
queryClient.invalidateQueries({ queryKey: ["workspace"] }),
|
||||
]);
|
||||
},
|
||||
onError: (error) => message.error(formatError(error)),
|
||||
}),
|
||||
};
|
||||
|
||||
const keywordMutations = {
|
||||
create: useMutation({
|
||||
mutationFn: () =>
|
||||
brandsApi.createKeyword(selectedBrandId.value as number, {
|
||||
name: keywordForm.name.trim(),
|
||||
}),
|
||||
onSuccess: async () => {
|
||||
message.success(t("brands.messages.createKeyword"));
|
||||
keywordModalOpen.value = false;
|
||||
await queryClient.invalidateQueries({ queryKey: ["brands", selectedBrandId.value, "keywords"] });
|
||||
},
|
||||
onError: (error) => message.error(formatError(error)),
|
||||
}),
|
||||
update: useMutation({
|
||||
mutationFn: () =>
|
||||
brandsApi.updateKeyword(selectedBrandId.value as number, editingKeywordId.value as number, {
|
||||
name: keywordForm.name.trim(),
|
||||
}),
|
||||
onSuccess: async () => {
|
||||
message.success(t("brands.messages.updateKeyword"));
|
||||
keywordModalOpen.value = false;
|
||||
await queryClient.invalidateQueries({ queryKey: ["brands", selectedBrandId.value, "keywords"] });
|
||||
},
|
||||
onError: (error) => message.error(formatError(error)),
|
||||
}),
|
||||
remove: useMutation({
|
||||
mutationFn: (keywordId: number) =>
|
||||
brandsApi.removeKeyword(selectedBrandId.value as number, keywordId),
|
||||
onSuccess: async () => {
|
||||
message.success(t("brands.messages.deleteKeyword"));
|
||||
if (selectedKeywordId.value === editingKeywordId.value) {
|
||||
selectedKeywordId.value = null;
|
||||
}
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: ["brands", selectedBrandId.value, "keywords"] }),
|
||||
queryClient.invalidateQueries({ queryKey: ["brands", selectedBrandId.value, "questions"] }),
|
||||
]);
|
||||
},
|
||||
onError: (error) => message.error(formatError(error)),
|
||||
}),
|
||||
};
|
||||
|
||||
const questionMutations = {
|
||||
create: useMutation({
|
||||
mutationFn: () =>
|
||||
brandsApi.createQuestion(selectedBrandId.value as number, {
|
||||
keyword_id: questionForm.keyword_id as number,
|
||||
question_text: questionForm.question_text.trim(),
|
||||
}),
|
||||
onSuccess: async () => {
|
||||
message.success(t("brands.messages.createQuestion"));
|
||||
questionModalOpen.value = false;
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: ["brands", selectedBrandId.value, "questions"] }),
|
||||
queryClient.invalidateQueries({ queryKey: ["brands", selectedBrandId.value, "question-versions"] }),
|
||||
]);
|
||||
},
|
||||
onError: (error) => message.error(formatError(error)),
|
||||
}),
|
||||
update: useMutation({
|
||||
mutationFn: () =>
|
||||
brandsApi.updateQuestion(selectedBrandId.value as number, editingQuestionId.value as number, {
|
||||
question_text: questionForm.question_text.trim(),
|
||||
}),
|
||||
onSuccess: async () => {
|
||||
message.success(t("brands.messages.updateQuestion"));
|
||||
questionModalOpen.value = false;
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: ["brands", selectedBrandId.value, "questions"] }),
|
||||
queryClient.invalidateQueries({ queryKey: ["brands", selectedBrandId.value, "question-versions"] }),
|
||||
]);
|
||||
},
|
||||
onError: (error) => message.error(formatError(error)),
|
||||
}),
|
||||
remove: useMutation({
|
||||
mutationFn: (questionId: number) =>
|
||||
brandsApi.removeQuestion(selectedBrandId.value as number, questionId),
|
||||
onSuccess: async () => {
|
||||
message.success(t("brands.messages.deleteQuestion"));
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: ["brands", selectedBrandId.value, "questions"] }),
|
||||
queryClient.invalidateQueries({ queryKey: ["brands", selectedBrandId.value, "question-versions"] }),
|
||||
]);
|
||||
},
|
||||
onError: (error) => message.error(formatError(error)),
|
||||
}),
|
||||
};
|
||||
|
||||
const competitorMutations = {
|
||||
create: useMutation({
|
||||
mutationFn: () =>
|
||||
brandsApi.createCompetitor(selectedBrandId.value as number, {
|
||||
name: competitorForm.name.trim(),
|
||||
website: competitorForm.website.trim() || null,
|
||||
description: competitorForm.description.trim() || null,
|
||||
product_lines_json: parseProductLines(competitorForm.product_lines),
|
||||
}),
|
||||
onSuccess: async () => {
|
||||
message.success(t("brands.messages.createCompetitor"));
|
||||
competitorModalOpen.value = false;
|
||||
await queryClient.invalidateQueries({ queryKey: ["brands", selectedBrandId.value, "competitors"] });
|
||||
},
|
||||
onError: (error) => message.error(formatError(error)),
|
||||
}),
|
||||
update: useMutation({
|
||||
mutationFn: () =>
|
||||
brandsApi.updateCompetitor(selectedBrandId.value as number, editingCompetitorId.value as number, {
|
||||
name: competitorForm.name.trim(),
|
||||
website: competitorForm.website.trim() || null,
|
||||
description: competitorForm.description.trim() || null,
|
||||
product_lines_json: parseProductLines(competitorForm.product_lines),
|
||||
}),
|
||||
onSuccess: async () => {
|
||||
message.success(t("brands.messages.updateCompetitor"));
|
||||
competitorModalOpen.value = false;
|
||||
await queryClient.invalidateQueries({ queryKey: ["brands", selectedBrandId.value, "competitors"] });
|
||||
},
|
||||
onError: (error) => message.error(formatError(error)),
|
||||
}),
|
||||
remove: useMutation({
|
||||
mutationFn: (competitorId: number) =>
|
||||
brandsApi.removeCompetitor(selectedBrandId.value as number, competitorId),
|
||||
onSuccess: async () => {
|
||||
message.success(t("brands.messages.deleteCompetitor"));
|
||||
await queryClient.invalidateQueries({ queryKey: ["brands", selectedBrandId.value, "competitors"] });
|
||||
},
|
||||
onError: (error) => message.error(formatError(error)),
|
||||
}),
|
||||
};
|
||||
|
||||
const selectedBrand = computed(() => brandDetailQuery.data.value);
|
||||
const selectedKeyword = computed(() =>
|
||||
keywordsQuery.data.value?.find((item) => item.id === selectedKeywordId.value) ?? null,
|
||||
);
|
||||
const versionItems = computed(() =>
|
||||
(questionVersionsQuery.data.value || []).filter((item) => item.question_id === selectedQuestionId.value),
|
||||
);
|
||||
|
||||
const questionColumns = computed<TableColumnsType<Question>>(() => [
|
||||
{
|
||||
title: t("brands.sections.questions"),
|
||||
dataIndex: "question_text",
|
||||
key: "question_text",
|
||||
},
|
||||
{
|
||||
title: t("common.createdAt"),
|
||||
dataIndex: "created_at",
|
||||
key: "created_at",
|
||||
width: 160,
|
||||
},
|
||||
{
|
||||
title: t("common.actions"),
|
||||
key: "actions",
|
||||
width: 180,
|
||||
},
|
||||
]);
|
||||
|
||||
watch(
|
||||
() => brandListQuery.data.value,
|
||||
(brands) => {
|
||||
if (!selectedBrandId.value && brands?.length) {
|
||||
selectedBrandId.value = brands[0].id;
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
watch(
|
||||
() => keywordsQuery.data.value,
|
||||
(keywords) => {
|
||||
if (!keywords?.length) {
|
||||
selectedKeywordId.value = null;
|
||||
return;
|
||||
}
|
||||
|
||||
const stillExists = keywords.some((item) => item.id === selectedKeywordId.value);
|
||||
if (!stillExists) {
|
||||
selectedKeywordId.value = keywords[0].id;
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
function parseProductLines(value: string): string[] | undefined {
|
||||
const lines = value
|
||||
.split(",")
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean);
|
||||
return lines.length ? lines : undefined;
|
||||
}
|
||||
|
||||
function stringifyProductLines(value: unknown): string {
|
||||
if (Array.isArray(value)) {
|
||||
return value.filter((item) => typeof item === "string").join(", ");
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
function openBrandModal(brand?: Brand): void {
|
||||
editingBrandId.value = brand?.id ?? null;
|
||||
brandForm.name = brand?.name ?? "";
|
||||
brandForm.description = brand?.description ?? "";
|
||||
brandModalOpen.value = true;
|
||||
}
|
||||
|
||||
function openKeywordModal(keyword?: Keyword): void {
|
||||
if (!selectedBrandId.value) {
|
||||
message.warning(t("brands.messages.chooseBrand"));
|
||||
return;
|
||||
}
|
||||
editingKeywordId.value = keyword?.id ?? null;
|
||||
keywordForm.name = keyword?.name ?? "";
|
||||
keywordModalOpen.value = true;
|
||||
}
|
||||
|
||||
function openQuestionModal(question?: Question): void {
|
||||
if (!selectedBrandId.value) {
|
||||
message.warning(t("brands.messages.chooseBrand"));
|
||||
return;
|
||||
}
|
||||
editingQuestionId.value = question?.id ?? null;
|
||||
questionForm.keyword_id = question?.keyword_id ?? selectedKeywordId.value ?? undefined;
|
||||
questionForm.question_text = question?.question_text ?? "";
|
||||
questionModalOpen.value = true;
|
||||
}
|
||||
|
||||
function openCompetitorModal(competitor?: Competitor): void {
|
||||
if (!selectedBrandId.value) {
|
||||
message.warning(t("brands.messages.chooseBrand"));
|
||||
return;
|
||||
}
|
||||
editingCompetitorId.value = competitor?.id ?? null;
|
||||
competitorForm.name = competitor?.name ?? "";
|
||||
competitorForm.website = competitor?.website ?? "";
|
||||
competitorForm.description = competitor?.description ?? "";
|
||||
competitorForm.product_lines = stringifyProductLines(competitor?.product_lines_json);
|
||||
competitorModalOpen.value = true;
|
||||
}
|
||||
|
||||
function openVersions(questionId: number): void {
|
||||
selectedQuestionId.value = questionId;
|
||||
versionsDrawerOpen.value = true;
|
||||
}
|
||||
|
||||
async function submitBrand(): Promise<void> {
|
||||
if (!brandForm.name.trim()) {
|
||||
return;
|
||||
}
|
||||
if (editingBrandId.value) {
|
||||
await brandMutations.update.mutateAsync();
|
||||
return;
|
||||
}
|
||||
await brandMutations.create.mutateAsync();
|
||||
}
|
||||
|
||||
async function submitKeyword(): Promise<void> {
|
||||
if (!keywordForm.name.trim()) {
|
||||
return;
|
||||
}
|
||||
if (editingKeywordId.value) {
|
||||
await keywordMutations.update.mutateAsync();
|
||||
return;
|
||||
}
|
||||
await keywordMutations.create.mutateAsync();
|
||||
}
|
||||
|
||||
async function submitQuestion(): Promise<void> {
|
||||
if (!questionForm.keyword_id) {
|
||||
message.warning(t("brands.messages.chooseKeyword"));
|
||||
return;
|
||||
}
|
||||
if (!questionForm.question_text.trim()) {
|
||||
return;
|
||||
}
|
||||
if (editingQuestionId.value) {
|
||||
await questionMutations.update.mutateAsync();
|
||||
return;
|
||||
}
|
||||
await questionMutations.create.mutateAsync();
|
||||
}
|
||||
|
||||
async function submitCompetitor(): Promise<void> {
|
||||
if (!competitorForm.name.trim()) {
|
||||
return;
|
||||
}
|
||||
if (editingCompetitorId.value) {
|
||||
await competitorMutations.update.mutateAsync();
|
||||
return;
|
||||
}
|
||||
await competitorMutations.create.mutateAsync();
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="brands-view">
|
||||
<PageHero
|
||||
:eyebrow="t('brands.eyebrow')"
|
||||
:title="t('brands.title')"
|
||||
:description="t('brands.description')"
|
||||
>
|
||||
<template #actions>
|
||||
<a-button type="primary" @click="openBrandModal()">
|
||||
<template #icon><PlusOutlined /></template>
|
||||
{{ t("brands.newBrand") }}
|
||||
</a-button>
|
||||
</template>
|
||||
</PageHero>
|
||||
|
||||
<section class="brands-view__brand-rail">
|
||||
<div class="brands-view__section-head">
|
||||
<div>
|
||||
<p class="eyebrow">{{ t("brands.railTitle") }}</p>
|
||||
<h3>{{ t("brands.railTitle") }}</h3>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="brandListQuery.data.value?.length" class="brands-view__brand-grid">
|
||||
<article
|
||||
v-for="brand in brandListQuery.data.value"
|
||||
:key="brand.id"
|
||||
class="brands-view__brand-card"
|
||||
:class="{ 'brands-view__brand-card--active': brand.id === selectedBrandId }"
|
||||
@click="selectedBrandId = brand.id"
|
||||
>
|
||||
<div class="brands-view__brand-card-top">
|
||||
<div>
|
||||
<h4>{{ brand.name }}</h4>
|
||||
<p>{{ brand.description || "--" }}</p>
|
||||
</div>
|
||||
|
||||
<div class="brands-view__brand-card-actions">
|
||||
<a-button size="small" @click.stop="openBrandModal(brand)">
|
||||
<template #icon><EditOutlined /></template>
|
||||
</a-button>
|
||||
<a-popconfirm @confirm="brandMutations.remove.mutate(brand.id)">
|
||||
<template #title>{{ t("brands.deleteBrand") }}</template>
|
||||
<a-button size="small" danger @click.stop>
|
||||
<template #icon><DeleteOutlined /></template>
|
||||
</a-button>
|
||||
</a-popconfirm>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
<a-empty v-else :description="t('brands.empty.brands')" />
|
||||
</section>
|
||||
|
||||
<template v-if="selectedBrandId && selectedBrand">
|
||||
<section class="brands-view__brand-summary">
|
||||
<div>
|
||||
<p class="eyebrow">{{ t("brands.title") }}</p>
|
||||
<h3>{{ selectedBrand.name }}</h3>
|
||||
<p>{{ selectedBrand.description || "--" }}</p>
|
||||
</div>
|
||||
<div class="brands-view__brand-summary-actions">
|
||||
<a-button @click="openBrandModal(selectedBrand)">{{ t("brands.editBrand") }}</a-button>
|
||||
<a-button @click="activeTab = 'competitors'">{{ t("brands.tabs.competitors") }}</a-button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<a-tabs v-model:activeKey="activeTab" class="brands-view__tabs">
|
||||
<a-tab-pane key="keywords" :tab="t('brands.tabs.keywords')">
|
||||
<section class="brands-view__keyword-layout">
|
||||
<aside class="brands-view__keywords-panel">
|
||||
<div class="brands-view__section-head brands-view__section-head--compact">
|
||||
<div>
|
||||
<p class="eyebrow">{{ t("brands.sections.keywords") }}</p>
|
||||
<h3>{{ t("brands.sections.keywords") }}</h3>
|
||||
</div>
|
||||
<a-button type="primary" size="small" @click="openKeywordModal()">
|
||||
{{ t("brands.actions.newKeyword") }}
|
||||
</a-button>
|
||||
</div>
|
||||
|
||||
<div v-if="keywordsQuery.data.value?.length" class="brands-view__keyword-list">
|
||||
<button
|
||||
v-for="keyword in keywordsQuery.data.value"
|
||||
:key="keyword.id"
|
||||
type="button"
|
||||
class="brands-view__keyword-item"
|
||||
:class="{ 'brands-view__keyword-item--active': keyword.id === selectedKeywordId }"
|
||||
@click="selectedKeywordId = keyword.id"
|
||||
>
|
||||
<span>{{ keyword.name }}</span>
|
||||
<div class="brands-view__keyword-actions">
|
||||
<a-button size="small" @click.stop="openKeywordModal(keyword)">
|
||||
<template #icon><EditOutlined /></template>
|
||||
</a-button>
|
||||
<a-popconfirm @confirm="keywordMutations.remove.mutate(keyword.id)">
|
||||
<template #title>{{ t("common.delete") }}</template>
|
||||
<a-button size="small" danger @click.stop>
|
||||
<template #icon><DeleteOutlined /></template>
|
||||
</a-button>
|
||||
</a-popconfirm>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
<a-empty v-else :description="t('common.noData')" />
|
||||
</aside>
|
||||
|
||||
<section class="brands-view__questions-panel">
|
||||
<div class="brands-view__section-head brands-view__section-head--compact">
|
||||
<div>
|
||||
<p class="eyebrow">{{ t("brands.sections.questions") }}</p>
|
||||
<h3>{{ selectedKeyword?.name || t("brands.sections.questions") }}</h3>
|
||||
</div>
|
||||
<a-button type="primary" size="small" @click="openQuestionModal()">
|
||||
{{ t("brands.actions.newQuestion") }}
|
||||
</a-button>
|
||||
</div>
|
||||
|
||||
<a-table
|
||||
:columns="questionColumns"
|
||||
:data-source="filteredQuestionsQuery.data.value || []"
|
||||
:loading="filteredQuestionsQuery.isPending.value"
|
||||
:pagination="false"
|
||||
row-key="id"
|
||||
>
|
||||
<template #emptyText>{{ t("brands.empty.questions") }}</template>
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.key === 'question_text'">
|
||||
<div class="brands-view__question-cell">
|
||||
<strong>{{ record.question_text }}</strong>
|
||||
<span>{{ record.version_no ? `v${record.version_no}` : "--" }}</span>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'created_at'">
|
||||
{{ record.created_at }}
|
||||
</template>
|
||||
<template v-else-if="column.key === 'actions'">
|
||||
<div class="brands-view__inline-actions">
|
||||
<a-button size="small" @click="openVersions(record.id)">
|
||||
<template #icon><FileSearchOutlined /></template>
|
||||
</a-button>
|
||||
<a-button size="small" @click="openQuestionModal(record)">
|
||||
<template #icon><EditOutlined /></template>
|
||||
</a-button>
|
||||
<a-popconfirm @confirm="questionMutations.remove.mutate(record.id)">
|
||||
<template #title>{{ t("common.delete") }}</template>
|
||||
<a-button size="small" danger>
|
||||
<template #icon><DeleteOutlined /></template>
|
||||
</a-button>
|
||||
</a-popconfirm>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
</a-table>
|
||||
</section>
|
||||
</section>
|
||||
</a-tab-pane>
|
||||
|
||||
<a-tab-pane key="competitors" :tab="t('brands.tabs.competitors')">
|
||||
<section class="brands-view__competitors-panel">
|
||||
<div class="brands-view__section-head brands-view__section-head--compact">
|
||||
<div>
|
||||
<p class="eyebrow">{{ t("brands.sections.competitors") }}</p>
|
||||
<h3>{{ t("brands.sections.competitors") }}</h3>
|
||||
</div>
|
||||
<a-button type="primary" size="small" @click="openCompetitorModal()">
|
||||
{{ t("brands.actions.newCompetitor") }}
|
||||
</a-button>
|
||||
</div>
|
||||
|
||||
<div v-if="competitorsQuery.data.value?.length" class="brands-view__competitor-grid">
|
||||
<article
|
||||
v-for="competitor in competitorsQuery.data.value"
|
||||
:key="competitor.id"
|
||||
class="brands-view__competitor-card"
|
||||
>
|
||||
<div class="brands-view__competitor-head">
|
||||
<div>
|
||||
<h4>{{ competitor.name }}</h4>
|
||||
<a :href="competitor.website || undefined" target="_blank" rel="noreferrer">
|
||||
{{ competitor.website || "--" }}
|
||||
</a>
|
||||
</div>
|
||||
<div class="brands-view__inline-actions">
|
||||
<a-button size="small" @click="openCompetitorModal(competitor)">
|
||||
<template #icon><EditOutlined /></template>
|
||||
</a-button>
|
||||
<a-popconfirm @confirm="competitorMutations.remove.mutate(competitor.id)">
|
||||
<template #title>{{ t("common.delete") }}</template>
|
||||
<a-button size="small" danger>
|
||||
<template #icon><DeleteOutlined /></template>
|
||||
</a-button>
|
||||
</a-popconfirm>
|
||||
</div>
|
||||
</div>
|
||||
<p>{{ competitor.description || "--" }}</p>
|
||||
<div class="brands-view__competitor-lines">
|
||||
<span
|
||||
v-for="line in Array.isArray(competitor.product_lines_json)
|
||||
? competitor.product_lines_json
|
||||
: []"
|
||||
:key="String(line)"
|
||||
>
|
||||
{{ line }}
|
||||
</span>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
<a-empty v-else :description="t('brands.empty.competitors')" />
|
||||
</section>
|
||||
</a-tab-pane>
|
||||
</a-tabs>
|
||||
</template>
|
||||
|
||||
<a-modal
|
||||
v-model:open="brandModalOpen"
|
||||
:title="editingBrandId ? t('brands.editBrand') : t('brands.newBrand')"
|
||||
@ok="submitBrand"
|
||||
>
|
||||
<a-form layout="vertical">
|
||||
<a-form-item :label="t('brands.form.brandName')">
|
||||
<a-input v-model:value="brandForm.name" />
|
||||
</a-form-item>
|
||||
<a-form-item :label="t('brands.form.brandDescription')">
|
||||
<a-input-text-area v-model:value="brandForm.description" :rows="4" />
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</a-modal>
|
||||
|
||||
<a-modal
|
||||
v-model:open="keywordModalOpen"
|
||||
:title="editingKeywordId ? t('common.edit') : t('brands.actions.newKeyword')"
|
||||
@ok="submitKeyword"
|
||||
>
|
||||
<a-form layout="vertical">
|
||||
<a-form-item :label="t('brands.form.keywordName')">
|
||||
<a-input v-model:value="keywordForm.name" />
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</a-modal>
|
||||
|
||||
<a-modal
|
||||
v-model:open="questionModalOpen"
|
||||
:title="editingQuestionId ? t('common.edit') : t('brands.actions.newQuestion')"
|
||||
@ok="submitQuestion"
|
||||
>
|
||||
<a-form layout="vertical">
|
||||
<a-form-item :label="t('brands.sections.keywords')">
|
||||
<a-select
|
||||
v-model:value="questionForm.keyword_id"
|
||||
:options="(keywordsQuery.data.value || []).map((item) => ({ label: item.name, value: item.id }))"
|
||||
/>
|
||||
</a-form-item>
|
||||
<a-form-item :label="t('brands.form.questionText')">
|
||||
<a-input-text-area v-model:value="questionForm.question_text" :rows="4" />
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</a-modal>
|
||||
|
||||
<a-modal
|
||||
v-model:open="competitorModalOpen"
|
||||
:title="editingCompetitorId ? t('common.edit') : t('brands.actions.newCompetitor')"
|
||||
@ok="submitCompetitor"
|
||||
>
|
||||
<a-form layout="vertical">
|
||||
<a-form-item :label="t('brands.form.competitorName')">
|
||||
<a-input v-model:value="competitorForm.name" />
|
||||
</a-form-item>
|
||||
<a-form-item :label="t('brands.form.competitorWebsite')">
|
||||
<a-input v-model:value="competitorForm.website" />
|
||||
</a-form-item>
|
||||
<a-form-item :label="t('brands.form.competitorDescription')">
|
||||
<a-input-text-area v-model:value="competitorForm.description" :rows="3" />
|
||||
</a-form-item>
|
||||
<a-form-item :label="t('brands.form.competitorLines')">
|
||||
<a-input
|
||||
v-model:value="competitorForm.product_lines"
|
||||
:placeholder="t('brands.form.competitorLinesHint')"
|
||||
/>
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</a-modal>
|
||||
|
||||
<a-drawer
|
||||
v-model:open="versionsDrawerOpen"
|
||||
:title="t('brands.sections.versions')"
|
||||
width="520"
|
||||
class="brands-view__versions-drawer"
|
||||
>
|
||||
<a-list :data-source="versionItems" item-layout="vertical">
|
||||
<template #renderItem="{ item }">
|
||||
<a-list-item>
|
||||
<a-list-item-meta :title="`v${item.version_no}`" :description="item.created_at" />
|
||||
<p class="brands-view__version-text">{{ item.question_text }}</p>
|
||||
</a-list-item>
|
||||
</template>
|
||||
</a-list>
|
||||
</a-drawer>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.brands-view {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 22px;
|
||||
}
|
||||
|
||||
.brands-view__brand-rail,
|
||||
.brands-view__brand-summary,
|
||||
.brands-view__keywords-panel,
|
||||
.brands-view__questions-panel,
|
||||
.brands-view__competitors-panel {
|
||||
padding: 24px;
|
||||
background: #fff;
|
||||
border: 1px solid #e6edf5;
|
||||
border-radius: 24px;
|
||||
}
|
||||
|
||||
.brands-view__section-head {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
|
||||
.brands-view__section-head--compact h3 {
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.brands-view__section-head h3 {
|
||||
margin: 6px 0 0;
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.brands-view__brand-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.brands-view__brand-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
padding: 18px;
|
||||
background: #f8fafc;
|
||||
border: 1px solid #e6edf5;
|
||||
border-radius: 20px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.brands-view__brand-card--active {
|
||||
border-color: #bfd7ff;
|
||||
box-shadow: 0 18px 40px rgba(31, 92, 255, 0.08);
|
||||
}
|
||||
|
||||
.brands-view__brand-card-top {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.brands-view__brand-card h4,
|
||||
.brands-view__brand-summary h3,
|
||||
.brands-view__competitor-card h4 {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.brands-view__brand-card p,
|
||||
.brands-view__brand-summary p,
|
||||
.brands-view__competitor-card p {
|
||||
margin: 8px 0 0;
|
||||
color: var(--muted);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.brands-view__brand-card-actions,
|
||||
.brands-view__inline-actions,
|
||||
.brands-view__keyword-actions,
|
||||
.brands-view__brand-summary-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.brands-view__brand-summary {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.brands-view__keyword-layout {
|
||||
display: grid;
|
||||
grid-template-columns: 320px minmax(0, 1fr);
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.brands-view__keyword-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.brands-view__keyword-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 14px 16px;
|
||||
background: #f8fafc;
|
||||
border: 1px solid #e6edf5;
|
||||
border-radius: 18px;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.brands-view__keyword-item--active {
|
||||
border-color: #bfd7ff;
|
||||
background: #eef5ff;
|
||||
}
|
||||
|
||||
.brands-view__question-cell {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.brands-view__question-cell span {
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.brands-view__competitor-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.brands-view__competitor-card {
|
||||
padding: 18px;
|
||||
background: #f8fafc;
|
||||
border: 1px solid #e6edf5;
|
||||
border-radius: 20px;
|
||||
}
|
||||
|
||||
.brands-view__competitor-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.brands-view__competitor-head a {
|
||||
color: #1f5cff;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.brands-view__competitor-lines {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin-top: 14px;
|
||||
}
|
||||
|
||||
.brands-view__competitor-lines span {
|
||||
padding: 6px 10px;
|
||||
background: #e8f0ff;
|
||||
border-radius: 999px;
|
||||
color: #1f5cff;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.brands-view__version-text {
|
||||
margin: 0;
|
||||
color: #334155;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.brands-view__versions-drawer :deep(.ant-drawer-body) {
|
||||
padding: 18px 24px 24px;
|
||||
}
|
||||
|
||||
@media (max-width: 1080px) {
|
||||
.brands-view__brand-grid,
|
||||
.brands-view__keyword-layout,
|
||||
.brands-view__competitor-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.brands-view__brand-summary,
|
||||
.brands-view__section-head {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user