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

@@ -94,11 +94,17 @@ export type CatalogProductTypeSetting = {
allowCustomLabel: Scalars['Boolean']['output'];
allowCustomLength: Scalars['Boolean']['output'];
allowCustomSleeveBrand: Scalars['Boolean']['output'];
colorOptions: Array<Scalars['String']['output']>;
customLengthMaxM?: Maybe<Scalars['Int']['output']>;
customLengthMinM?: Maybe<Scalars['Int']['output']>;
customLengthStepM?: Maybe<Scalars['Int']['output']>;
labelOptions: Array<Scalars['String']['output']>;
lengthOptionsM: Array<Scalars['Int']['output']>;
productType: Scalars['String']['output'];
showQuantityPerBox: Scalars['Boolean']['output'];
sleeveOptions: Array<Scalars['String']['output']>;
thicknessOptionsMicron: Array<Scalars['Int']['output']>;
widthOptionsMm: Array<Scalars['Int']['output']>;
};
export type Company = {
@@ -786,11 +792,17 @@ export type UpsertCatalogProductTypeSettingInput = {
allowCustomLabel: Scalars['Boolean']['input'];
allowCustomLength: Scalars['Boolean']['input'];
allowCustomSleeveBrand: Scalars['Boolean']['input'];
colorOptions: Array<Scalars['String']['input']>;
customLengthMaxM?: InputMaybe<Scalars['Int']['input']>;
customLengthMinM?: InputMaybe<Scalars['Int']['input']>;
customLengthStepM?: InputMaybe<Scalars['Int']['input']>;
labelOptions: Array<Scalars['String']['input']>;
lengthOptionsM: Array<Scalars['Int']['input']>;
productType: Scalars['String']['input'];
showQuantityPerBox: Scalars['Boolean']['input'];
sleeveOptions: Array<Scalars['String']['input']>;
thicknessOptionsMicron: Array<Scalars['Int']['input']>;
widthOptionsMm: Array<Scalars['Int']['input']>;
};
export type UpsertMyCounterpartyProfileInput = {
@@ -1158,7 +1170,7 @@ export type UpsertMyCounterpartyProfileMutation = { __typename?: 'Mutation', ups
export type CatalogProductTypeSettingsQueryVariables = Exact<{ [key: string]: never; }>;
export type CatalogProductTypeSettingsQuery = { __typename?: 'Query', catalogProductTypeSettings: Array<{ __typename?: 'CatalogProductTypeSetting', productType: string, showQuantityPerBox: boolean, allowCustomLength: boolean, customLengthMinM?: number | null, customLengthMaxM?: number | null, customLengthStepM?: number | null, allowCustomSleeveBrand: boolean, allowCustomLabel: boolean }> };
export type CatalogProductTypeSettingsQuery = { __typename?: 'Query', catalogProductTypeSettings: Array<{ __typename?: 'CatalogProductTypeSetting', productType: string, showQuantityPerBox: boolean, allowCustomLength: boolean, customLengthMinM?: number | null, customLengthMaxM?: number | null, customLengthStepM?: number | null, allowCustomSleeveBrand: boolean, allowCustomLabel: boolean, widthOptionsMm: Array<number>, lengthOptionsM: Array<number>, thicknessOptionsMicron: Array<number>, sleeveOptions: Array<string>, colorOptions: Array<string>, labelOptions: Array<string> }> };
export type IntegrationSyncDashboardQueryVariables = Exact<{ [key: string]: never; }>;
@@ -1170,7 +1182,7 @@ export type UpsertCatalogProductTypeSettingMutationVariables = Exact<{
}>;
export type UpsertCatalogProductTypeSettingMutation = { __typename?: 'Mutation', upsertCatalogProductTypeSetting: { __typename?: 'CatalogProductTypeSetting', productType: string, showQuantityPerBox: boolean, allowCustomLength: boolean, customLengthMinM?: number | null, customLengthMaxM?: number | null, customLengthStepM?: number | null, allowCustomSleeveBrand: boolean, allowCustomLabel: boolean } };
export type UpsertCatalogProductTypeSettingMutation = { __typename?: 'Mutation', upsertCatalogProductTypeSetting: { __typename?: 'CatalogProductTypeSetting', productType: string, showQuantityPerBox: boolean, allowCustomLength: boolean, customLengthMinM?: number | null, customLengthMaxM?: number | null, customLengthStepM?: number | null, allowCustomSleeveBrand: boolean, allowCustomLabel: boolean, widthOptionsMm: Array<number>, lengthOptionsM: Array<number>, thicknessOptionsMicron: Array<number>, sleeveOptions: Array<string>, colorOptions: Array<string>, labelOptions: Array<string> } };
export const ConsumeLoginTokenDocument = gql`
@@ -2980,6 +2992,12 @@ export const CatalogProductTypeSettingsDocument = gql`
customLengthStepM
allowCustomSleeveBrand
allowCustomLabel
widthOptionsMm
lengthOptionsM
thicknessOptionsMicron
sleeveOptions
colorOptions
labelOptions
}
}
`;
@@ -3055,6 +3073,12 @@ export const UpsertCatalogProductTypeSettingDocument = gql`
customLengthStepM
allowCustomSleeveBrand
allowCustomLabel
widthOptionsMm
lengthOptionsM
thicknessOptionsMicron
sleeveOptions
colorOptions
labelOptions
}
}
`;

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>

View File

@@ -8,5 +8,11 @@ query CatalogProductTypeSettings {
customLengthStepM
allowCustomSleeveBrand
allowCustomLabel
widthOptionsMm
lengthOptionsM
thicknessOptionsMicron
sleeveOptions
colorOptions
labelOptions
}
}

View File

@@ -8,5 +8,11 @@ mutation UpsertCatalogProductTypeSetting($input: UpsertCatalogProductTypeSetting
customLengthStepM
allowCustomSleeveBrand
allowCustomLabel
widthOptionsMm
lengthOptionsM
thicknessOptionsMicron
sleeveOptions
colorOptions
labelOptions
}
}

View File

@@ -257,6 +257,12 @@ type CatalogProductTypeSetting {
customLengthStepM: Int
allowCustomSleeveBrand: Boolean!
allowCustomLabel: Boolean!
widthOptionsMm: [Int!]!
lengthOptionsM: [Int!]!
thicknessOptionsMicron: [Int!]!
sleeveOptions: [String!]!
colorOptions: [String!]!
labelOptions: [String!]!
}
type CartItem {
@@ -512,6 +518,12 @@ input UpsertCatalogProductTypeSettingInput {
customLengthStepM: Int
allowCustomSleeveBrand: Boolean!
allowCustomLabel: Boolean!
widthOptionsMm: [Int!]!
lengthOptionsM: [Int!]!
thicknessOptionsMicron: [Int!]!
sleeveOptions: [String!]!
colorOptions: [String!]!
labelOptions: [String!]!
}
input ReadyOrderItemInput {