Add catalog settings management

This commit is contained in:
Ruslan Bakiev
2026-04-09 16:03:32 +07:00
parent e8fbe84e4f
commit 7ed5fbd66d
7 changed files with 504 additions and 9 deletions

View File

@@ -83,6 +83,14 @@ const managerPageTabs = computed(() => {
if (route.path.startsWith('/admin/settings')) {
return [
{
key: 'catalog',
label: 'Каталог',
active: route.path === '/admin/settings/catalog',
to: {
path: '/admin/settings/catalog',
},
},
{
key: 'messages',
label: 'Сообщения',

View File

@@ -1,9 +1,15 @@
<script setup lang="ts">
import { useQuery } from '@vue/apollo-composable';
import { ClientProductsDocument, type ClientProductsQuery } from '~/composables/graphql/generated';
import {
CatalogProductTypeSettingsDocument,
ClientProductsDocument,
type CatalogProductTypeSettingsQuery,
type ClientProductsQuery,
} from '~/composables/graphql/generated';
import { useClientCart } from '~/composables/useClientCart';
type ProductNode = ClientProductsQuery['clientProducts'][number];
type CatalogProductTypeSettingNode = CatalogProductTypeSettingsQuery['catalogProductTypeSettings'][number];
type ParamFieldKey = 'widthMm' | 'lengthM' | 'thicknessMicron' | 'sleeveBrand' | 'quantityPerBox' | 'colorTag' | 'labelTag';
type ParamValue = number | string;
@@ -35,6 +41,16 @@ type GroupState = {
const COLOR_TAGS = ['прозрачный', 'коричневый', 'белый', 'черный', 'желтый', 'зеленый', 'красный', 'синий', 'оранжевый', 'красно-белый'];
const LABEL_TAGS = ['хрупкое', 'подарок', 'акция'];
const PARAM_KEYS: ParamFieldKey[] = ['widthMm', 'lengthM', 'thicknessMicron', 'sleeveBrand', 'quantityPerBox', 'colorTag', 'labelTag'];
const DEFAULT_CATALOG_PRODUCT_TYPE_SETTING: CatalogProductTypeSettingNode = {
productType: '',
showQuantityPerBox: false,
allowCustomLength: false,
customLengthMinM: null,
customLengthMaxM: null,
customLengthStepM: null,
allowCustomSleeveBrand: false,
allowCustomLabel: false,
};
const parameterFields: Array<{ key: ParamFieldKey; label: string }> = [
{ key: 'widthMm', label: 'Ширина' },
{ key: 'lengthM', label: 'Длина' },
@@ -51,11 +67,22 @@ const coverPresets = [
['#e8f5ec', '#b2e0c6', '#7dd0a9'],
];
const { result, loading, error } = useQuery(ClientProductsDocument);
const productsQuery = useQuery(ClientProductsDocument);
const catalogSettingsQuery = useQuery(CatalogProductTypeSettingsDocument);
const search = ref('');
const groupStates = reactive<Record<string, GroupState>>({});
const { addProduct, getQuantity, incrementQuantity, decrementQuantity } = useClientCart();
const loading = computed(() => productsQuery.loading.value || catalogSettingsQuery.loading.value);
const error = computed(() => productsQuery.error.value || catalogSettingsQuery.error.value);
const catalogSettingsByType = computed<Record<string, CatalogProductTypeSettingNode>>(() => (
Object.fromEntries(
(catalogSettingsQuery.result.value?.catalogProductTypeSettings ?? [])
.map((setting) => [setting.productType, setting]),
)
));
function normalizeText(value: string | null | undefined) {
return String(value ?? '').replaceAll(/\s+/g, ' ').trim();
}
@@ -135,7 +162,7 @@ function compareProducts(a: ParsedProduct, b: ParsedProduct) {
}
const parsedProducts = computed<ParsedProduct[]>(() => {
const list = result.value?.clientProducts ?? [];
const list = productsQuery.result.value?.clientProducts ?? [];
const query = search.value.trim().toLowerCase();
return list
@@ -228,8 +255,23 @@ function getAllFieldOptions(group: ProductGroup, field: ParamFieldKey) {
return sortParamValues([...values]);
}
function groupCatalogSetting(group: ProductGroup) {
return catalogSettingsByType.value[group.typeLabel] ?? {
...DEFAULT_CATALOG_PRODUCT_TYPE_SETTING,
productType: group.typeLabel,
};
}
function visibleFields(group: ProductGroup) {
return parameterFields.filter((field) => getAllFieldOptions(group, field.key).length > 1);
const catalogSetting = groupCatalogSetting(group);
return parameterFields.filter((field) => {
if (field.key === 'quantityPerBox' && !catalogSetting.showQuantityPerBox) {
return false;
}
return getAllFieldOptions(group, field.key).length > 1;
});
}
function requiredKeys(group: ProductGroup) {
@@ -301,7 +343,7 @@ function selectedProduct(group: ProductGroup) {
const state = getGroupState(group);
if (keys.length === 0) {
return group.products.length === 1 ? group.products[0] : null;
return group.products[0] ?? null;
}
if (keys.some((key) => state[key] === null)) {
@@ -309,7 +351,7 @@ function selectedProduct(group: ProductGroup) {
}
const matches = group.products.filter((product) => matchesProductState(product, state, keys));
return matches.length === 1 ? matches[0] : null;
return matches[0] ?? null;
}
function formatOptionLabel(field: ParamFieldKey, value: ParamValue) {
@@ -441,6 +483,25 @@ function variantCountLabel(count: number) {
return `${count} вариантов`;
}
function customizationNotes(group: ProductGroup) {
const setting = groupCatalogSetting(group);
const notes: string[] = [];
if (setting.allowCustomLength && setting.customLengthMinM && setting.customLengthMaxM && setting.customLengthStepM) {
notes.push(`Своя длина ${setting.customLengthMinM}-${setting.customLengthMaxM} м, шаг ${setting.customLengthStepM} м`);
}
if (setting.allowCustomSleeveBrand) {
notes.push('Своя втулка');
}
if (setting.allowCustomLabel) {
notes.push('Своя надпись');
}
return notes;
}
function toggleExpanded(group: ProductGroup) {
getGroupState(group).isExpanded = !getGroupState(group).isExpanded;
}
@@ -513,6 +574,15 @@ function decrementSelected(group: ProductGroup) {
<div class="p-4 md:p-5 xl:col-span-4">
<div class="mb-4">
<h2 class="text-2xl font-bold text-[#163624]">{{ group.typeLabel }}</h2>
<div v-if="customizationNotes(group).length" class="mt-3 flex flex-wrap gap-2">
<span
v-for="note in customizationNotes(group)"
:key="`${group.key}-${note}`"
class="rounded-full bg-[#eef8f1] px-3 py-1 text-xs font-semibold text-[#15613d]"
>
{{ note }}
</span>
</div>
</div>
<div class="grid content-start gap-4 md:grid-cols-2 2xl:grid-cols-3">
@@ -599,7 +669,7 @@ function decrementSelected(group: ProductGroup) {
<th class="border-b border-base-300">Длина</th>
<th class="border-b border-base-300">Толщина</th>
<th class="border-b border-base-300">Втулка</th>
<th class="border-b border-base-300">Короб</th>
<th v-if="groupCatalogSetting(group).showQuantityPerBox" class="border-b border-base-300">Короб</th>
<th class="border-b border-base-300 text-right">Действие</th>
</tr>
</thead>
@@ -610,7 +680,7 @@ function decrementSelected(group: ProductGroup) {
<td class="border-b border-base-200">{{ product.lengthM ?? '—' }}</td>
<td class="border-b border-base-200">{{ product.thicknessMicron ?? '—' }}</td>
<td class="border-b border-base-200">{{ product.sleeveBrand ?? '—' }}</td>
<td class="border-b border-base-200">{{ product.quantityPerBox ?? '—' }}</td>
<td v-if="groupCatalogSetting(group).showQuantityPerBox" class="border-b border-base-200">{{ product.quantityPerBox ?? '' }}</td>
<td class="border-b border-base-200 text-right">
<button
v-if="getQuantity(product.id) === 0"

View File

@@ -89,6 +89,18 @@ export type CartItem = {
updatedAt: Scalars['DateTime']['output'];
};
export type CatalogProductTypeSetting = {
__typename?: 'CatalogProductTypeSetting';
allowCustomLabel: Scalars['Boolean']['output'];
allowCustomLength: Scalars['Boolean']['output'];
allowCustomSleeveBrand: Scalars['Boolean']['output'];
customLengthMaxM?: Maybe<Scalars['Int']['output']>;
customLengthMinM?: Maybe<Scalars['Int']['output']>;
customLengthStepM?: Maybe<Scalars['Int']['output']>;
productType: Scalars['String']['output'];
showQuantityPerBox: Scalars['Boolean']['output'];
};
export type Company = {
__typename?: 'Company';
id: Scalars['ID']['output'];
@@ -328,6 +340,7 @@ export type Mutation = {
submitCalculationOrder: Order;
submitReadyOrder: Order;
updateCartItemQuantity: Cart;
upsertCatalogProductTypeSetting: CatalogProductTypeSetting;
upsertMyCounterpartyProfile: CounterpartyProfile;
verifyLoginCode: AuthSession;
};
@@ -467,6 +480,11 @@ export type MutationUpdateCartItemQuantityArgs = {
};
export type MutationUpsertCatalogProductTypeSettingArgs = {
input: UpsertCatalogProductTypeSettingInput;
};
export type MutationUpsertMyCounterpartyProfileArgs = {
input: UpsertMyCounterpartyProfileInput;
};
@@ -592,6 +610,7 @@ export type ProductWarehouseBalance = {
export type Query = {
__typename?: 'Query';
catalogProductTypeSettings: Array<CatalogProductTypeSetting>;
clientProducts: Array<Product>;
healthcheck: Scalars['String']['output'];
integrationSyncDashboard: IntegrationSyncDashboard;
@@ -763,6 +782,17 @@ export type UpdateCartItemQuantityInput = {
quantity: Scalars['Float']['input'];
};
export type UpsertCatalogProductTypeSettingInput = {
allowCustomLabel: Scalars['Boolean']['input'];
allowCustomLength: Scalars['Boolean']['input'];
allowCustomSleeveBrand: Scalars['Boolean']['input'];
customLengthMaxM?: InputMaybe<Scalars['Int']['input']>;
customLengthMinM?: InputMaybe<Scalars['Int']['input']>;
customLengthStepM?: InputMaybe<Scalars['Int']['input']>;
productType: Scalars['String']['input'];
showQuantityPerBox: Scalars['Boolean']['input'];
};
export type UpsertMyCounterpartyProfileInput = {
bankName: Scalars['String']['input'];
bik: Scalars['String']['input'];
@@ -1125,11 +1155,23 @@ export type UpsertMyCounterpartyProfileMutationVariables = Exact<{
export type UpsertMyCounterpartyProfileMutation = { __typename?: 'Mutation', upsertMyCounterpartyProfile: { __typename?: 'CounterpartyProfile', id: string, companyName: string, companyFullName: string, inn: string, kpp?: string | null, ogrn?: string | null, legalAddress: string, bankName: string, bik: string, correspondentAccount: string, checkingAccount: string, signerFullName: string, signerPosition: string, signerBasis: string, isComplete: boolean, updatedAt: any } };
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 IntegrationSyncDashboardQueryVariables = Exact<{ [key: string]: never; }>;
export type IntegrationSyncDashboardQuery = { __typename?: 'Query', integrationSyncDashboard: { __typename?: 'IntegrationSyncDashboard', generatedAt: any, lastActivityAt?: any | null, totalOrders: number, totalProducts: number, totalClients: number, items: Array<{ __typename?: 'IntegrationSyncItem', id: string, title: string, description: string, source: string, syncedCount: number, lastSyncedAt?: any | null, status: string, note: string }> } };
export type UpsertCatalogProductTypeSettingMutationVariables = Exact<{
input: UpsertCatalogProductTypeSettingInput;
}>;
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 const ConsumeLoginTokenDocument = gql`
mutation ConsumeLoginToken($token: String!) {
@@ -2927,6 +2969,40 @@ export function useUpsertMyCounterpartyProfileMutation(options: VueApolloComposa
return VueApolloComposable.useMutation<UpsertMyCounterpartyProfileMutation, UpsertMyCounterpartyProfileMutationVariables>(UpsertMyCounterpartyProfileDocument, options);
}
export type UpsertMyCounterpartyProfileMutationCompositionFunctionResult = VueApolloComposable.UseMutationReturn<UpsertMyCounterpartyProfileMutation, UpsertMyCounterpartyProfileMutationVariables>;
export const CatalogProductTypeSettingsDocument = gql`
query CatalogProductTypeSettings {
catalogProductTypeSettings {
productType
showQuantityPerBox
allowCustomLength
customLengthMinM
customLengthMaxM
customLengthStepM
allowCustomSleeveBrand
allowCustomLabel
}
}
`;
/**
* __useCatalogProductTypeSettingsQuery__
*
* To run a query within a Vue component, call `useCatalogProductTypeSettingsQuery` and pass it any options that fit your needs.
* When your component renders, `useCatalogProductTypeSettingsQuery` returns an object from Apollo Client that contains result, loading and error properties
* you can use to render your UI.
*
* @param options that will be passed into the query, supported options are listed on: https://v4.apollo.vuejs.org/guide-composable/query.html#options;
*
* @example
* const { result, loading, error } = useCatalogProductTypeSettingsQuery();
*/
export function useCatalogProductTypeSettingsQuery(options: VueApolloComposable.UseQueryOptions<CatalogProductTypeSettingsQuery, CatalogProductTypeSettingsQueryVariables> | VueCompositionApi.Ref<VueApolloComposable.UseQueryOptions<CatalogProductTypeSettingsQuery, CatalogProductTypeSettingsQueryVariables>> | ReactiveFunction<VueApolloComposable.UseQueryOptions<CatalogProductTypeSettingsQuery, CatalogProductTypeSettingsQueryVariables>> = {}) {
return VueApolloComposable.useQuery<CatalogProductTypeSettingsQuery, CatalogProductTypeSettingsQueryVariables>(CatalogProductTypeSettingsDocument, {}, options);
}
export function useCatalogProductTypeSettingsLazyQuery(options: VueApolloComposable.UseQueryOptions<CatalogProductTypeSettingsQuery, CatalogProductTypeSettingsQueryVariables> | VueCompositionApi.Ref<VueApolloComposable.UseQueryOptions<CatalogProductTypeSettingsQuery, CatalogProductTypeSettingsQueryVariables>> | ReactiveFunction<VueApolloComposable.UseQueryOptions<CatalogProductTypeSettingsQuery, CatalogProductTypeSettingsQueryVariables>> = {}) {
return VueApolloComposable.useLazyQuery<CatalogProductTypeSettingsQuery, CatalogProductTypeSettingsQueryVariables>(CatalogProductTypeSettingsDocument, {}, options);
}
export type CatalogProductTypeSettingsQueryCompositionFunctionResult = VueApolloComposable.UseQueryReturn<CatalogProductTypeSettingsQuery, CatalogProductTypeSettingsQueryVariables>;
export const IntegrationSyncDashboardDocument = gql`
query IntegrationSyncDashboard {
integrationSyncDashboard {
@@ -2967,4 +3043,40 @@ export function useIntegrationSyncDashboardQuery(options: VueApolloComposable.Us
export function useIntegrationSyncDashboardLazyQuery(options: VueApolloComposable.UseQueryOptions<IntegrationSyncDashboardQuery, IntegrationSyncDashboardQueryVariables> | VueCompositionApi.Ref<VueApolloComposable.UseQueryOptions<IntegrationSyncDashboardQuery, IntegrationSyncDashboardQueryVariables>> | ReactiveFunction<VueApolloComposable.UseQueryOptions<IntegrationSyncDashboardQuery, IntegrationSyncDashboardQueryVariables>> = {}) {
return VueApolloComposable.useLazyQuery<IntegrationSyncDashboardQuery, IntegrationSyncDashboardQueryVariables>(IntegrationSyncDashboardDocument, {}, options);
}
export type IntegrationSyncDashboardQueryCompositionFunctionResult = VueApolloComposable.UseQueryReturn<IntegrationSyncDashboardQuery, IntegrationSyncDashboardQueryVariables>;
export type IntegrationSyncDashboardQueryCompositionFunctionResult = VueApolloComposable.UseQueryReturn<IntegrationSyncDashboardQuery, IntegrationSyncDashboardQueryVariables>;
export const UpsertCatalogProductTypeSettingDocument = gql`
mutation UpsertCatalogProductTypeSetting($input: UpsertCatalogProductTypeSettingInput!) {
upsertCatalogProductTypeSetting(input: $input) {
productType
showQuantityPerBox
allowCustomLength
customLengthMinM
customLengthMaxM
customLengthStepM
allowCustomSleeveBrand
allowCustomLabel
}
}
`;
/**
* __useUpsertCatalogProductTypeSettingMutation__
*
* To run a mutation, you first call `useUpsertCatalogProductTypeSettingMutation` within a Vue component and pass it any options that fit your needs.
* When your component renders, `useUpsertCatalogProductTypeSettingMutation` returns an object that includes:
* - A mutate function that you can call at any time to execute the mutation
* - Several other properties: https://v4.apollo.vuejs.org/api/use-mutation.html#return
*
* @param options that will be passed into the mutation, supported options are listed on: https://v4.apollo.vuejs.org/guide-composable/mutation.html#options;
*
* @example
* const { mutate, loading, error, onDone } = useUpsertCatalogProductTypeSettingMutation({
* variables: {
* input: // value for 'input'
* },
* });
*/
export function useUpsertCatalogProductTypeSettingMutation(options: VueApolloComposable.UseMutationOptions<UpsertCatalogProductTypeSettingMutation, UpsertCatalogProductTypeSettingMutationVariables> | ReactiveFunction<VueApolloComposable.UseMutationOptions<UpsertCatalogProductTypeSettingMutation, UpsertCatalogProductTypeSettingMutationVariables>> = {}) {
return VueApolloComposable.useMutation<UpsertCatalogProductTypeSettingMutation, UpsertCatalogProductTypeSettingMutationVariables>(UpsertCatalogProductTypeSettingDocument, options);
}
export type UpsertCatalogProductTypeSettingMutationCompositionFunctionResult = VueApolloComposable.UseMutationReturn<UpsertCatalogProductTypeSettingMutation, UpsertCatalogProductTypeSettingMutationVariables>;

View File

@@ -0,0 +1,257 @@
<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 CatalogSettingForm = {
productType: string;
showQuantityPerBox: boolean;
allowCustomLength: boolean;
customLengthMinM: string;
customLengthMaxM: string;
customLengthStepM: string;
allowCustomSleeveBrand: boolean;
allowCustomLabel: boolean;
};
const settingsQuery = useQuery(CatalogProductTypeSettingsDocument);
const saveSettingMutation = useMutation(UpsertCatalogProductTypeSettingDocument, { throws: 'never' });
const forms = reactive<Record<string, CatalogSettingForm>>({});
const savingState = reactive<Record<string, boolean>>({});
const successMessage = reactive<Record<string, string>>({});
const errorMessage = reactive<Record<string, string>>({});
const settings = computed<CatalogSettingItem[]>(() => settingsQuery.result.value?.catalogProductTypeSettings ?? []);
function toInputValue(value: number | null | undefined) {
return value == null ? '' : String(value);
}
function createForm(item: CatalogSettingItem): CatalogSettingForm {
return {
productType: item.productType,
showQuantityPerBox: item.showQuantityPerBox,
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);
}
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);
Reflect.deleteProperty(savingState, productType);
Reflect.deleteProperty(successMessage, productType);
Reflect.deleteProperty(errorMessage, productType);
}
}
for (const item of items) {
forms[item.productType] = createForm(item);
savingState[item.productType] ??= false;
successMessage[item.productType] ??= '';
errorMessage[item.productType] ??= '';
}
},
{ immediate: true },
);
async function saveProductTypeSetting(productType: string) {
const form = forms[productType];
if (!form) {
return;
}
successMessage[productType] = '';
errorMessage[productType] = '';
savingState[productType] = true;
const result = await saveSettingMutation.mutate({
input: {
productType: form.productType,
showQuantityPerBox: form.showQuantityPerBox,
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,
},
});
savingState[productType] = false;
if (!result?.data?.upsertCatalogProductTypeSetting) {
errorMessage[productType] = saveSettingMutation.error.value?.message || 'Не удалось сохранить настройки.';
return;
}
forms[productType] = createForm(result.data.upsertCatalogProductTypeSetting);
successMessage[productType] = 'Настройки сохранены.';
}
</script>
<template>
<section class="space-y-6">
<div class="space-y-2">
<h1 class="text-3xl font-extrabold text-[#0f2f20]">Каталог</h1>
<p class="text-sm leading-6 text-[#557562]">
Правила конструктора по каждому типу товара: длина, своя втулка, своя надпись и видимость короба.
</p>
</div>
<div v-if="settingsQuery.loading.value" 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">
<div class="space-y-1">
<h2 class="text-xl font-bold text-[#123824]">{{ item.productType }}</h2>
<p class="text-sm text-[#557562]">
Определяет, какие параметры клиент сможет менять на витрине для этого типа товара.
</p>
</div>
<div class="grid gap-3 md:grid-cols-2">
<label class="surface-card flex items-start gap-3 rounded-[22px] p-4">
<input v-model="forms[item.productType].allowCustomLength" type="checkbox" class="toggle toggle-success mt-1">
<span>
<span class="block font-semibold text-[#123824]">Своя длина</span>
<span class="mt-1 block text-sm text-[#557562]">Разрешить длину вне стандартного списка.</span>
</span>
</label>
<label class="surface-card flex items-start gap-3 rounded-[22px] p-4">
<input v-model="forms[item.productType].allowCustomSleeveBrand" type="checkbox" class="toggle toggle-success mt-1">
<span>
<span class="block font-semibold text-[#123824]">Своя втулка</span>
<span class="mt-1 block text-sm text-[#557562]">Клиент сможет запросить втулку со своим вариантом.</span>
</span>
</label>
<label class="surface-card flex items-start gap-3 rounded-[22px] p-4">
<input v-model="forms[item.productType].allowCustomLabel" type="checkbox" class="toggle toggle-success mt-1">
<span>
<span class="block font-semibold text-[#123824]">Своя надпись</span>
<span class="mt-1 block text-sm text-[#557562]">Разрешить индивидуальную надпись или маркировку.</span>
</span>
</label>
<label class="surface-card flex items-start gap-3 rounded-[22px] p-4">
<input v-model="forms[item.productType].showQuantityPerBox" type="checkbox" class="toggle toggle-success mt-1">
<span>
<span class="block font-semibold text-[#123824]">Показывать короб</span>
<span class="mt-1 block text-sm text-[#557562]">Если выключено, параметр короба скрывается из клиентского каталога.</span>
</span>
</label>
</div>
<div class="rounded-[24px] bg-[#f7fbf8] p-4">
<div class="flex items-center justify-between gap-3">
<div>
<h3 class="text-sm font-bold uppercase tracking-[0.12em] text-[#355947]">Диапазон длины</h3>
<p class="mt-1 text-sm text-[#557562]">Минимум, максимум и шаг для пользовательской длины.</p>
</div>
<span
class="rounded-full px-3 py-1 text-xs font-bold uppercase tracking-[0.12em]"
:class="forms[item.productType].allowCustomLength ? 'bg-[#e8f5ec] text-[#1c6b45]' : 'bg-[#eef2ef] text-[#6b7f71]'"
>
{{ forms[item.productType].allowCustomLength ? 'Включено' : 'Выключено' }}
</span>
</div>
<div class="mt-4 grid gap-3 md:grid-cols-3">
<label class="space-y-2">
<span class="text-sm font-semibold text-[#123824]">Мин. длина, м</span>
<input
v-model="forms[item.productType].customLengthMinM"
type="number"
min="1"
step="1"
class="input manager-field w-full"
:disabled="!forms[item.productType].allowCustomLength"
>
</label>
<label class="space-y-2">
<span class="text-sm font-semibold text-[#123824]">Макс. длина, м</span>
<input
v-model="forms[item.productType].customLengthMaxM"
type="number"
min="1"
step="1"
class="input manager-field w-full"
:disabled="!forms[item.productType].allowCustomLength"
>
</label>
<label class="space-y-2">
<span class="text-sm font-semibold text-[#123824]">Шаг, м</span>
<input
v-model="forms[item.productType].customLengthStepM"
type="number"
min="1"
step="1"
class="input manager-field w-full"
:disabled="!forms[item.productType].allowCustomLength"
>
</label>
</div>
</div>
<div class="flex flex-wrap items-center justify-between gap-3">
<div class="space-y-1 text-sm">
<p v-if="successMessage[item.productType]" class="font-semibold text-[#1c6b45]">{{ successMessage[item.productType] }}</p>
<p v-if="errorMessage[item.productType]" class="font-semibold text-[#c4472d]">{{ errorMessage[item.productType] }}</p>
</div>
<button
class="btn h-11 rounded-full border-0 bg-[#139957] px-5 text-sm font-semibold text-white hover:bg-[#0d854a]"
:disabled="savingState[item.productType]"
@click="saveProductTypeSetting(item.productType)"
>
{{ savingState[item.productType] ? 'Сохраняем…' : 'Сохранить' }}
</button>
</div>
</div>
</article>
</div>
</section>
</template>

View File

@@ -0,0 +1,12 @@
query CatalogProductTypeSettings {
catalogProductTypeSettings {
productType
showQuantityPerBox
allowCustomLength
customLengthMinM
customLengthMaxM
customLengthStepM
allowCustomSleeveBrand
allowCustomLabel
}
}

View File

@@ -0,0 +1,12 @@
mutation UpsertCatalogProductTypeSetting($input: UpsertCatalogProductTypeSettingInput!) {
upsertCatalogProductTypeSetting(input: $input) {
productType
showQuantityPerBox
allowCustomLength
customLengthMinM
customLengthMaxM
customLengthStepM
allowCustomSleeveBrand
allowCustomLabel
}
}

View File

@@ -248,6 +248,17 @@ type Product {
availableInWarehouses: [ProductWarehouseBalance!]!
}
type CatalogProductTypeSetting {
productType: String!
showQuantityPerBox: Boolean!
allowCustomLength: Boolean!
customLengthMinM: Int
customLengthMaxM: Int
customLengthStepM: Int
allowCustomSleeveBrand: Boolean!
allowCustomLabel: Boolean!
}
type CartItem {
id: ID!
productId: ID!
@@ -411,6 +422,7 @@ type Query {
integrationSyncDashboard: IntegrationSyncDashboard!
managerNotificationHistory(userId: ID!, channel: MessengerType!, limit: Int = 50): [NotificationHistoryItem!]!
clientProducts: [Product!]!
catalogProductTypeSettings: [CatalogProductTypeSetting!]!
order(id: ID!): Order
myOrders: [Order!]!
myCurrentOrders: [Order!]!
@@ -491,6 +503,17 @@ input UpdateCartItemQuantityInput {
quantity: Float!
}
input UpsertCatalogProductTypeSettingInput {
productType: String!
showQuantityPerBox: Boolean!
allowCustomLength: Boolean!
customLengthMinM: Int
customLengthMaxM: Int
customLengthStepM: Int
allowCustomSleeveBrand: Boolean!
allowCustomLabel: Boolean!
}
input ReadyOrderItemInput {
productId: ID!
quantity: Float!
@@ -554,6 +577,7 @@ type Mutation {
connectMessenger(input: ConnectMessengerInput!): MessengerConnection!
deleteMyMessengerConnection(connectionId: ID!): Boolean!
upsertMyCounterpartyProfile(input: UpsertMyCounterpartyProfileInput!): CounterpartyProfile!
upsertCatalogProductTypeSetting(input: UpsertCatalogProductTypeSettingInput!): CatalogProductTypeSetting!
addProductToCart(productId: ID!): Cart!
updateCartItemQuantity(input: UpdateCartItemQuantityInput!): Cart!
removeCartItem(productId: ID!): Cart!