Files
web-frontend/app/pages/catalog-settings.vue
2026-04-09 16:48:34 +07:00

302 lines
11 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<script setup lang="ts">
import { useMutation, useQuery } from '@vue/apollo-composable';
import {
CatalogProductTypeSettingsDocument,
ClientProductsDocument,
UpsertCatalogProductTypeSettingDocument,
type CatalogProductTypeSettingsQuery,
type ClientProductsQuery,
} from '~/composables/graphql/generated';
definePageMeta({
middleware: ['manager-only'],
path: '/admin/settings/catalog',
});
type CatalogSettingItem = CatalogProductTypeSettingsQuery['catalogProductTypeSettings'][number];
type ProductNode = ClientProductsQuery['clientProducts'][number];
type CatalogSettingForm = {
productType: string;
allowCustomLength: boolean;
customLengthMinM: string;
customLengthMaxM: string;
customLengthStepM: string;
allowCustomSleeveBrand: boolean;
allowCustomLabel: boolean;
};
type StandardOptionGroup = {
label: string;
values: string[];
};
const COLOR_TAGS = ['прозрачный', 'коричневый', 'белый', 'черный', 'желтый', 'зеленый', 'красный', 'синий', 'оранжевый', 'красно-белый'];
const LABEL_TAGS = ['хрупкое', 'подарок', 'акция'];
const settingsQuery = useQuery(CatalogProductTypeSettingsDocument);
const catalogProductsQuery = useQuery(ClientProductsDocument);
const saveSettingMutation = useMutation(UpsertCatalogProductTypeSettingDocument, { throws: 'never' });
const forms = reactive<Record<string, CatalogSettingForm>>({});
const isSavingAll = ref(false);
const saveSuccess = ref('');
const saveError = ref('');
const settings = computed<CatalogSettingItem[]>(() => settingsQuery.result.value?.catalogProductTypeSettings ?? []);
const isLoading = computed(() => settingsQuery.loading.value || catalogProductsQuery.loading.value);
function normalizeText(value: string | null | undefined) {
return String(value ?? '').replaceAll(/\s+/g, ' ').trim();
}
function formatNumberOptions(values: Array<number | null | undefined>, suffix: string) {
return [...new Set(values.filter((value): value is number => typeof value === 'number'))]
.sort((a, b) => a - b)
.map((value) => `${value} ${suffix}`);
}
function formatTextOptions(values: Array<string | null | undefined>) {
return [...new Set(values.map((value) => normalizeText(value)).filter(Boolean))]
.sort((a, b) => a.localeCompare(b, 'ru'));
}
const standardOptionsByType = computed<Record<string, StandardOptionGroup[]>>(() => {
const products = catalogProductsQuery.result.value?.clientProducts ?? [];
const grouped = new Map<string, ProductNode[]>();
for (const product of products) {
const typeLabel = normalizeText(product.productType) || 'Без типа';
const existing = grouped.get(typeLabel);
if (existing) {
existing.push(product);
} else {
grouped.set(typeLabel, [product]);
}
}
return Object.fromEntries(
[...grouped.entries()].map(([typeLabel, items]) => {
const colorValues = formatTextOptions(
items.flatMap((product) => product.tags.filter((tag) => COLOR_TAGS.includes(normalizeText(tag)))),
);
const labelValues = formatTextOptions(
items.flatMap((product) => product.tags.filter((tag) => LABEL_TAGS.includes(normalizeText(tag)))),
);
const optionGroups: StandardOptionGroup[] = [
{ label: 'Ширина', values: formatNumberOptions(items.map((product) => product.widthMm), 'мм') },
{ label: 'Длина', values: formatNumberOptions(items.map((product) => product.lengthM), 'м') },
{ label: 'Толщина', values: formatNumberOptions(items.map((product) => product.thicknessMicron), 'мкм') },
{ label: 'Втулка', values: formatTextOptions(items.map((product) => product.sleeveBrand)) },
{ label: 'Цвет', values: colorValues },
{ label: 'Надпись', values: labelValues },
].filter((group) => group.values.length > 0);
return [typeLabel, optionGroups];
}),
);
});
function toInputValue(value: number | null | undefined) {
return value == null ? '' : String(value);
}
function createForm(item: CatalogSettingItem): CatalogSettingForm {
return {
productType: item.productType,
allowCustomLength: item.allowCustomLength,
customLengthMinM: toInputValue(item.customLengthMinM),
customLengthMaxM: toInputValue(item.customLengthMaxM),
customLengthStepM: toInputValue(item.customLengthStepM),
allowCustomSleeveBrand: item.allowCustomSleeveBrand,
allowCustomLabel: item.allowCustomLabel,
};
}
function parseOptionalInteger(value: string) {
const normalized = value.trim();
if (!normalized) {
return null;
}
return Number(normalized);
}
function formFor(item: CatalogSettingItem) {
forms[item.productType] ??= createForm(item);
return forms[item.productType];
}
function standardOptionsFor(item: CatalogSettingItem) {
return standardOptionsByType.value[item.productType] ?? [];
}
watch(
settings,
(items) => {
const activeTypes = new Set(items.map((item) => item.productType));
for (const productType of Object.keys(forms)) {
if (!activeTypes.has(productType)) {
Reflect.deleteProperty(forms, productType);
}
}
for (const item of items) {
forms[item.productType] = createForm(item);
}
},
{ immediate: true },
);
async function saveAllSettings() {
saveSuccess.value = '';
saveError.value = '';
isSavingAll.value = true;
for (const item of settings.value) {
const form = formFor(item);
const result = await saveSettingMutation.mutate({
input: {
productType: form.productType,
showQuantityPerBox: false,
allowCustomLength: form.allowCustomLength,
customLengthMinM: form.allowCustomLength ? parseOptionalInteger(form.customLengthMinM) : null,
customLengthMaxM: form.allowCustomLength ? parseOptionalInteger(form.customLengthMaxM) : null,
customLengthStepM: form.allowCustomLength ? parseOptionalInteger(form.customLengthStepM) : null,
allowCustomSleeveBrand: form.allowCustomSleeveBrand,
allowCustomLabel: form.allowCustomLabel,
},
});
if (!result?.data?.upsertCatalogProductTypeSetting) {
saveError.value = saveSettingMutation.error.value?.message || `Не удалось сохранить настройки для "${form.productType}".`;
isSavingAll.value = false;
return;
}
forms[item.productType] = createForm(result.data.upsertCatalogProductTypeSetting);
}
isSavingAll.value = false;
saveSuccess.value = 'Настройки сохранены.';
}
</script>
<template>
<section class="space-y-6">
<h1 class="text-3xl font-extrabold text-[#0f2f20]">Каталог</h1>
<div v-if="isLoading" class="manager-empty-state">
Загружаем настройки каталога...
</div>
<div v-else-if="settings.length === 0" class="manager-empty-state">
Типы товаров пока не появились в каталоге.
</div>
<div v-else class="grid gap-4 xl:grid-cols-2">
<article
v-for="item in settings"
:key="item.productType"
class="rounded-[28px] bg-white p-5 shadow-[0_18px_38px_rgba(18,56,36,0.08)]"
>
<div class="flex flex-col gap-4">
<h2 class="text-xl font-bold text-[#123824]">{{ item.productType }}</h2>
<div class="space-y-3">
<label class="surface-card flex items-center gap-3 rounded-[22px] p-4">
<input v-model="formFor(item).allowCustomLength" type="checkbox" class="checkbox checkbox-success">
<span class="text-sm font-semibold text-[#123824]">Любая длина</span>
</label>
<label class="surface-card flex items-center gap-3 rounded-[22px] p-4">
<input v-model="formFor(item).allowCustomSleeveBrand" type="checkbox" class="checkbox checkbox-success">
<span class="text-sm font-semibold text-[#123824]">Логотип на втулке</span>
</label>
<label class="surface-card flex items-center gap-3 rounded-[22px] p-4">
<input v-model="formFor(item).allowCustomLabel" type="checkbox" class="checkbox checkbox-success">
<span class="text-sm font-semibold text-[#123824]">Нанесение надписи</span>
</label>
</div>
<div v-if="formFor(item).allowCustomLength" class="rounded-[24px] bg-[#f7fbf8] p-4">
<div class="mb-4 text-sm font-bold uppercase tracking-[0.12em] text-[#355947]">Диапазон длины</div>
<div class="grid gap-3 md:grid-cols-3">
<label class="space-y-2">
<span class="text-sm font-semibold text-[#123824]">Мин. длина, м</span>
<input
v-model="formFor(item).customLengthMinM"
type="number"
min="1"
step="1"
class="input manager-field w-full"
>
</label>
<label class="space-y-2">
<span class="text-sm font-semibold text-[#123824]">Макс. длина, м</span>
<input
v-model="formFor(item).customLengthMaxM"
type="number"
min="1"
step="1"
class="input manager-field w-full"
>
</label>
<label class="space-y-2">
<span class="text-sm font-semibold text-[#123824]">Шаг, м</span>
<input
v-model="formFor(item).customLengthStepM"
type="number"
min="1"
step="1"
class="input manager-field w-full"
>
</label>
</div>
</div>
<div v-if="standardOptionsFor(item).length" class="rounded-[24px] bg-[#f7fbf8] p-4">
<div class="mb-4 text-sm font-bold uppercase tracking-[0.12em] text-[#355947]">Стандартные параметры</div>
<div class="space-y-3">
<div
v-for="group in standardOptionsFor(item)"
:key="`${item.productType}-${group.label}`"
class="space-y-2"
>
<div class="text-sm font-semibold text-[#123824]">{{ group.label }}</div>
<div class="flex flex-wrap gap-2">
<span
v-for="value in group.values"
:key="`${item.productType}-${group.label}-${value}`"
class="rounded-full bg-white px-3 py-1 text-xs font-semibold text-[#355947]"
>
{{ value }}
</span>
</div>
</div>
</div>
</div>
</div>
</article>
</div>
<div v-if="saveSuccess || saveError" class="space-y-2 text-sm">
<p v-if="saveSuccess" class="font-semibold text-[#1c6b45]">{{ saveSuccess }}</p>
<p v-if="saveError" class="font-semibold text-[#c4472d]">{{ saveError }}</p>
</div>
<div v-if="settings.length" class="flex justify-end">
<button
class="btn h-11 rounded-full border-0 bg-[#139957] px-6 text-sm font-semibold text-white hover:bg-[#0d854a]"
:disabled="isSavingAll"
@click="saveAllSettings"
>
{{ isSavingAll ? 'Сохраняем…' : 'Сохранить' }}
</button>
</div>
</section>
</template>