Make catalog settings editable

This commit is contained in:
Ruslan Bakiev
2026-04-09 17:10:53 +07:00
parent 872dba648c
commit 6f1df4bf00
5 changed files with 213 additions and 86 deletions

View File

@@ -2,10 +2,8 @@
import { useMutation, useQuery } from '@vue/apollo-composable';
import {
CatalogProductTypeSettingsDocument,
ClientProductsDocument,
UpsertCatalogProductTypeSettingDocument,
type CatalogProductTypeSettingsQuery,
type ClientProductsQuery,
} from '~/composables/graphql/generated';
definePageMeta({
@@ -14,7 +12,14 @@ definePageMeta({
});
type CatalogSettingItem = CatalogProductTypeSettingsQuery['catalogProductTypeSettings'][number];
type ProductNode = ClientProductsQuery['clientProducts'][number];
type OptionKey =
| 'widthOptionsMm'
| 'lengthOptionsM'
| 'thicknessOptionsMicron'
| 'sleeveOptions'
| 'colorOptions'
| 'labelOptions';
type OptionKind = 'number' | 'text';
type CatalogSettingForm = {
productType: string;
allowCustomLength: boolean;
@@ -23,17 +28,32 @@ type CatalogSettingForm = {
customLengthStepM: string;
allowCustomSleeveBrand: boolean;
allowCustomLabel: boolean;
widthOptionsMm: string[];
lengthOptionsM: string[];
thicknessOptionsMicron: string[];
sleeveOptions: string[];
colorOptions: string[];
labelOptions: string[];
drafts: Record<OptionKey, string>;
};
type StandardOptionGroup = {
type OptionGroupDefinition = {
key: OptionKey;
label: string;
values: string[];
placeholder: string;
kind: OptionKind;
suffix?: string;
};
const COLOR_TAGS = ['прозрачный', 'коричневый', 'белый', 'черный', 'желтый', 'зеленый', 'красный', 'синий', 'оранжевый', 'красно-белый'];
const LABEL_TAGS = ['хрупкое', 'подарок', 'акция'];
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 catalogProductsQuery = useQuery(ClientProductsDocument);
const saveSettingMutation = useMutation(UpsertCatalogProductTypeSettingDocument, { throws: 'never' });
const forms = reactive<Record<string, CatalogSettingForm>>({});
@@ -42,64 +62,64 @@ const saveSuccess = ref('');
const saveError = ref('');
const settings = computed<CatalogSettingItem[]>(() => settingsQuery.result.value?.catalogProductTypeSettings ?? []);
const isLoading = computed(() => settingsQuery.loading.value || catalogProductsQuery.loading.value);
const isLoading = computed(() => settingsQuery.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 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,
@@ -109,25 +129,45 @@ function createForm(item: CatalogSettingItem): CatalogSettingForm {
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 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] ?? [];
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(
@@ -165,6 +205,12 @@ async function saveAllSettings() {
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),
},
});
@@ -194,13 +240,13 @@ async function saveAllSettings() {
Типы товаров пока не появились в каталоге.
</div>
<div v-else class="grid gap-4 xl:grid-cols-2">
<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="flex flex-col gap-4">
<div class="space-y-5">
<h2 class="text-xl font-bold text-[#123824]">{{ item.productType }}</h2>
<div class="space-y-3">
@@ -258,23 +304,56 @@ async function saveAllSettings() {
</div>
</div>
<div v-if="standardOptionsFor(item).length" class="rounded-[24px] bg-[#f7fbf8] p-4">
<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-3">
<div class="space-y-4">
<div
v-for="group in standardOptionsFor(item)"
:key="`${item.productType}-${group.label}`"
class="space-y-2"
v-for="group in OPTION_GROUPS"
:key="`${item.productType}-${group.key}`"
class="rounded-[20px] bg-white p-4"
>
<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 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>