Add catalog settings management
This commit is contained in:
@@ -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: 'Сообщения',
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>;
|
||||
257
app/pages/catalog-settings.vue
Normal file
257
app/pages/catalog-settings.vue
Normal 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>
|
||||
@@ -0,0 +1,12 @@
|
||||
query CatalogProductTypeSettings {
|
||||
catalogProductTypeSettings {
|
||||
productType
|
||||
showQuantityPerBox
|
||||
allowCustomLength
|
||||
customLengthMinM
|
||||
customLengthMaxM
|
||||
customLengthStepM
|
||||
allowCustomSleeveBrand
|
||||
allowCustomLabel
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
mutation UpsertCatalogProductTypeSetting($input: UpsertCatalogProductTypeSettingInput!) {
|
||||
upsertCatalogProductTypeSetting(input: $input) {
|
||||
productType
|
||||
showQuantityPerBox
|
||||
allowCustomLength
|
||||
customLengthMinM
|
||||
customLengthMaxM
|
||||
customLengthStepM
|
||||
allowCustomSleeveBrand
|
||||
allowCustomLabel
|
||||
}
|
||||
}
|
||||
@@ -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!
|
||||
|
||||
Reference in New Issue
Block a user