Files
geo/apps/admin-web/src/views/BrandsView.vue
T
root 71233b6715 feat(brand): add brand sort order and drag-to-reorder
Add a sort_order column to brands with a migration, expose a
PUT /api/tenant/brands/order reorder endpoint, and wire the
admin-web BrandsView with drag-and-drop reordering plus i18n.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-08 21:48:20 +08:00

1357 lines
37 KiB
Vue

<script setup lang="ts">
import {
ArrowDownOutlined,
ArrowUpOutlined,
CheckOutlined,
CloseOutlined,
DeleteOutlined,
EditOutlined,
HolderOutlined,
PlusOutlined,
ThunderboltOutlined,
} from '@ant-design/icons-vue'
import type { Brand, BrandLibrarySummary, Competitor, Question } from '@geo/shared-types'
import { useMutation, useQuery, useQueryClient } from '@tanstack/vue-query'
import type { TableColumnsType } from 'ant-design-vue'
import { message } from 'ant-design-vue'
import { computed, reactive, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRoute, useRouter } from 'vue-router'
import { brandsApi } from '@/lib/api'
import { formatDateTime } from '@/lib/display'
import {
formatError,
getQuestionMaterializeErrorMessage,
isDuplicateQuestionSkipReason,
} from '@/lib/errors'
import { useCompanyStore } from '@/stores/company'
const queryClient = useQueryClient()
const { t } = useI18n()
const route = useRoute()
const router = useRouter()
const companyStore = useCompanyStore()
const activeTab = ref<'questions' | 'competitors'>('questions')
const selectedBrandId = computed<number | null>({
get: () => companyStore.currentBrandId,
set: (brandId) => companyStore.setCurrentBrand(brandId),
})
const brandModalOpen = ref(false)
const editingBrandId = ref<number | null>(null)
const singleQuestionModalOpen = ref(false)
const questionEditOpen = ref(false)
const editingQuestionId = ref<number | null>(null)
const competitorModalOpen = ref(false)
const editingCompetitorId = ref<number | null>(null)
const sortMode = ref(false)
const draftBrandIds = ref<number[]>([])
const draggedBrandId = ref<number | null>(null)
const dragOverBrandId = ref<number | null>(null)
const brandForm = reactive({
name: '',
website: '',
description: '',
})
const questionEditForm = reactive({
question_text: '',
})
const singleQuestionForm = reactive({
question_text: '',
})
const competitorForm = reactive({
name: '',
website: '',
description: '',
product_lines: '',
})
const questionPage = ref(1)
const questionPageSize = ref(10)
const brandListQuery = useQuery({
queryKey: ['brands', 'list'],
queryFn: () => brandsApi.list(),
})
const brandLibrarySummaryQuery = useQuery({
queryKey: ['brands', 'library-summary'],
queryFn: () => brandsApi.getLibrarySummary(),
})
const questionsQuery = useQuery({
queryKey: computed(() => [
'brands',
selectedBrandId.value,
'questions',
questionPage.value,
questionPageSize.value,
]),
enabled: computed(() => Boolean(selectedBrandId.value)),
queryFn: () =>
brandsApi.listQuestions(selectedBrandId.value as number, {
page: questionPage.value,
page_size: questionPageSize.value,
}),
})
const competitorsQuery = useQuery({
queryKey: computed(() => ['brands', selectedBrandId.value, 'competitors']),
enabled: computed(() => Boolean(selectedBrandId.value)),
queryFn: () => brandsApi.listCompetitors(selectedBrandId.value as number),
})
const brandLibrarySummary = computed<BrandLibrarySummary | null>(
() => brandLibrarySummaryQuery.data.value ?? null,
)
const selectedBrand = computed(
() => brandListQuery.data.value?.find((item) => item.id === selectedBrandId.value) ?? null,
)
const brandList = computed(() => brandListQuery.data.value ?? [])
const orderedBrands = computed(() => {
if (!sortMode.value) {
return brandList.value
}
const brandById = new Map(brandList.value.map((brand) => [brand.id, brand]))
const ordered = draftBrandIds.value
.map((brandId) => brandById.get(brandId))
.filter((brand): brand is Brand => Boolean(brand))
const orderedIds = new Set(ordered.map((brand) => brand.id))
const appended = brandList.value.filter((brand) => !orderedIds.has(brand.id))
return [...ordered, ...appended]
})
const canSortBrands = computed(() => brandList.value.length > 1)
const hasBrandOrderChanges = computed(
() => !isSameBrandOrder(draftBrandIds.value, brandList.value.map((brand) => brand.id)),
)
const currentQuestions = computed(() => questionsQuery.data.value?.items ?? [])
const questionTotal = computed(() => questionsQuery.data.value?.total ?? 0)
const maxQuestions = computed(
() =>
brandLibrarySummary.value?.max_questions ??
brandLibrarySummary.value?.max_questions_per_brand ??
0,
)
const usedQuestions = computed(
() => brandLibrarySummary.value?.used_questions ?? questionTotal.value,
)
const brandLimitReached = computed(() => {
if (!brandLibrarySummary.value) {
return false
}
return brandLibrarySummary.value.remaining_brands <= 0
})
const questionLimitReached = computed(() => {
if (!maxQuestions.value) {
return false
}
return usedQuestions.value >= maxQuestions.value
})
const questionColumns = computed<TableColumnsType<Question>>(() => [
{
title: t('brands.questions.table.question'),
dataIndex: 'question_text',
key: 'question_text',
},
{
title: t('common.createdAt'),
dataIndex: 'created_at',
key: 'created_at',
width: 180,
},
{
title: t('common.actions'),
key: 'actions',
width: 120,
align: 'right',
},
])
const brandMutations = {
create: useMutation({
mutationFn: () =>
brandsApi.create({
name: brandForm.name.trim(),
website: brandForm.website.trim() || null,
description: brandForm.description.trim(),
}),
onSuccess: async (brand) => {
message.success(t('brands.messages.createBrand'))
brandModalOpen.value = false
selectedBrandId.value = brand.id
companyStore.setCurrentBrand(brand.id)
await invalidateBrandQueries()
},
onError: (error) => message.error(formatError(error)),
}),
update: useMutation({
mutationFn: () =>
brandsApi.update(editingBrandId.value as number, {
name: brandForm.name.trim(),
website: brandForm.website.trim() || null,
description: brandForm.description.trim(),
}),
onSuccess: async () => {
message.success(t('brands.messages.updateBrand'))
brandModalOpen.value = false
await invalidateBrandQueries()
},
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 invalidateBrandQueries()
},
onError: (error) => message.error(formatError(error)),
}),
}
const brandOrderMutation = useMutation({
mutationFn: (brandIds: number[]) => brandsApi.reorder({ brand_ids: brandIds }),
onSuccess: async (brands) => {
message.success(t('brands.messages.reorderBrand'))
queryClient.setQueryData(['brands', 'list'], brands)
companyStore.setBrands(brands)
sortMode.value = false
resetBrandDragState()
await queryClient.invalidateQueries({ queryKey: ['brands'] })
},
onError: (error) => message.error(formatError(error)),
})
const questionMutations = {
createSingle: useMutation({
mutationFn: () =>
brandsApi.materializeQuestions(selectedBrandId.value as number, {
source: 'manual',
questions: [{ text: singleQuestionForm.question_text.trim() }],
}),
onSuccess: async (result) => {
if (result.created_questions <= 0) {
const skippedAllDuplicates =
result.skipped_questions.length > 0 &&
result.skipped_questions.every((item) => isDuplicateQuestionSkipReason(item.reason))
message.warning(
skippedAllDuplicates
? t('brands.questions.errors.duplicateExisting')
: t('brands.questions.errors.noValidQuestions'),
)
return
}
message.success(t('brands.messages.createQuestion'))
singleQuestionModalOpen.value = false
singleQuestionForm.question_text = ''
await invalidateBrandQueries()
},
onError: (error) => {
const materializeMessage = getQuestionMaterializeErrorMessage(error, {
duplicate: t('brands.questions.errors.duplicateExisting'),
noValid: t('brands.questions.errors.noValidQuestions'),
})
if (materializeMessage) {
message.warning(materializeMessage)
return
}
message.error(formatError(error))
},
}),
update: useMutation({
mutationFn: () =>
brandsApi.updateQuestion(selectedBrandId.value as number, editingQuestionId.value as number, {
question_text: questionEditForm.question_text.trim(),
}),
onSuccess: async () => {
message.success(t('brands.messages.updateQuestion'))
questionEditOpen.value = false
await invalidateBrandQueries()
},
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 invalidateBrandQueries()
},
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 invalidateBrandQueries()
},
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 invalidateBrandQueries()
},
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 invalidateBrandQueries()
},
onError: (error) => message.error(formatError(error)),
}),
}
watch(
() => brandListQuery.data.value,
(brands) => {
const routeBrandId = Number(route.query.brand_id)
if (!selectedBrandId.value && !Number.isNaN(routeBrandId) && routeBrandId > 0) {
selectedBrandId.value = routeBrandId
}
if (!selectedBrandId.value && brands?.length) {
selectedBrandId.value = brands[0].id
return
}
if (
selectedBrandId.value &&
brands &&
!brands.some((item) => item.id === selectedBrandId.value)
) {
selectedBrandId.value = brands[0]?.id ?? null
}
},
{ immediate: true },
)
watch(
() => brandList.value.map((brand) => brand.id).join(','),
() => {
if (!sortMode.value) {
draftBrandIds.value = currentBrandIds()
}
},
{ immediate: true },
)
watch([questionTotal, questionPageSize], ([total, pageSize]) => {
const maxPage = Math.max(1, Math.ceil(total / pageSize))
if (questionPage.value > maxPage) {
questionPage.value = maxPage
}
})
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 currentBrandIds(): number[] {
return brandList.value.map((brand) => brand.id)
}
function isSameBrandOrder(left: number[], right: number[]): boolean {
if (left.length !== right.length) {
return false
}
return left.every((brandId, index) => brandId === right[index])
}
function resetBrandDragState(): void {
draggedBrandId.value = null
dragOverBrandId.value = null
}
function startSortMode(): void {
if (!canSortBrands.value) {
return
}
draftBrandIds.value = currentBrandIds()
sortMode.value = true
resetBrandDragState()
}
function cancelSortMode(): void {
draftBrandIds.value = currentBrandIds()
sortMode.value = false
resetBrandDragState()
}
async function saveBrandOrder(): Promise<void> {
const brandIds = orderedBrands.value.map((brand) => brand.id)
if (!isSameBrandOrder(brandIds, currentBrandIds())) {
await brandOrderMutation.mutateAsync(brandIds)
return
}
cancelSortMode()
}
function moveDraftBrand(brandId: number, direction: -1 | 1): void {
const nextIds = [...draftBrandIds.value]
const currentIndex = nextIds.indexOf(brandId)
const nextIndex = currentIndex + direction
if (currentIndex < 0 || nextIndex < 0 || nextIndex >= nextIds.length) {
return
}
const [movedId] = nextIds.splice(currentIndex, 1)
nextIds.splice(nextIndex, 0, movedId)
draftBrandIds.value = nextIds
}
function moveDraftBrandToTarget(sourceBrandId: number, targetBrandId: number): void {
if (sourceBrandId === targetBrandId) {
return
}
const nextIds = [...draftBrandIds.value]
const sourceIndex = nextIds.indexOf(sourceBrandId)
const targetIndex = nextIds.indexOf(targetBrandId)
if (sourceIndex < 0 || targetIndex < 0) {
return
}
const [movedId] = nextIds.splice(sourceIndex, 1)
nextIds.splice(targetIndex, 0, movedId)
draftBrandIds.value = nextIds
}
function handleBrandCardClick(brandId: number): void {
if (sortMode.value) {
return
}
selectBrand(brandId)
}
function handleBrandDragStart(brandId: number, event: DragEvent): void {
draggedBrandId.value = brandId
dragOverBrandId.value = null
event.dataTransfer?.setData('text/plain', String(brandId))
if (event.dataTransfer) {
event.dataTransfer.effectAllowed = 'move'
}
}
function handleBrandDragOver(brandId: number, event: DragEvent): void {
if (!sortMode.value || draggedBrandId.value === null || draggedBrandId.value === brandId) {
return
}
event.preventDefault()
dragOverBrandId.value = brandId
if (event.dataTransfer) {
event.dataTransfer.dropEffect = 'move'
}
}
function handleBrandDrop(brandId: number, event: DragEvent): void {
event.preventDefault()
if (!sortMode.value || draggedBrandId.value === null) {
resetBrandDragState()
return
}
moveDraftBrandToTarget(draggedBrandId.value, brandId)
resetBrandDragState()
}
function openBrandModal(brand?: Brand): void {
if (!brand && brandLimitReached.value) {
message.warning(
t('brands.messages.brandLimitReached', {
limit: brandLibrarySummary.value?.max_brands ?? 0,
}),
)
return
}
editingBrandId.value = brand?.id ?? null
brandForm.name = brand?.name ?? ''
brandForm.website = brand?.website ?? ''
brandForm.description = brand?.description ?? ''
brandModalOpen.value = true
}
function selectBrand(brandId: number): void {
if (!Number.isFinite(brandId) || brandId <= 0) {
return
}
selectedBrandId.value = brandId
questionPage.value = 1
void queryClient.invalidateQueries({ queryKey: ['brands'] })
}
function ensureQuestionCreatable(): boolean {
if (!selectedBrandId.value) {
message.warning(t('brands.messages.chooseBrand'))
return false
}
if (questionLimitReached.value) {
message.warning(
t('brands.questions.messages.limitReached', {
limit: maxQuestions.value,
}),
)
return false
}
return true
}
function openSingleQuestionModal(): void {
if (!ensureQuestionCreatable()) {
return
}
singleQuestionForm.question_text = ''
singleQuestionModalOpen.value = true
}
function openQuestionExpansionPage(): void {
if (!ensureQuestionCreatable()) {
return
}
void router.push({
name: 'brand-question-create',
params: { brandId: String(selectedBrandId.value) },
})
}
function openQuestionEditModal(question: Question): void {
editingQuestionId.value = question.id
questionEditForm.question_text = question.question_text
questionEditOpen.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
}
async function submitBrand(): Promise<void> {
if (!brandForm.name.trim()) {
return
}
if (!brandForm.description.trim()) {
message.warning(t('brands.messages.brandDescriptionRequired'))
return
}
if (editingBrandId.value) {
await brandMutations.update.mutateAsync()
return
}
await brandMutations.create.mutateAsync()
}
async function submitQuestionEdit(): Promise<void> {
if (!questionEditForm.question_text.trim()) {
return
}
await questionMutations.update.mutateAsync()
}
async function submitSingleQuestion(): Promise<void> {
if (!singleQuestionForm.question_text.trim()) {
return
}
await questionMutations.createSingle.mutateAsync()
}
function handleQuestionTableChange(nextPage: number, nextPageSize: number): void {
questionPage.value = nextPage
questionPageSize.value = nextPageSize
}
async function submitCompetitor(): Promise<void> {
if (!competitorForm.name.trim()) {
return
}
if (editingCompetitorId.value) {
await competitorMutations.update.mutateAsync()
return
}
await competitorMutations.create.mutateAsync()
}
async function invalidateBrandQueries(): Promise<void> {
await Promise.all([
queryClient.invalidateQueries({ queryKey: ['brands'] }),
queryClient.invalidateQueries({ queryKey: ['workspace'] }),
])
await companyStore.refreshBrands()
}
</script>
<template>
<div class="brands-view">
<section class="brand-page-head">
<div>
<p class="eyebrow">{{ t('brands.eyebrow') }}</p>
<h2>{{ t('brands.title') }}</h2>
</div>
<div class="brand-page-head__quota">
<span>{{ t('brands.quota.questions') }}</span>
<strong>{{ usedQuestions }} / {{ maxQuestions || '--' }}</strong>
</div>
</section>
<section class="brand-rail">
<div class="brand-rail__toolbar">
<div>
<p class="eyebrow">{{ t('brands.order.eyebrow') }}</p>
<h3>{{ t('brands.order.title') }}</h3>
</div>
<div class="brand-rail__actions">
<template v-if="sortMode">
<a-button :disabled="brandOrderMutation.isPending.value" @click="cancelSortMode">
<template #icon><CloseOutlined /></template>
{{ t('common.cancel') }}
</a-button>
<a-button
type="primary"
:disabled="!hasBrandOrderChanges"
:loading="brandOrderMutation.isPending.value"
@click="saveBrandOrder"
>
<template #icon><CheckOutlined /></template>
{{ t('brands.order.done') }}
</a-button>
</template>
<a-button v-else :disabled="!canSortBrands" @click="startSortMode">
<template #icon><HolderOutlined /></template>
{{ t('brands.order.adjust') }}
</a-button>
</div>
</div>
<div v-if="brandListQuery.isPending.value" class="brand-grid">
<a-skeleton v-for="item in 3" :key="item" active />
</div>
<div v-else-if="brandListQuery.data.value?.length" class="brand-grid">
<article
v-for="(brand, index) in orderedBrands"
:key="brand.id"
class="brand-card"
:class="{
'brand-card--active': brand.id === selectedBrandId,
'brand-card--sorting': sortMode,
'brand-card--dragging': draggedBrandId === brand.id,
'brand-card--drop-target': dragOverBrandId === brand.id,
}"
@click="handleBrandCardClick(brand.id)"
@dragover="handleBrandDragOver(brand.id, $event)"
@drop="handleBrandDrop(brand.id, $event)"
@dragend="resetBrandDragState"
>
<div class="brand-card__top">
<div class="brand-card__identity">
<div v-if="sortMode" class="brand-card__order">
<button
type="button"
class="brand-card__drag-handle"
draggable="true"
:aria-label="t('brands.order.dragHandle')"
@click.stop
@dragstart.stop="handleBrandDragStart(brand.id, $event)"
@dragend="resetBrandDragState"
>
<HolderOutlined />
</button>
<span>{{ index + 1 }}</span>
</div>
<div class="brand-card__copy">
<h3>{{ brand.name }}</h3>
<p>{{ brand.description || t('brands.empty.brandDescription') }}</p>
</div>
</div>
<div v-if="sortMode" class="brand-card__sort-actions">
<a-tooltip :title="t('brands.order.moveUp')">
<a-button
type="text"
shape="circle"
size="small"
:disabled="index === 0"
@click.stop="moveDraftBrand(brand.id, -1)"
>
<ArrowUpOutlined />
</a-button>
</a-tooltip>
<a-tooltip :title="t('brands.order.moveDown')">
<a-button
type="text"
shape="circle"
size="small"
:disabled="index === orderedBrands.length - 1"
@click.stop="moveDraftBrand(brand.id, 1)"
>
<ArrowDownOutlined />
</a-button>
</a-tooltip>
</div>
<div v-else class="brand-card__actions">
<a-tooltip :title="t('common.edit')">
<a-button
type="text"
shape="circle"
size="small"
class="action-btn-edit"
@click.stop="openBrandModal(brand)"
>
<EditOutlined />
</a-button>
</a-tooltip>
<a-popconfirm @confirm="brandMutations.remove.mutate(brand.id)">
<template #title>{{ t('brands.deleteBrand') }}</template>
<a-button type="text" shape="circle" size="small" danger @click.stop>
<DeleteOutlined />
</a-button>
</a-popconfirm>
</div>
</div>
<div class="brand-card__meta">
<span>{{ t('brands.meta.questionCount', { count: brand.question_count }) }}</span>
<span>
{{ t('brands.meta.competitorCount', { count: brand.competitor_count ?? 0 }) }}
</span>
</div>
</article>
<button
v-if="!sortMode"
type="button"
class="brand-card brand-card--add"
@click="openBrandModal()"
>
<PlusOutlined />
<span>{{ t('brands.actions.addBrand') }}</span>
</button>
</div>
<a-empty v-else :description="t('brands.empty.brands')">
<a-button type="primary" @click="openBrandModal()">
<template #icon><PlusOutlined /></template>
{{ t('brands.newBrand') }}
</a-button>
</a-empty>
</section>
<section v-if="selectedBrand" class="brand-detail">
<div class="brand-detail__head">
<div>
<p class="eyebrow">{{ selectedBrand?.name }}</p>
<h3>
{{
activeTab === 'questions' ? t('brands.tabs.questions') : t('brands.tabs.competitors')
}}
</h3>
</div>
<div v-if="activeTab === 'questions'" class="brand-detail__actions">
<a-button :disabled="!selectedBrand" @click="openQuestionExpansionPage">
<template #icon><ThunderboltOutlined /></template>
{{ t('brands.questions.expand') }}
</a-button>
<a-button type="primary" :disabled="!selectedBrand" @click="openSingleQuestionModal">
<template #icon><PlusOutlined /></template>
{{ t('brands.questions.createSingle') }}
</a-button>
</div>
<a-button v-else type="primary" :disabled="!selectedBrand" @click="openCompetitorModal()">
<template #icon><PlusOutlined /></template>
{{ t('brands.actions.newCompetitor') }}
</a-button>
</div>
<a-tabs v-model:activeKey="activeTab" class="brand-tabs">
<a-tab-pane key="questions" :tab="t('brands.tabs.questions')">
<a-table
:columns="questionColumns"
:data-source="currentQuestions"
:loading="questionsQuery.isPending.value"
:pagination="{
current: questionPage,
pageSize: questionPageSize,
total: questionTotal,
showSizeChanger: false,
onChange: handleQuestionTableChange,
}"
row-key="id"
>
<template #emptyText>{{ t('brands.empty.questions') }}</template>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'question_text'">
<div class="question-cell">
<strong>{{ record.question_text }}</strong>
</div>
</template>
<template v-else-if="column.key === 'created_at'">
<span class="datetime">{{ formatDateTime(record.created_at) }}</span>
</template>
<template v-else-if="column.key === 'actions'">
<div class="table-actions-row">
<a-tooltip :title="t('common.edit')">
<a-button
type="text"
shape="circle"
size="small"
class="action-btn-edit"
@click="openQuestionEditModal(record)"
>
<EditOutlined />
</a-button>
</a-tooltip>
<a-popconfirm @confirm="questionMutations.remove.mutate(record.id)">
<template #title>{{ t('common.delete') }}</template>
<a-button type="text" shape="circle" size="small" danger>
<DeleteOutlined />
</a-button>
</a-popconfirm>
</div>
</template>
</template>
</a-table>
</a-tab-pane>
<a-tab-pane key="competitors" :tab="t('brands.tabs.competitors')">
<div v-if="competitorsQuery.data.value?.length" class="competitor-grid">
<article
v-for="competitor in competitorsQuery.data.value"
:key="competitor.id"
class="competitor-card"
>
<div class="competitor-card__head">
<div>
<h4>{{ competitor.name }}</h4>
<a :href="competitor.website || undefined" target="_blank" rel="noreferrer">
{{ competitor.website || t('common.optional') }}
</a>
</div>
<div class="inline-actions">
<a-button
type="text"
shape="circle"
size="small"
class="action-btn-edit"
@click="openCompetitorModal(competitor)"
>
<EditOutlined />
</a-button>
<a-popconfirm @confirm="competitorMutations.remove.mutate(competitor.id)">
<template #title>{{ t('common.delete') }}</template>
<a-button type="text" shape="circle" size="small" danger>
<DeleteOutlined />
</a-button>
</a-popconfirm>
</div>
</div>
<p>{{ competitor.description || t('brands.empty.competitorDescription') }}</p>
<div class="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')" />
</a-tab-pane>
</a-tabs>
</section>
<a-modal
v-model:open="brandModalOpen"
:title="editingBrandId ? t('brands.editBrand') : t('brands.newBrand')"
centered
@ok="submitBrand"
>
<a-form layout="vertical">
<a-form-item :label="t('brands.form.brandName')" required>
<a-input v-model:value="brandForm.name" />
</a-form-item>
<a-form-item :label="t('brands.form.brandWebsite')">
<a-input v-model:value="brandForm.website" />
</a-form-item>
<a-form-item :label="t('brands.form.brandDescription')" required>
<a-textarea v-model:value="brandForm.description" :rows="4" />
</a-form-item>
</a-form>
</a-modal>
<a-modal
v-model:open="singleQuestionModalOpen"
:title="t('brands.questions.createSingle')"
centered
:confirm-loading="questionMutations.createSingle.isPending.value"
@ok="submitSingleQuestion"
>
<a-form layout="vertical">
<a-form-item :label="t('brands.form.questionText')">
<a-textarea v-model:value="singleQuestionForm.question_text" :rows="4" />
</a-form-item>
</a-form>
</a-modal>
<a-modal
v-model:open="questionEditOpen"
:title="t('common.edit')"
centered
@ok="submitQuestionEdit"
>
<a-form layout="vertical">
<a-form-item :label="t('brands.form.questionText')">
<a-textarea v-model:value="questionEditForm.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')"
centered
@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-textarea 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>
</div>
</template>
<style scoped>
.brands-view {
display: flex;
flex-direction: column;
gap: 18px;
}
.eyebrow {
margin: 0;
color: #64748b;
font-size: 12px;
font-weight: 700;
letter-spacing: 0;
text-transform: uppercase;
}
.brand-page-head,
.brand-rail,
.brand-detail {
background: #ffffff;
border: 1px solid #e5e7eb;
border-radius: 8px;
}
.brand-page-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 22px 24px;
}
.brand-page-head h2,
.brand-detail__head h3 {
margin: 4px 0 0;
color: #111827;
font-size: 22px;
font-weight: 760;
}
.brand-page-head__quota {
min-width: 132px;
padding-left: 18px;
border-left: 1px solid #e2e8f0;
text-align: right;
}
.brand-page-head__quota span {
display: block;
color: #64748b;
font-size: 12px;
}
.brand-page-head__quota strong {
color: #0f172a;
font-size: 22px;
}
.brand-rail,
.brand-detail {
padding: 20px;
}
.brand-rail__toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
margin-bottom: 16px;
}
.brand-rail__toolbar h3 {
margin: 4px 0 0;
color: #111827;
font-size: 18px;
font-weight: 740;
}
.brand-rail__actions {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 8px;
}
.brand-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 14px;
}
.brand-card {
display: flex;
flex-direction: column;
min-height: 142px;
padding: 16px;
color: inherit;
text-align: left;
background: #ffffff;
border: 1px solid #e5e7eb;
border-radius: 8px;
cursor: pointer;
transition:
border-color 0.18s ease,
box-shadow 0.18s ease,
transform 0.18s ease;
}
.brand-card:hover {
border-color: #94a3b8;
transform: translateY(-1px);
box-shadow: 0 16px 30px rgba(15, 23, 42, 0.06);
}
.brand-card--active {
border-color: #2563eb;
background: #f8fbff;
box-shadow: inset 0 0 0 1px rgba(37, 99, 235, 0.12);
}
.brand-card--sorting {
cursor: default;
}
.brand-card--dragging {
opacity: 0.46;
transform: scale(0.985);
}
.brand-card--drop-target {
border-color: #1677ff;
background: #f0f7ff;
box-shadow:
inset 0 0 0 1px rgba(22, 119, 255, 0.18),
0 14px 28px rgba(22, 119, 255, 0.1);
}
.brand-card--add {
align-items: center;
justify-content: center;
gap: 10px;
color: #2563eb;
border-style: dashed;
}
.brand-card__top,
.brand-detail__head,
.competitor-card__head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
}
.brand-card__identity {
display: flex;
align-items: flex-start;
gap: 12px;
min-width: 0;
}
.brand-card__copy {
min-width: 0;
}
.brand-card__order {
display: flex;
flex: 0 0 auto;
flex-direction: column;
align-items: center;
gap: 6px;
min-width: 30px;
}
.brand-card__order span {
min-width: 24px;
height: 20px;
color: #2563eb;
font-size: 12px;
font-weight: 760;
line-height: 20px;
text-align: center;
background: #eff6ff;
border-radius: 999px;
}
.brand-card__drag-handle {
display: inline-flex;
width: 28px;
height: 28px;
align-items: center;
justify-content: center;
padding: 0;
color: #64748b;
background: #f8fafc;
border: 1px solid #e2e8f0;
border-radius: 7px;
cursor: grab;
transition:
color 0.18s ease,
border-color 0.18s ease,
background 0.18s ease;
}
.brand-card__drag-handle :deep(.anticon) {
display: inline-flex;
width: 16px;
height: 16px;
align-items: center;
justify-content: center;
font-size: 14px;
line-height: 1;
}
.brand-card__drag-handle :deep(svg) {
display: block;
}
.brand-card__drag-handle:hover {
color: #1677ff;
background: #eef6ff;
border-color: #93c5fd;
}
.brand-card__drag-handle:active {
cursor: grabbing;
}
.brand-detail__actions {
display: flex;
flex-wrap: wrap;
justify-content: flex-end;
gap: 10px;
}
.brand-card__top h3,
.competitor-card h4 {
margin: 0;
color: #111827;
font-size: 16px;
font-weight: 720;
}
.brand-card__top p,
.competitor-card p {
display: -webkit-box;
margin: 8px 0 0;
overflow: hidden;
color: #64748b;
line-height: 1.55;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
}
.brand-card__actions {
display: flex;
opacity: 0;
transition: opacity 0.18s ease;
}
.brand-card__sort-actions {
display: flex;
flex: 0 0 auto;
align-items: center;
gap: 2px;
}
.brand-card:hover .brand-card__actions,
.brand-card:focus-within .brand-card__actions {
opacity: 1;
}
.brand-card__meta {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: auto;
padding-top: 16px;
}
.brand-card__meta span,
.competitor-lines span {
padding: 5px 9px;
color: #1e40af;
font-size: 12px;
font-weight: 650;
background: #eff6ff;
border-radius: 999px;
}
.brand-tabs {
margin-top: 8px;
}
.question-cell strong {
color: #0f172a;
font-weight: 650;
}
.datetime {
white-space: nowrap;
}
.table-actions-row,
.inline-actions {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 4px;
}
.competitor-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 14px;
}
.competitor-card {
padding: 16px;
border: 1px solid #e5e7eb;
border-radius: 8px;
}
.competitor-card a {
color: #2563eb;
font-size: 13px;
}
.competitor-lines {
display: flex;
flex-wrap: wrap;
gap: 8px;
min-height: 28px;
margin-top: 14px;
}
@media (max-width: 1180px) {
.brand-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (max-width: 760px) {
.brand-page-head,
.brand-guide,
.brand-rail__toolbar,
.brand-detail__head {
flex-direction: column;
align-items: stretch;
}
.brand-rail__actions {
justify-content: flex-start;
}
.brand-detail__actions {
justify-content: flex-start;
}
.brand-grid,
.competitor-grid {
grid-template-columns: 1fr;
}
.brand-guide__quota {
padding-left: 0;
border-left: 0;
}
}
.action-btn-edit:hover {
color: #52c41a !important;
background: #f6ffed !important;
}
</style>