381 lines
14 KiB
Vue
381 lines
14 KiB
Vue
<script setup lang="ts">
|
||
import { useMutation, useQuery } from '@vue/apollo-composable';
|
||
import {
|
||
CatalogProductTypeSettingsDocument,
|
||
UpsertCatalogProductTypeSettingDocument,
|
||
type CatalogProductTypeSettingsQuery,
|
||
} from '~/composables/graphql/generated';
|
||
|
||
definePageMeta({
|
||
middleware: ['manager-only'],
|
||
path: '/admin/settings/catalog',
|
||
});
|
||
|
||
type CatalogSettingItem = CatalogProductTypeSettingsQuery['catalogProductTypeSettings'][number];
|
||
type OptionKey =
|
||
| 'widthOptionsMm'
|
||
| 'lengthOptionsM'
|
||
| 'thicknessOptionsMicron'
|
||
| 'sleeveOptions'
|
||
| 'colorOptions'
|
||
| 'labelOptions';
|
||
type OptionKind = 'number' | 'text';
|
||
type CatalogSettingForm = {
|
||
productType: string;
|
||
allowCustomLength: boolean;
|
||
customLengthMinM: string;
|
||
customLengthMaxM: string;
|
||
customLengthStepM: string;
|
||
allowCustomSleeveBrand: boolean;
|
||
allowCustomLabel: boolean;
|
||
widthOptionsMm: string[];
|
||
lengthOptionsM: string[];
|
||
thicknessOptionsMicron: string[];
|
||
sleeveOptions: string[];
|
||
colorOptions: string[];
|
||
labelOptions: string[];
|
||
drafts: Record<OptionKey, string>;
|
||
};
|
||
type OptionGroupDefinition = {
|
||
key: OptionKey;
|
||
label: string;
|
||
placeholder: string;
|
||
kind: OptionKind;
|
||
suffix?: string;
|
||
};
|
||
|
||
const OPTION_GROUPS: OptionGroupDefinition[] = [
|
||
{ key: 'widthOptionsMm', label: 'Ширина', placeholder: 'Добавить ширину', kind: 'number', suffix: 'мм' },
|
||
{ key: 'lengthOptionsM', label: 'Длина', placeholder: 'Добавить длину', kind: 'number', suffix: 'м' },
|
||
{ key: 'thicknessOptionsMicron', label: 'Толщина', placeholder: 'Добавить толщину', kind: 'number', suffix: 'мкм' },
|
||
{ key: 'sleeveOptions', label: 'Втулка', placeholder: 'Добавить втулку', kind: 'text' },
|
||
{ key: 'colorOptions', label: 'Цвет', placeholder: 'Добавить цвет', kind: 'text' },
|
||
{ key: 'labelOptions', label: 'Надпись', placeholder: 'Добавить надпись', kind: 'text' },
|
||
];
|
||
|
||
const settingsQuery = useQuery(CatalogProductTypeSettingsDocument);
|
||
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);
|
||
|
||
function normalizeText(value: string | null | undefined) {
|
||
return String(value ?? '').replaceAll(/\s+/g, ' ').trim();
|
||
}
|
||
|
||
function toInputValue(value: number | null | undefined) {
|
||
return value == null ? '' : String(value);
|
||
}
|
||
|
||
function createDrafts(): Record<OptionKey, string> {
|
||
return {
|
||
widthOptionsMm: '',
|
||
lengthOptionsM: '',
|
||
thicknessOptionsMicron: '',
|
||
sleeveOptions: '',
|
||
colorOptions: '',
|
||
labelOptions: '',
|
||
};
|
||
}
|
||
|
||
function parseOptionalInteger(value: string) {
|
||
const normalized = value.trim();
|
||
if (!normalized) {
|
||
return null;
|
||
}
|
||
|
||
const parsed = Number(normalized);
|
||
if (!Number.isInteger(parsed) || parsed <= 0) {
|
||
return null;
|
||
}
|
||
|
||
return parsed;
|
||
}
|
||
|
||
function normalizeOptionEntry(value: string, kind: OptionKind) {
|
||
if (kind === 'number') {
|
||
const parsed = parseOptionalInteger(value);
|
||
return parsed == null ? '' : String(parsed);
|
||
}
|
||
|
||
return normalizeText(value);
|
||
}
|
||
|
||
function normalizeOptionList(values: Array<string | number | null | undefined>, kind: OptionKind) {
|
||
const normalizedValues = values
|
||
.map((value) => normalizeOptionEntry(String(value ?? ''), kind))
|
||
.filter(Boolean);
|
||
|
||
return [...new Set(normalizedValues)].sort((left, right) => {
|
||
if (kind === 'number') {
|
||
return Number(left) - Number(right);
|
||
}
|
||
|
||
return left.localeCompare(right, 'ru');
|
||
});
|
||
}
|
||
|
||
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,
|
||
widthOptionsMm: normalizeOptionList(item.widthOptionsMm, 'number'),
|
||
lengthOptionsM: normalizeOptionList(item.lengthOptionsM, 'number'),
|
||
thicknessOptionsMicron: normalizeOptionList(item.thicknessOptionsMicron, 'number'),
|
||
sleeveOptions: normalizeOptionList(item.sleeveOptions, 'text'),
|
||
colorOptions: normalizeOptionList(item.colorOptions, 'text'),
|
||
labelOptions: normalizeOptionList(item.labelOptions, 'text'),
|
||
drafts: createDrafts(),
|
||
};
|
||
}
|
||
|
||
function formFor(item: CatalogSettingItem) {
|
||
forms[item.productType] ??= createForm(item);
|
||
return forms[item.productType];
|
||
}
|
||
|
||
function addOption(form: CatalogSettingForm, group: OptionGroupDefinition) {
|
||
const value = normalizeOptionEntry(form.drafts[group.key], group.kind);
|
||
if (!value) {
|
||
return;
|
||
}
|
||
|
||
form[group.key] = normalizeOptionList([...form[group.key], value], group.kind);
|
||
form.drafts[group.key] = '';
|
||
}
|
||
|
||
function removeOption(form: CatalogSettingForm, groupKey: OptionKey, value: string) {
|
||
form[groupKey] = form[groupKey].filter((item) => item !== value);
|
||
}
|
||
|
||
function optionChipLabel(value: string, group: OptionGroupDefinition) {
|
||
return group.suffix ? `${value} ${group.suffix}` : value;
|
||
}
|
||
|
||
function parseIntegerOptionList(values: string[]) {
|
||
return normalizeOptionList(values, 'number').map((value) => Number(value));
|
||
}
|
||
|
||
function parseTextOptionList(values: string[]) {
|
||
return normalizeOptionList(values, 'text');
|
||
}
|
||
|
||
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,
|
||
widthOptionsMm: parseIntegerOptionList(form.widthOptionsMm),
|
||
lengthOptionsM: parseIntegerOptionList(form.lengthOptionsM),
|
||
thicknessOptionsMicron: parseIntegerOptionList(form.thicknessOptionsMicron),
|
||
sleeveOptions: parseTextOptionList(form.sleeveOptions),
|
||
colorOptions: parseTextOptionList(form.colorOptions),
|
||
labelOptions: parseTextOptionList(form.labelOptions),
|
||
},
|
||
});
|
||
|
||
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="space-y-4">
|
||
<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="space-y-5">
|
||
<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 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-4">
|
||
<div
|
||
v-for="group in OPTION_GROUPS"
|
||
:key="`${item.productType}-${group.key}`"
|
||
class="rounded-[20px] bg-white p-4"
|
||
>
|
||
<div class="space-y-3">
|
||
<div class="text-sm font-semibold text-[#123824]">{{ group.label }}</div>
|
||
|
||
<div class="flex flex-wrap gap-2">
|
||
<button
|
||
v-for="value in formFor(item)[group.key]"
|
||
:key="`${item.productType}-${group.key}-${value}`"
|
||
type="button"
|
||
class="inline-flex items-center gap-2 rounded-full bg-[#eef7f1] px-3 py-1 text-xs font-semibold text-[#1d5a3c]"
|
||
@click="removeOption(formFor(item), group.key, value)"
|
||
>
|
||
<span>{{ optionChipLabel(value, group) }}</span>
|
||
<span class="text-[11px] leading-none text-[#6a8a78]">×</span>
|
||
</button>
|
||
|
||
<span
|
||
v-if="formFor(item)[group.key].length === 0"
|
||
class="rounded-full border border-dashed border-[#d6e7dc] px-3 py-1 text-xs font-medium text-[#7b8f84]"
|
||
>
|
||
Пока пусто
|
||
</span>
|
||
</div>
|
||
|
||
<div class="flex flex-col gap-2 md:flex-row">
|
||
<input
|
||
v-model="formFor(item).drafts[group.key]"
|
||
:type="group.kind === 'number' ? 'number' : 'text'"
|
||
:min="group.kind === 'number' ? '1' : undefined"
|
||
:step="group.kind === 'number' ? '1' : undefined"
|
||
class="input manager-field w-full"
|
||
:placeholder="group.placeholder"
|
||
@keydown.enter.prevent="addOption(formFor(item), group)"
|
||
>
|
||
<button
|
||
type="button"
|
||
class="btn h-11 rounded-full border-0 bg-[#dff2e7] px-5 text-sm font-semibold text-[#155c3a] hover:bg-[#caead8]"
|
||
@click="addOption(formFor(item), group)"
|
||
>
|
||
Добавить
|
||
</button>
|
||
</div>
|
||
</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>
|