302 lines
11 KiB
Vue
302 lines
11 KiB
Vue
<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>
|