Compare commits

67 Commits

Author SHA1 Message Date
Ruslan Bakiev
f9be58d061 Revert "Add catalog product images"
This reverts commit 898171cb5f.
2026-05-16 17:16:24 +07:00
Ruslan Bakiev
898171cb5f Add catalog product images 2026-05-16 17:15:10 +07:00
Ruslan Bakiev
5f68cc80b1 Update TZ client request flow 2026-05-15 20:19:36 +07:00
Ruslan Bakiev
0ce037df6e Update TZ request to order flow 2026-05-15 19:53:12 +07:00
Ruslan Bakiev
d3f56efac1 Sync TZ deployment infrastructure 2026-05-15 15:53:40 +07:00
Ruslan Bakiev
49e0e444d9 Update TZ client data flow 2026-05-15 15:13:47 +07:00
Ruslan Bakiev
6a49cbcc30 Include pnpm workspace config in Docker build 2026-05-15 12:32:48 +07:00
Ruslan Bakiev
107623ca92 Allow pnpm dependency build scripts 2026-05-15 12:24:19 +07:00
Ruslan Bakiev
dfc053b723 Clarify invite-only client onboarding 2026-05-14 08:30:46 +07:00
Ruslan Bakiev
81efc78029 Align specification prototypes with screens 2026-05-05 10:38:56 +07:00
Ruslan Bakiev
948385bc32 Refine specification title page 2026-05-04 14:34:32 +07:00
Ruslan Bakiev
283abf95a1 Clarify 1C file exchange concept 2026-05-04 14:30:19 +07:00
Ruslan Bakiev
fb22a6b11d Define document exchange for 1C integration 2026-05-04 14:14:54 +07:00
Ruslan Bakiev
b971444976 Simplify specification work stages 2026-05-04 11:59:42 +07:00
Ruslan Bakiev
415fcf40fe Align specification stages and prototypes 2026-05-04 11:53:45 +07:00
Ruslan Bakiev
d9cefb9f54 Start specification sections on new pages 2026-05-04 11:46:10 +07:00
Ruslan Bakiev
a52eb45ca3 Shorten specification overview 2026-05-04 11:41:59 +07:00
Ruslan Bakiev
3d790b2102 Use automatic numbering in specification 2026-05-04 11:20:02 +07:00
Ruslan Bakiev
790b3a1d99 Clarify warranty claim procedure 2026-05-04 11:08:54 +07:00
Ruslan Bakiev
3885782afd Remove obsolete markdown documentation artifacts 2026-05-04 11:01:31 +07:00
Ruslan Bakiev
d21ff3437f Use Typst as source for specification PDF 2026-05-04 10:58:06 +07:00
Ruslan Bakiev
d86d817bce Remove internal references from technical specification 2026-05-04 10:48:29 +07:00
Ruslan Bakiev
234f46c082 Use serif font for Typst specification export 2026-05-04 10:38:35 +07:00
Ruslan Bakiev
bbd9dcfb5a Add Typst PDF export for technical specification 2026-05-04 10:13:45 +07:00
Ruslan Bakiev
ac312a3a62 Update technical specification structure 2026-05-04 09:59:07 +07:00
Ruslan Bakiev
0a96adbb78 Remove current-state appendix from specification 2026-05-01 16:54:21 +07:00
Ruslan Bakiev
98ae168a93 Add wireframe screen prototypes to specification 2026-05-01 16:20:19 +07:00
Ruslan Bakiev
e050fd55a5 Expand entities and page prototypes in specification 2026-05-01 15:51:51 +07:00
Ruslan Bakiev
58e9d6806d Restructure technical specification sections 2026-05-01 15:39:23 +07:00
Ruslan Bakiev
542ad1b648 Render documentation diagrams as static Mermaid assets 2026-05-01 15:09:02 +07:00
Ruslan Bakiev
b7a5018c6e Collapse technical specification into single page 2026-05-01 14:54:49 +07:00
Ruslan Bakiev
eb6dcf9a52 Renumber technical specification sections 2026-05-01 14:50:06 +07:00
Ruslan Bakiev
d514eac990 Document infrastructure and dependency inventory 2026-05-01 14:46:12 +07:00
Ruslan Bakiev
3a3bd09a8c Render technical diagrams with mermaid 2026-05-01 14:41:02 +07:00
Ruslan Bakiev
fc6117c8f5 Replace textual specs with schematic diagrams 2026-05-01 12:11:04 +07:00
Ruslan Bakiev
fccb3039bf Expand technical specification architecture and prototypes 2026-05-01 12:02:16 +07:00
Ruslan Bakiev
46bb36d63c Rewrite technical specification in formal style 2026-05-01 11:50:58 +07:00
Ruslan Bakiev
ef0622fe89 Expand technical specification content 2026-05-01 11:41:30 +07:00
Ruslan Bakiev
df721e273d Add VitePress technical specification draft 2026-05-01 11:24:14 +07:00
Ruslan Bakiev
3b3959ced0 Align catalog detail page geometry 2026-04-09 20:05:10 +07:00
Ruslan Bakiev
e8ff766c24 Refine catalog detail interactions 2026-04-09 19:47:50 +07:00
Ruslan Bakiev
03ac74e10b Simplify catalog product copy 2026-04-09 19:44:06 +07:00
Ruslan Bakiev
09054647aa Tighten catalog product detail layout 2026-04-09 19:40:33 +07:00
Ruslan Bakiev
93074c5c14 Redesign catalog product detail page 2026-04-09 19:29:48 +07:00
Ruslan Bakiev
73adbb76c7 Refine catalog detail layout 2026-04-09 19:14:14 +07:00
Ruslan Bakiev
0236d88b20 Simplify catalog cards and fix product routes 2026-04-09 19:00:25 +07:00
Ruslan Bakiev
76ab87620e Split catalog into list and detail pages 2026-04-09 18:49:25 +07:00
Ruslan Bakiev
21e40d3fa1 Refine catalog product cards 2026-04-09 18:43:25 +07:00
Ruslan Bakiev
848b491a90 Collapse catalog settings list 2026-04-09 18:39:16 +07:00
Ruslan Bakiev
28b29480bc Rename catalog parameters section 2026-04-09 18:31:39 +07:00
Ruslan Bakiev
2b134940f0 Simplify catalog option controls 2026-04-09 17:27:25 +07:00
Ruslan Bakiev
6f1df4bf00 Make catalog settings editable 2026-04-09 17:10:53 +07:00
Ruslan Bakiev
872dba648c Show standard catalog options in settings 2026-04-09 16:48:34 +07:00
Ruslan Bakiev
de5fc6b4a8 Hide box settings and rename customization labels 2026-04-09 16:42:14 +07:00
Ruslan Bakiev
d6f1a03501 Simplify catalog settings layout 2026-04-09 16:36:43 +07:00
Ruslan Bakiev
a5fd0a7d5e Fix catalog settings SSR form initialization 2026-04-09 16:23:13 +07:00
Ruslan Bakiev
7ed5fbd66d Add catalog settings management 2026-04-09 16:03:32 +07:00
Ruslan Bakiev
e8fbe84e4f Remove tags column from catalog table 2026-04-09 15:29:35 +07:00
Ruslan Bakiev
f8880d75c6 Show variant counts in catalog toggle 2026-04-09 15:13:56 +07:00
Ruslan Bakiev
21ce43b790 Align catalog parameter layout 2026-04-09 14:43:58 +07:00
Ruslan Bakiev
8d6bc7346c Group catalog variants by product type 2026-04-09 14:37:36 +07:00
Ruslan Bakiev
5173956b06 Show product tags in catalog 2026-04-09 14:14:10 +07:00
Ruslan Bakiev
249e081dec Rename bonus link route 2026-04-07 14:46:13 +07:00
Ruslan Bakiev
345301e138 Simplify bonus account link flow 2026-04-07 14:44:52 +07:00
Ruslan Bakiev
af5d06f990 Add bonus relation creation entrypoint 2026-04-07 14:40:27 +07:00
Ruslan Bakiev
647947d9cb Rename bonus balances to bonus accounts 2026-04-07 14:26:26 +07:00
Ruslan Bakiev
a54b4f4405 Simplify 1C sync status page 2026-04-07 10:56:45 +07:00
44 changed files with 7550 additions and 563 deletions

2
.gitignore vendored
View File

@@ -5,6 +5,8 @@
.nitro
.cache
dist
docs/.vitepress/cache
docs/export
# Node dependencies
node_modules

View File

@@ -4,7 +4,7 @@ WORKDIR /app
RUN corepack enable
COPY package.json pnpm-lock.yaml ./
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
RUN pnpm install --frozen-lockfile
FROM deps AS build

View File

@@ -51,7 +51,7 @@ const managerPageTabs = computed(() => {
return [
{
key: 'balances',
label: алансы',
label: онусные счета',
active: route.path === '/admin/bonuses'
|| route.path === '/admin/bonuses/balances'
|| route.path.startsWith('/admin/bonuses/balances/')
@@ -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,15 +1,28 @@
<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';
const props = defineProps<{
productTypeSlug: string;
}>();
type ProductNode = ClientProductsQuery['clientProducts'][number];
type ParamFieldKey = 'widthMm' | 'lengthM' | 'thicknessMicron' | 'sleeveBrand' | 'quantityPerBox';
type CatalogProductTypeSettingNode = CatalogProductTypeSettingsQuery['catalogProductTypeSettings'][number];
type ParamFieldKey = 'widthMm' | 'lengthM' | 'thicknessMicron' | 'sleeveBrand' | 'quantityPerBox' | 'colorTag' | 'labelTag';
type ParamValue = number | string;
type ParsedProduct = ProductNode & {
productTypeLabel: string;
quantityPerBoxOptions: string[];
normalizedTags: string[];
colorTags: string[];
labelTags: string[];
};
type ProductGroup = {
@@ -24,16 +37,37 @@ type GroupState = {
thicknessMicron: number | null;
sleeveBrand: string | null;
quantityPerBox: string | null;
isExpanded: boolean;
colorTag: string | null;
labelTag: string | null;
};
const PARAM_KEYS: ParamFieldKey[] = ['widthMm', 'lengthM', 'thicknessMicron', 'sleeveBrand', 'quantityPerBox'];
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,
widthOptionsMm: [],
lengthOptionsM: [],
thicknessOptionsMicron: [],
sleeveOptions: [],
colorOptions: [],
labelOptions: [],
};
const parameterFields: Array<{ key: ParamFieldKey; label: string }> = [
{ key: 'widthMm', label: 'Ширина' },
{ key: 'lengthM', label: 'Длина' },
{ key: 'thicknessMicron', label: 'Толщина' },
{ key: 'sleeveBrand', label: 'Втулка' },
{ key: 'quantityPerBox', label: 'Короб' },
{ key: 'colorTag', label: 'Цвет' },
{ key: 'labelTag', label: 'Надпись' },
];
const coverPresets = [
@@ -42,11 +76,21 @@ const coverPresets = [
['#e8f5ec', '#b2e0c6', '#7dd0a9'],
];
const { result, loading, error } = useQuery(ClientProductsDocument);
const search = ref('');
const productsQuery = useQuery(ClientProductsDocument);
const catalogSettingsQuery = useQuery(CatalogProductTypeSettingsDocument);
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();
}
@@ -85,10 +129,15 @@ function createProductCover(name: string, sku: string) {
}
function hydrateProduct(product: ProductNode): ParsedProduct {
const normalizedTags = product.tags.map((tag) => normalizeText(tag)).filter(Boolean).sort((a, b) => a.localeCompare(b, 'ru'));
return {
...product,
productTypeLabel: normalizeText(product.productType) || 'Без типа',
quantityPerBoxOptions: splitBoxValues(product.quantityPerBox),
normalizedTags,
colorTags: normalizedTags.filter((tag) => COLOR_TAGS.includes(tag)),
labelTags: normalizedTags.filter((tag) => LABEL_TAGS.includes(tag)),
};
}
@@ -121,27 +170,10 @@ function compareProducts(a: ParsedProduct, b: ParsedProduct) {
}
const parsedProducts = computed<ParsedProduct[]>(() => {
const list = result.value?.clientProducts ?? [];
const query = search.value.trim().toLowerCase();
const list = productsQuery.result.value?.clientProducts ?? [];
return list
.map(hydrateProduct)
.filter((product) => {
if (!query) {
return true;
}
return [
product.name,
product.sku,
product.productTypeLabel,
String(product.widthMm ?? ''),
String(product.lengthM ?? ''),
String(product.thicknessMicron ?? ''),
normalizeText(product.sleeveBrand),
normalizeText(product.quantityPerBox),
].some((part) => part.toLowerCase().includes(query));
})
.sort(compareProducts);
});
@@ -160,7 +192,11 @@ const productGroups = computed<ProductGroup[]>(() => {
return [...map.entries()]
.sort((a, b) => a[0].localeCompare(b[0], 'ru'))
.map(([typeLabel, products]) => ({
key: typeLabel.toLowerCase().replaceAll(/\s+/g, '-'),
key: typeLabel
.toLowerCase()
.replaceAll(/[^a-z0-9а-яё]+/gi, '-')
.replaceAll(/-+/g, '-')
.replaceAll(/^-|-$/g, ''),
typeLabel,
products: [...products].sort(compareProducts),
}));
@@ -186,6 +222,20 @@ function getAllFieldOptions(group: ProductGroup, field: ParamFieldKey) {
continue;
}
if (field === 'colorTag') {
for (const tag of product.colorTags) {
values.add(tag);
}
continue;
}
if (field === 'labelTag') {
for (const tag of product.labelTags) {
values.add(tag);
}
continue;
}
const value = product[field];
if (value !== null && value !== undefined) {
values.add(value);
@@ -195,25 +245,39 @@ 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);
return parameterFields.filter((field) => {
if (field.key === 'quantityPerBox') {
return false;
}
return getAllFieldOptions(group, field.key).length > 1;
});
}
function visibleFieldsByColumn(group: ProductGroup) {
const visibleKeys = new Set(visibleFields(group).map((field) => field.key));
const selectedGroup = computed(() => productGroups.value.find((group) => group.key === props.productTypeSlug) ?? null);
const currentGroupIndex = computed(() => productGroups.value.findIndex((group) => group.key === props.productTypeSlug));
const previousGroup = computed(() => {
if (currentGroupIndex.value <= 0) {
return null;
}
const leftColumn = parameterFields.filter((field) => (
visibleKeys.has(field.key)
&& ['widthMm', 'lengthM'].includes(field.key)
));
return productGroups.value[currentGroupIndex.value - 1] ?? null;
});
const nextGroup = computed(() => {
if (currentGroupIndex.value < 0 || currentGroupIndex.value >= productGroups.value.length - 1) {
return null;
}
const rightColumn = parameterFields.filter((field) => (
visibleKeys.has(field.key)
&& ['thicknessMicron', 'quantityPerBox', 'sleeveBrand'].includes(field.key)
));
return { leftColumn, rightColumn };
}
return productGroups.value[currentGroupIndex.value + 1] ?? null;
});
function requiredKeys(group: ProductGroup) {
return visibleFields(group).map((field) => field.key);
@@ -228,7 +292,8 @@ function createGroupState(group: ProductGroup): GroupState {
thicknessMicron: firstProduct?.thicknessMicron ?? null,
sleeveBrand: firstProduct?.sleeveBrand ?? null,
quantityPerBox: firstProduct?.quantityPerBoxOptions[0] ?? null,
isExpanded: false,
colorTag: firstProduct?.colorTags[0] ?? null,
labelTag: firstProduct?.labelTags[0] ?? null,
};
}
@@ -265,6 +330,14 @@ function matchesProductState(product: ParsedProduct, state: GroupState, keys: Pa
return product.quantityPerBoxOptions.includes(String(state[key]));
}
if (key === 'colorTag') {
return product.colorTags.includes(String(state[key]));
}
if (key === 'labelTag') {
return product.labelTags.includes(String(state[key]));
}
return product[key] === state[key];
});
}
@@ -274,7 +347,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)) {
@@ -282,7 +355,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) {
@@ -303,6 +376,14 @@ function productHasOption(product: ParsedProduct, field: ParamFieldKey, option:
return product.quantityPerBoxOptions.includes(String(option));
}
if (field === 'colorTag') {
return product.colorTags.includes(String(option));
}
if (field === 'labelTag') {
return product.labelTags.includes(String(option));
}
return product[field] === option;
}
@@ -359,6 +440,8 @@ function applyProductToState(state: GroupState, product: ParsedProduct, preferre
state.lengthM = product.lengthM ?? null;
state.thicknessMicron = product.thicknessMicron ?? null;
state.sleeveBrand = product.sleeveBrand ?? null;
state.colorTag = product.colorTags[0] ?? null;
state.labelTag = product.labelTags[0] ?? null;
if (preferredBoxOption !== null && product.quantityPerBoxOptions.includes(String(preferredBoxOption))) {
state.quantityPerBox = String(preferredBoxOption);
@@ -389,8 +472,112 @@ function articleLabel(group: ProductGroup) {
return selectedProduct(group)?.sku ?? '—';
}
function toggleExpanded(group: ProductGroup) {
getGroupState(group).isExpanded = !getGroupState(group).isExpanded;
function formatLengthRange(setting: CatalogProductTypeSettingNode) {
if (!setting.customLengthMinM || !setting.customLengthMaxM || !setting.customLengthStepM) {
return null;
}
return `${setting.customLengthMinM}-${setting.customLengthMaxM} м, шаг ${setting.customLengthStepM} м`;
}
function fieldHelperText(group: ProductGroup, field: ParamFieldKey) {
const setting = groupCatalogSetting(group);
if (field === 'widthMm') {
return 'Ширина определяет, насколько широкой будет полоса материала в работе и при намотке.';
}
if (field === 'lengthM') {
const customRange = formatLengthRange(setting);
if (setting.allowCustomLength && customRange) {
return `Можно выбрать стандартный метраж из наличия или заказать свой вариант. Доступный диапазон: ${customRange}.`;
}
return 'Длина показывает, сколько метров материала будет в одном рулоне.';
}
if (field === 'thicknessMicron') {
return 'Толщина влияет на плотность, прочность и общее ощущение материала в работе.';
}
if (field === 'sleeveBrand') {
if (setting.allowCustomSleeveBrand) {
return 'Можно выбрать стандартную втулку или сделать свою с логотипом под заказ.';
}
return 'Втулка находится внутри рулона и влияет на совместимость с вашим оборудованием.';
}
if (field === 'colorTag') {
return 'Цвет нужен для визуального отличия, маркировки и внешнего вида готового рулона.';
}
if (field === 'labelTag') {
if (setting.allowCustomLabel) {
return 'Можно взять стандартную маркировку из каталога или нанести свою надпись.';
}
return 'Надпись или маркировка помогает сразу выбрать нужный готовый вариант.';
}
return 'Параметр товара.';
}
function customizationDetails(group: ProductGroup) {
const setting = groupCatalogSetting(group);
const details: string[] = [];
const customRange = formatLengthRange(setting);
if (setting.allowCustomLength && customRange) {
details.push(`Своя длина: ${customRange}.`);
}
if (setting.allowCustomSleeveBrand) {
details.push('Втулка с логотипом под заказ.');
}
if (setting.allowCustomLabel) {
details.push('Можно нанести свою надпись.');
}
return details;
}
function totalAvailableQty(product: ParsedProduct) {
return product.availableInWarehouses.reduce((sum, balance) => sum + Number(balance.availableQty || 0), 0);
}
function warehouseAvailability(product: ParsedProduct) {
return product.availableInWarehouses
.filter((balance) => Number(balance.availableQty || 0) > 0)
.map((balance) => `${balance.warehouse.code}: ${balance.availableQty}`)
.join(' · ');
}
function availabilityTone(product: ParsedProduct) {
const qty = totalAvailableQty(product);
if (qty <= 0) {
return 'bg-[#d95c5c]';
}
if (qty < 20) {
return 'bg-[#e2b534]';
}
return 'bg-[#2aa36b]';
}
function availabilityLabel(product: ParsedProduct) {
const qty = totalAvailableQty(product);
if (qty <= 0) {
return 'Нет в наличии';
}
if (qty < 20) {
return 'Остаток ограничен';
}
return 'В наличии';
}
function incrementProduct(product: ProductNode) {
@@ -429,222 +616,259 @@ function decrementSelected(group: ProductGroup) {
decrementProduct(product.id);
}
}
function productDetailPath(group: ProductGroup) {
return `/products/${group.key}`;
}
</script>
<template>
<section class="space-y-5">
<UiSectionSearchHero
v-model="search"
title="Каталог"
search-placeholder="Поиск по артикулу, типу товара или параметрам"
/>
<div v-if="loading" class="alert surface-card border-0">Загрузка каталога...</div>
<div v-else-if="error" class="alert alert-error">{{ error.message }}</div>
<div v-else-if="productGroups.length" class="space-y-4">
<article
v-for="group in productGroups"
:key="group.key"
class="surface-card rounded-3xl p-4 md:p-5"
<div v-else-if="selectedGroup" class="relative pb-10">
<NuxtLink
v-if="previousGroup"
:to="productDetailPath(previousGroup)"
class="absolute left-[-212px] top-28 z-10 hidden w-44 rounded-[28px] border border-[#e6efe9] bg-white p-3 shadow-[0_20px_40px_rgba(18,56,36,0.08)] transition hover:-translate-x-2 hover:shadow-[0_28px_48px_rgba(18,56,36,0.12)] 2xl:block"
>
<div class="grid gap-4 xl:grid-cols-6 xl:items-start">
<div class="p-3 xl:col-span-1">
<img
:src="createProductCover(previousGroup.typeLabel, previousGroup.key)"
:alt="`Перейти к товару ${previousGroup.typeLabel}`"
class="aspect-square w-full rounded-[20px] object-cover"
loading="lazy"
>
<p class="mt-3 text-sm font-semibold leading-5 text-[#163624]">{{ previousGroup.typeLabel }}</p>
</NuxtLink>
<NuxtLink
v-if="nextGroup"
:to="productDetailPath(nextGroup)"
class="absolute right-[-212px] top-28 z-10 hidden w-44 rounded-[28px] border border-[#e6efe9] bg-white p-3 shadow-[0_20px_40px_rgba(18,56,36,0.08)] transition hover:translate-x-2 hover:shadow-[0_28px_48px_rgba(18,56,36,0.12)] 2xl:block"
>
<img
:src="createProductCover(nextGroup.typeLabel, nextGroup.key)"
:alt="`Перейти к товару ${nextGroup.typeLabel}`"
class="aspect-square w-full rounded-[20px] object-cover"
loading="lazy"
>
<p class="mt-3 text-sm font-semibold leading-5 text-[#163624]">{{ nextGroup.typeLabel }}</p>
</NuxtLink>
<header class="mb-5 flex items-center gap-4">
<NuxtLink
to="/products"
class="inline-flex h-11 w-11 shrink-0 items-center justify-center rounded-full border border-[#dce9e1] bg-white text-xl text-[#163624] shadow-[0_10px_24px_rgba(18,56,36,0.06)] transition hover:-translate-y-0.5"
aria-label="Назад к списку товаров"
>
</NuxtLink>
<div class="min-w-0">
<h1 class="text-3xl font-bold leading-tight text-[#163624] md:text-[2.5rem]">{{ selectedGroup.typeLabel }}</h1>
</div>
</header>
<div class="mb-5 grid gap-3 2xl:hidden">
<NuxtLink
v-if="previousGroup"
:to="productDetailPath(previousGroup)"
class="flex items-center gap-3 rounded-[24px] border border-[#e6efe9] bg-white p-3 shadow-[0_14px_30px_rgba(18,56,36,0.06)]"
>
<img
:src="createProductCover(previousGroup.typeLabel, previousGroup.key)"
:alt="`Перейти к товару ${previousGroup.typeLabel}`"
class="h-16 w-16 rounded-2xl object-cover"
loading="lazy"
>
<span class="text-sm font-semibold text-[#163624]">{{ previousGroup.typeLabel }}</span>
</NuxtLink>
<NuxtLink
v-if="nextGroup"
:to="productDetailPath(nextGroup)"
class="flex items-center gap-3 rounded-[24px] border border-[#e6efe9] bg-white p-3 shadow-[0_14px_30px_rgba(18,56,36,0.06)]"
>
<img
:src="createProductCover(nextGroup.typeLabel, nextGroup.key)"
:alt="`Перейти к товару ${nextGroup.typeLabel}`"
class="h-16 w-16 rounded-2xl object-cover"
loading="lazy"
>
<span class="text-sm font-semibold text-[#163624]">{{ nextGroup.typeLabel }}</span>
</NuxtLink>
</div>
<div class="grid gap-5 xl:grid-cols-[minmax(0,0.92fr)_minmax(0,1.15fr)_320px]">
<div class="space-y-3">
<div class="overflow-hidden rounded-[32px] border border-[#e6efe9] bg-white p-4 shadow-[0_20px_40px_rgba(18,56,36,0.06)]">
<img
:src="createProductCover(group.typeLabel, group.key)"
:alt="`Превью группы ${group.typeLabel}`"
class="aspect-square w-full rounded-[24px] object-cover"
:src="createProductCover(selectedGroup.typeLabel, articleLabel(selectedGroup))"
:alt="selectedGroup.typeLabel"
class="aspect-[5/4] w-full rounded-[26px] object-cover"
loading="lazy"
>
</div>
<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>
<div class="grid gap-8 md:grid-cols-2">
<div class="space-y-4">
<div
v-for="field in visibleFieldsByColumn(group).leftColumn"
:key="`${group.key}-${field.key}`"
class="border-b border-base-200 pb-4 last:border-b-0 last:pb-0"
>
<p class="text-sm font-semibold text-[#163624]">{{ field.label }}</p>
<div class="mt-3 flex flex-wrap gap-2">
<label
v-for="option in getAllFieldOptions(group, field.key)"
:key="`${group.key}-${field.key}-${option}`"
class="btn btn-sm rounded-full text-sm normal-case shadow-none transition"
:class="[
getGroupState(group)[field.key] === option
? 'bg-neutral text-neutral-content'
: isOptionAvailable(group, field.key, option)
? 'bg-base-100 text-base-content hover:bg-base-200'
: 'bg-[#eef1f4] text-[#7b8591] hover:bg-[#e3e7eb]',
'cursor-pointer',
]"
>
<input
type="radio"
class="sr-only"
:name="`${group.key}-${field.key}`"
:checked="getGroupState(group)[field.key] === option"
@change="updateField(group, field.key, option)"
>
<span>{{ formatOptionLabel(field.key, option) }}</span>
</label>
</div>
</div>
</div>
<div class="space-y-4">
<div
v-for="field in visibleFieldsByColumn(group).rightColumn"
:key="`${group.key}-${field.key}`"
class="border-b border-base-200 pb-4 last:border-b-0 last:pb-0"
>
<p class="text-sm font-semibold text-[#163624]">{{ field.label }}</p>
<div class="mt-3 flex flex-wrap gap-2">
<label
v-for="option in getAllFieldOptions(group, field.key)"
:key="`${group.key}-${field.key}-${option}`"
class="btn btn-sm rounded-full text-sm normal-case shadow-none transition"
:class="[
getGroupState(group)[field.key] === option
? 'bg-neutral text-neutral-content'
: isOptionAvailable(group, field.key, option)
? 'bg-base-100 text-base-content hover:bg-base-200'
: 'bg-[#eef1f4] text-[#7b8591] hover:bg-[#e3e7eb]',
'cursor-pointer',
]"
>
<input
type="radio"
class="sr-only"
:name="`${group.key}-${field.key}`"
:checked="getGroupState(group)[field.key] === option"
@change="updateField(group, field.key, option)"
>
<span>{{ formatOptionLabel(field.key, option) }}</span>
</label>
</div>
</div>
</div>
<div
v-if="customizationDetails(selectedGroup).length"
class="rounded-[28px] border border-[#dce9e1] bg-[#f7fbf8] p-4"
>
<div class="space-y-2">
<p
v-for="note in customizationDetails(selectedGroup)"
:key="`${selectedGroup.key}-${note}`"
class="text-sm leading-6 text-[#456555]"
>
{{ note }}
</p>
</div>
</div>
<aside class="p-4 md:p-5 xl:col-span-1">
<div class="flex h-full flex-col justify-between gap-4">
<div />
<div class="space-y-3">
<button
v-if="selectedQty(group) === 0"
class="btn h-11 w-full rounded-full border-0 bg-[#139957] text-sm font-semibold text-white hover:bg-[#0d854a]"
:disabled="!selectedProduct(group)"
@click="incrementSelected(group)"
>
В корзину
</button>
<div v-else class="rounded-[22px] border border-base-300 bg-base-100 px-2 py-1">
<div class="flex items-center justify-between gap-2">
<button
class="btn btn-square btn-sm"
:disabled="selectedQty(group) === 0"
@click="decrementSelected(group)"
>
-
</button>
<div class="min-w-10 text-center text-lg font-semibold text-[#163624]">{{ selectedQty(group) }}</div>
<button class="btn btn-square btn-sm" :disabled="!selectedProduct(group)" @click="incrementSelected(group)">
+
</button>
</div>
</div>
<p class="text-center text-sm font-medium text-base-content/55">{{ articleLabel(group) }}</p>
</div>
</div>
</aside>
</div>
<div
v-if="getGroupState(group).isExpanded"
class="mt-4 overflow-x-auto rounded-[28px] bg-white"
>
<table class="table border-separate border-spacing-0 bg-white [&_tbody_tr:hover]:bg-white [&_tbody_tr]:bg-white [&_td]:bg-white [&_th]:bg-white [&_thead_tr]:bg-white">
<div class="space-y-4">
<article
v-for="field in visibleFields(selectedGroup)"
:key="`${selectedGroup.key}-${field.key}`"
class="rounded-[28px] border border-[#e6efe9] bg-white p-4 shadow-[0_18px_36px_rgba(18,56,36,0.05)]"
>
<div class="flex flex-wrap gap-2">
<label
v-for="option in getAllFieldOptions(selectedGroup, field.key)"
:key="`${selectedGroup.key}-${field.key}-${option}`"
class="cursor-pointer rounded-2xl border px-4 py-2 text-sm font-medium transition"
:class="[
getGroupState(selectedGroup)[field.key] === option
? 'border-[#163624] bg-[#163624] text-white'
: isOptionAvailable(selectedGroup, field.key, option)
? 'border-[#dce9e1] bg-white text-[#163624] hover:border-[#163624]'
: 'border-[#e6eaee] bg-[#f3f5f7] text-[#8a949d]',
]"
>
<input
type="radio"
class="sr-only"
:name="`${selectedGroup.key}-${field.key}`"
:checked="getGroupState(selectedGroup)[field.key] === option"
@change="updateField(selectedGroup, field.key, option)"
>
<span>{{ formatOptionLabel(field.key, option) }}</span>
</label>
</div>
<details class="mt-3 rounded-[20px] bg-[#f7fbf8] px-4 py-3 text-sm text-[#587064]">
<summary class="cursor-pointer font-medium text-[#355947]">Подробнее</summary>
<p class="mt-2 leading-6">{{ fieldHelperText(selectedGroup, field.key) }}</p>
</details>
</article>
</div>
<aside class="self-start xl:sticky xl:top-24">
<div class="rounded-[30px] border border-[#e6efe9] bg-white p-5 shadow-[0_24px_48px_rgba(18,56,36,0.08)]">
<p class="mt-1 text-lg font-medium leading-tight text-[#163624]">{{ articleLabel(selectedGroup) }}</p>
<button
v-if="selectedQty(selectedGroup) === 0"
class="btn mt-4 h-12 w-full rounded-full border-0 bg-[#139957] px-6 text-base font-semibold text-white hover:bg-[#0d854a]"
:disabled="!selectedProduct(selectedGroup)"
@click="incrementSelected(selectedGroup)"
>
В корзину
</button>
<div
v-else
class="mt-4 flex items-center justify-between rounded-[24px] border border-[#dce9e1] bg-[#f8fbf9] px-2 py-2"
>
<button
class="btn btn-square border-0 bg-white text-[#163624] shadow-none hover:bg-white"
:disabled="selectedQty(selectedGroup) === 0"
@click="decrementSelected(selectedGroup)"
>
-
</button>
<div class="min-w-12 text-center text-lg font-semibold text-[#163624]">{{ selectedQty(selectedGroup) }}</div>
<button
class="btn btn-square border-0 bg-white text-[#163624] shadow-none hover:bg-white"
:disabled="!selectedProduct(selectedGroup)"
@click="incrementSelected(selectedGroup)"
>
+
</button>
</div>
</div>
</aside>
</div>
<div class="mt-8">
<p class="mb-4 text-base font-semibold text-[#163624]">Доступные варианты</p>
<div class="overflow-x-auto rounded-[24px] border border-[#edf4ef] bg-white">
<table class="table bg-white">
<thead>
<tr>
<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 class="border-b border-base-300">Втулка</th>
<th class="border-b border-base-300">Короб</th>
<th class="border-b border-base-300 text-right">Действие</th>
<tr class="text-[#587064]">
<th>SKU</th>
<th>Ширина</th>
<th>Длина</th>
<th>Толщина</th>
<th>Втулка</th>
<th>Цвет</th>
<th>Надпись</th>
<th>Остаток</th>
<th class="text-right">Действие</th>
</tr>
</thead>
<tbody>
<tr v-for="product in group.products" :key="`${group.key}-${product.id}`">
<td class="border-b border-base-200">{{ product.sku }}</td>
<td class="border-b border-base-200">{{ product.widthMm ?? '—' }}</td>
<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 class="border-b border-base-200 text-right">
<tr v-for="product in selectedGroup.products" :key="`${selectedGroup.key}-${product.id}`" class="align-middle">
<td class="font-semibold text-[#163624]">{{ product.sku }}</td>
<td>{{ product.widthMm ?? '—' }}</td>
<td>{{ product.lengthM ?? '—' }}</td>
<td>{{ product.thicknessMicron ?? '—' }}</td>
<td>{{ product.sleeveBrand ?? '—' }}</td>
<td>{{ product.colorTags.join(', ') || '—' }}</td>
<td>{{ product.labelTags.join(', ') || '—' }}</td>
<td>
<div class="flex min-w-[180px] items-center gap-3">
<span class="h-3 w-3 rounded-sm" :class="availabilityTone(product)" />
<div class="min-w-0">
<p class="text-sm font-medium text-[#163624]">{{ availabilityLabel(product) }}</p>
<p class="text-xs text-[#607569]">
{{ totalAvailableQty(product) }}<span v-if="warehouseAvailability(product)"> · {{ warehouseAvailability(product) }}</span>
</p>
</div>
</div>
</td>
<td class="text-right">
<button
v-if="getQuantity(product.id) === 0"
class="btn h-9 rounded-full border-0 bg-[#139957] px-4 text-xs font-semibold text-white hover:bg-[#0d854a]"
class="btn h-10 rounded-full border-0 bg-[#139957] px-5 text-sm font-semibold text-white hover:bg-[#0d854a]"
@click="incrementProduct(product)"
>
В корзину
</button>
<div v-else class="ml-auto flex w-28 items-center justify-between rounded-xl border border-base-300 px-1 py-1">
<div v-else class="ml-auto flex w-32 items-center justify-between rounded-[20px] border border-[#dce9e1] bg-white px-2 py-2">
<button
class="btn btn-xs btn-square"
class="btn btn-xs btn-square border-0 bg-[#f3f7f4] text-[#163624] shadow-none hover:bg-[#eaf2ec]"
:disabled="getQuantity(product.id) === 0"
@click="decrementProduct(product.id)"
>
-
</button>
<span class="min-w-8 text-center text-sm font-semibold">{{ getQuantity(product.id) }}</span>
<button class="btn btn-xs btn-square" @click="incrementProduct(product)">+</button>
<span class="min-w-8 text-center text-sm font-semibold text-[#163624]">{{ getQuantity(product.id) }}</span>
<button
class="btn btn-xs btn-square border-0 bg-[#f3f7f4] text-[#163624] shadow-none hover:bg-[#eaf2ec]"
@click="incrementProduct(product)"
>
+
</button>
</div>
</td>
</tr>
</tbody>
</table>
</div>
<button
class="btn btn-ghost mt-3 w-full justify-center gap-2"
@click="toggleExpanded(group)"
>
<svg
class="h-4 w-4 transition-transform"
:class="{ 'rotate-180': getGroupState(group).isExpanded }"
viewBox="0 0 20 20"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M5 7.5L10 12.5L15 7.5"
stroke="currentColor"
stroke-width="1.8"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
<span>{{ getGroupState(group).isExpanded ? 'Свернуть все варианты' : 'Развернуть все варианты' }}</span>
</button>
</article>
</div>
</div>
<div v-else class="alert surface-card border-0">По текущему запросу товары не найдены.</div>
<div v-else class="alert surface-card border-0">Такой тип товара не найден.</div>
</section>
</template>

View File

@@ -0,0 +1,127 @@
<script setup lang="ts">
import { useQuery } from '@vue/apollo-composable';
import {
ClientProductsDocument,
type ClientProductsQuery,
} from '~/composables/graphql/generated';
type ProductNode = ClientProductsQuery['clientProducts'][number];
type ProductTypeCard = {
key: string;
typeLabel: string;
};
const productsQuery = useQuery(ClientProductsDocument);
const search = ref('');
const loading = computed(() => productsQuery.loading.value);
const error = computed(() => productsQuery.error.value);
function normalizeText(value: string | null | undefined) {
return String(value ?? '').replaceAll(/\s+/g, ' ').trim();
}
function slugifyTypeLabel(value: string) {
return normalizeText(value)
.toLowerCase()
.replaceAll(/[^a-z0-9а-яё]+/gi, '-')
.replaceAll(/-+/g, '-')
.replaceAll(/^-|-$/g, '');
}
function createProductCover(name: string, sku: string) {
const coverPresets = [
['#d9f5e6', '#9ce8c1', '#6fd09d'],
['#eaf9ef', '#b3e8cb', '#76c89f'],
['#e8f5ec', '#b2e0c6', '#7dd0a9'],
];
const seed = `${name}${sku}`.split('').reduce((acc, char) => acc + char.charCodeAt(0), 0);
const [start, middle, finish] = coverPresets[seed % coverPresets.length];
const firstLetter = name.trim().charAt(0).toUpperCase() || 'P';
const svg = `
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 320 220">
<defs>
<linearGradient id="g" x1="0" y1="0" x2="1" y2="1">
<stop offset="0%" stop-color="${start}" />
<stop offset="55%" stop-color="${middle}" />
<stop offset="100%" stop-color="${finish}" />
</linearGradient>
</defs>
<rect width="320" height="220" fill="url(#g)" rx="22" />
<g opacity="0.15">
<circle cx="266" cy="45" r="55" fill="#0f7a49" />
<circle cx="42" cy="198" r="55" fill="#0f7a49" />
</g>
<text x="50%" y="56%" text-anchor="middle" fill="#11412c" font-family="Manrope, sans-serif" font-size="84" font-weight="700">${firstLetter}</text>
</svg>
`.trim();
return `data:image/svg+xml;utf8,${encodeURIComponent(svg)}`;
}
const productTypeCards = computed<ProductTypeCard[]>(() => {
const products = productsQuery.result.value?.clientProducts ?? [];
const query = search.value.trim().toLowerCase();
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 [...grouped.entries()]
.map(([typeLabel]) => ({
key: slugifyTypeLabel(typeLabel),
typeLabel,
}))
.filter((card) => {
if (!query) {
return true;
}
return card.typeLabel.toLowerCase().includes(query);
})
.sort((a, b) => a.typeLabel.localeCompare(b.typeLabel, 'ru'));
});
</script>
<template>
<section class="space-y-5">
<UiSectionSearchHero
v-model="search"
title="Каталог"
search-placeholder="Поиск по типу товара"
/>
<div v-if="loading" class="alert surface-card border-0">Загрузка каталога...</div>
<div v-else-if="error" class="alert alert-error">{{ error.message }}</div>
<div v-else-if="productTypeCards.length" class="grid grid-cols-2 gap-3 md:grid-cols-3 xl:grid-cols-5">
<NuxtLink
v-for="card in productTypeCards"
:key="card.key"
:to="`/products/${card.key}`"
class="surface-card block rounded-3xl p-3 transition hover:-translate-y-0.5 hover:shadow-[0_22px_42px_rgba(18,56,36,0.12)]"
>
<img
:src="createProductCover(card.typeLabel, card.key)"
:alt="`Превью ${card.typeLabel}`"
class="aspect-square w-full rounded-[24px] object-cover"
loading="lazy"
>
<div class="mt-3">
<h2 class="text-base font-bold leading-5 text-[#163624]">{{ card.typeLabel }}</h2>
</div>
</NuxtLink>
</div>
<div v-else class="alert surface-card border-0">По текущему запросу товары не найдены.</div>
</section>
</template>

View File

@@ -89,6 +89,24 @@ export type CartItem = {
updatedAt: Scalars['DateTime']['output'];
};
export type CatalogProductTypeSetting = {
__typename?: '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 = {
__typename?: 'Company';
id: Scalars['ID']['output'];
@@ -328,6 +346,7 @@ export type Mutation = {
submitCalculationOrder: Order;
submitReadyOrder: Order;
updateCartItemQuantity: Cart;
upsertCatalogProductTypeSetting: CatalogProductTypeSetting;
upsertMyCounterpartyProfile: CounterpartyProfile;
verifyLoginCode: AuthSession;
};
@@ -467,6 +486,11 @@ export type MutationUpdateCartItemQuantityArgs = {
};
export type MutationUpsertCatalogProductTypeSettingArgs = {
input: UpsertCatalogProductTypeSettingInput;
};
export type MutationUpsertMyCounterpartyProfileArgs = {
input: UpsertMyCounterpartyProfileInput;
};
@@ -579,6 +603,7 @@ export type Product = {
quantityPerBox?: Maybe<Scalars['String']['output']>;
sku: Scalars['String']['output'];
sleeveBrand?: Maybe<Scalars['String']['output']>;
tags: Array<Scalars['String']['output']>;
thicknessMicron?: Maybe<Scalars['Int']['output']>;
widthMm?: Maybe<Scalars['Int']['output']>;
};
@@ -591,6 +616,7 @@ export type ProductWarehouseBalance = {
export type Query = {
__typename?: 'Query';
catalogProductTypeSettings: Array<CatalogProductTypeSetting>;
clientProducts: Array<Product>;
healthcheck: Scalars['String']['output'];
integrationSyncDashboard: IntegrationSyncDashboard;
@@ -762,6 +788,23 @@ export type UpdateCartItemQuantityInput = {
quantity: Scalars['Float']['input'];
};
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 = {
bankName: Scalars['String']['input'];
bik: Scalars['String']['input'];
@@ -899,7 +942,7 @@ export type UpdateCartItemQuantityMutation = { __typename?: 'Mutation', updateCa
export type ClientProductsQueryVariables = Exact<{ [key: string]: never; }>;
export type ClientProductsQuery = { __typename?: 'Query', clientProducts: Array<{ __typename?: 'Product', id: string, sku: string, name: string, description?: string | null, productType?: string | null, widthMm?: number | null, lengthM?: number | null, thicknessMicron?: number | null, sleeveBrand?: string | null, quantityPerBox?: string | null, isCustomizable: boolean, availableInWarehouses: Array<{ __typename?: 'ProductWarehouseBalance', availableQty: number, warehouse: { __typename?: 'Warehouse', id: string, code: string, name: string } }> }> };
export type ClientProductsQuery = { __typename?: 'Query', clientProducts: Array<{ __typename?: 'Product', id: string, sku: string, name: string, description?: string | null, productType?: string | null, widthMm?: number | null, lengthM?: number | null, thicknessMicron?: number | null, sleeveBrand?: string | null, quantityPerBox?: string | null, tags: Array<string>, isCustomizable: boolean, availableInWarehouses: Array<{ __typename?: 'ProductWarehouseBalance', availableQty: number, warehouse: { __typename?: 'Warehouse', id: string, code: string, name: string } }> }> };
export type AddBonusTransactionMutationVariables = Exact<{
input: AddBonusTransactionInput;
@@ -1124,11 +1167,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, 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; }>;
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, widthOptionsMm: Array<number>, lengthOptionsM: Array<number>, thicknessOptionsMicron: Array<number>, sleeveOptions: Array<string>, colorOptions: Array<string>, labelOptions: Array<string> } };
export const ConsumeLoginTokenDocument = gql`
mutation ConsumeLoginToken($token: String!) {
@@ -1629,6 +1684,7 @@ export const ClientProductsDocument = gql`
thicknessMicron
sleeveBrand
quantityPerBox
tags
isCustomizable
availableInWarehouses {
availableQty
@@ -2925,6 +2981,46 @@ 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
widthOptionsMm
lengthOptionsM
thicknessOptionsMicron
sleeveOptions
colorOptions
labelOptions
}
}
`;
/**
* __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 {
@@ -2965,4 +3061,46 @@ 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
widthOptionsMm
lengthOptionsM
thicknessOptionsMicron
sleeveOptions
colorOptions
labelOptions
}
}
`;
/**
* __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

@@ -103,7 +103,7 @@ async function copyBonusProgramLink() {
<div class="space-y-4">
<UiBackHeader
to="/admin/bonuses/balances"
back-label="Назад к бонусам"
back-label="Назад к бонусным счетам"
:title="`Бонусный счёт ${bonusAccount.fullName}`"
:subtitle="bonusAccount.companyName || bonusAccount.email || undefined"
>

View File

@@ -2,11 +2,9 @@
import { useQuery } from '@vue/apollo-composable';
import {
ManagerBonusBalancesDocument,
ManagerReferralLinksDocument,
ManagerUsersDocument,
ManagerWithdrawalRequestsDocument,
type ManagerBonusBalancesQuery,
type ManagerReferralLinksQuery,
type ManagerUsersQuery,
type ManagerWithdrawalRequestsQuery,
} from '~/composables/graphql/generated';
@@ -19,7 +17,6 @@ definePageMeta({
});
type BalanceItem = ManagerBonusBalancesQuery['managerBonusBalances'][number];
type ReferralLinkItem = ManagerReferralLinksQuery['managerReferralLinks'][number];
type ManagerUserItem = ManagerUsersQuery['managerUsers'][number];
type WithdrawalItem = ManagerWithdrawalRequestsQuery['managerWithdrawalRequests'][number];
type ProductCard = {
@@ -33,7 +30,6 @@ type ProductCard = {
const route = useRoute();
const search = ref('');
const balancesQuery = useQuery(ManagerBonusBalancesDocument);
const referralLinksQuery = useQuery(ManagerReferralLinksDocument);
const usersQuery = useQuery(ManagerUsersDocument);
const withdrawalsQuery = useQuery(ManagerWithdrawalRequestsDocument, {
status: 'PENDING',
@@ -95,34 +91,15 @@ const productCards: ProductCard[] = [
];
const balances = computed<BalanceItem[]>(() => balancesQuery.result.value?.managerBonusBalances ?? []);
const referralLinks = computed<ReferralLinkItem[]>(() => referralLinksQuery.result.value?.managerReferralLinks ?? []);
const users = computed<ManagerUserItem[]>(() => usersQuery.result.value?.managerUsers ?? []);
const withdrawals = computed<WithdrawalItem[]>(() => withdrawalsQuery.result.value?.managerWithdrawalRequests ?? []);
const usersById = computed(() => new Map(users.value.map((user) => [user.id, user])));
const referralLinksByReferrer = computed(() => {
const grouped = new Map<string, ReferralLinkItem[]>();
for (const link of referralLinks.value) {
const existing = grouped.get(link.referrerId) ?? [];
existing.push(link);
grouped.set(link.referrerId, existing);
}
return grouped;
});
const filteredBalances = computed(() => {
const query = search.value.trim().toLowerCase();
return balances.value
.filter((item) => {
const links = referralLinksByReferrer.value.get(item.userId);
if (!links?.length) {
return false;
}
if (!query) {
return true;
}
@@ -132,22 +109,11 @@ const filteredBalances = computed(() => {
item.email,
item.companyName || '',
String(item.balance),
...links.flatMap((link) => [
link.refereeName,
link.refereeEmail,
link.refereeCompanyName || '',
String(link.bonusPercent),
]),
String(item.transactionsCount),
]
.join(' ')
.toLowerCase()
.includes(query);
})
.slice()
.sort((left, right) => {
const leftLatest = referralLinksByReferrer.value.get(left.userId)?.[0]?.createdAt ?? '';
const rightLatest = referralLinksByReferrer.value.get(right.userId)?.[0]?.createdAt ?? '';
return rightLatest.localeCompare(leftLatest);
});
});
@@ -308,14 +274,24 @@ function compactProductTitle(product: ProductCard) {
: activeTab === 'withdrawals'
? 'Номер выплаты, клиент или сумма'
: 'Название или номинал'"
/>
>
<template #controls>
<NuxtLink
v-if="activeTab === 'balances'"
to="/admin/bonuses/links/new"
class="btn btn-primary border-0"
>
Добавить
</NuxtLink>
</template>
</UiSectionSearchHero>
<template v-if="activeTab === 'balances'">
<div v-if="balancesQuery.loading.value || referralLinksQuery.loading.value || usersQuery.loading.value" class="manager-empty-state">
Загружаем балансы...
<div v-if="balancesQuery.loading.value || usersQuery.loading.value" class="manager-empty-state">
Загружаем бонусные счета...
</div>
<div v-else-if="filteredBalances.length === 0" class="manager-empty-state">
Бонусных связок пока нет.
Бонусных счетов пока нет.
</div>
<div v-else class="space-y-4">
<div class="grid gap-4 sm:grid-cols-2 xl:grid-cols-4 2xl:grid-cols-6">

View File

@@ -1,103 +1,71 @@
<script setup lang="ts">
import { useMutation, useQuery } from '@vue/apollo-composable';
import {
CreateReferralDocument,
ManagerReferralLinksDocument,
CreateBonusProgramLinkDocument,
ManagerUsersDocument,
type ManagerReferralLinksQuery,
type CreateBonusProgramLinkMutation,
type ManagerUsersQuery,
} from '~/composables/graphql/generated';
definePageMeta({
middleware: ['manager-only'],
path: '/admin/bonuses/referrals/new',
alias: ['/bonus-system/referrals/new'],
path: '/admin/bonuses/links/new',
alias: ['/bonus-system/links/new', '/bonus-system/referrals/new', '/admin/bonuses/referrals/new'],
});
type ManagerUserItem = ManagerUsersQuery['managerUsers'][number];
type ManagerReferralLinkItem = ManagerReferralLinksQuery['managerReferralLinks'][number];
const referrerUserId = ref('');
const refereeUserId = ref('');
const bonusPercent = ref(5);
const createdReferralId = ref('');
const userId = ref('');
const errorMessage = ref('');
const bonusProgramLink = ref('');
const bonusProgramLinkExpiresAt = ref('');
const usersQuery = useQuery(ManagerUsersDocument);
const linksQuery = useQuery(ManagerReferralLinksDocument);
const createReferralMutation = useMutation(CreateReferralDocument);
const createBonusProgramLinkMutation = useMutation(CreateBonusProgramLinkDocument, { throws: 'never' });
const clientOptions = computed<ManagerUserItem[]>(() => (
(usersQuery.result.value?.managerUsers ?? [])
.filter((user) => user.role === 'CLIENT')
));
const referrerOptions = computed<ManagerUserItem[]>(() => (
clientOptions.value.filter((user) => user.id !== refereeUserId.value)
));
const refereeOptions = computed<ManagerUserItem[]>(() => (
clientOptions.value.filter((user) => user.id !== referrerUserId.value)
));
const referralLinks = computed<ManagerReferralLinkItem[]>(() => (
linksQuery.result.value?.managerReferralLinks ?? []
));
watch(referrerUserId, (value) => {
if (value && value === refereeUserId.value) {
refereeUserId.value = '';
}
});
watch(refereeUserId, (value) => {
if (value && value === referrerUserId.value) {
referrerUserId.value = '';
}
});
function userOptionLabel(user: ManagerUserItem) {
return [user.fullName, user.companyName || user.email]
.filter(Boolean)
.join(' • ');
}
async function createReferral() {
createdReferralId.value = '';
async function createBonusAccountLink() {
errorMessage.value = '';
bonusProgramLink.value = '';
bonusProgramLinkExpiresAt.value = '';
if (!referrerUserId.value || !refereeUserId.value) {
errorMessage.value = 'Выберите обоих клиентов для связки.';
if (!userId.value) {
errorMessage.value = 'Выберите клиента.';
return;
}
if (referrerUserId.value === refereeUserId.value) {
errorMessage.value = 'Нельзя связать клиента с самим собой.';
const bonusLinkResponse = await createBonusProgramLinkMutation.mutate({
userId: userId.value,
});
const bonusLinkPayload: CreateBonusProgramLinkMutation['createBonusProgramLink'] | undefined = bonusLinkResponse?.data?.createBonusProgramLink;
if (!bonusLinkPayload?.url) {
errorMessage.value = createBonusProgramLinkMutation.error.value?.message || 'Не удалось сгенерировать ссылку.';
return;
}
const normalizedBonusPercent = Number(bonusPercent.value);
if (!Number.isFinite(normalizedBonusPercent) || normalizedBonusPercent <= 0 || normalizedBonusPercent > 100) {
errorMessage.value = 'Укажите процент бонуса от 0.01 до 100.';
bonusProgramLink.value = bonusLinkPayload.url;
bonusProgramLinkExpiresAt.value = bonusLinkPayload.expiresAt;
}
async function copyBonusProgramLink() {
if (!bonusProgramLink.value) {
return;
}
try {
const response = await createReferralMutation.mutate({
input: {
referrerUserId: referrerUserId.value,
refereeUserId: refereeUserId.value,
bonusPercent: normalizedBonusPercent,
},
});
await navigator.clipboard.writeText(bonusProgramLink.value);
}
createdReferralId.value = response?.data?.createReferral.id ?? '';
refereeUserId.value = '';
await linksQuery.refetch();
} catch (error: unknown) {
errorMessage.value = error instanceof Error
? error.message
: 'Не удалось создать бонусную связку.';
}
function formatDateTime(value: string) {
return new Date(value).toLocaleString('ru-RU');
}
</script>
@@ -105,52 +73,29 @@ async function createReferral() {
<section class="space-y-6 max-w-3xl">
<UiBackHeader
to="/admin/bonuses/balances"
back-label="Назад к бонусам"
title="Создать бонусную связку клиентов"
subtitle="Первый клиент получает процент бонуса, когда заказ второго клиента переходит в статус доставленного."
back-label="Назад к бонусным счетам"
title="Создать бонусный счет"
subtitle="Менеджер выбирает клиента и сразу получает ссылку, которую можно переслать ему."
/>
<div class="surface-card rounded-3xl p-5 space-y-4">
<label class="form-control">
<span class="label-text">Клиент, который получает бонус</span>
<select v-model="referrerUserId" class="select manager-field w-full">
<span class="label-text">Клиент</span>
<select v-model="userId" class="select manager-field w-full">
<option value="">Выберите клиента</option>
<option v-for="user in referrerOptions" :key="user.id" :value="user.id">
<option v-for="user in clientOptions" :key="user.id" :value="user.id">
{{ userOptionLabel(user) }}
</option>
</select>
</label>
<label class="form-control">
<span class="label-text">Клиент, с чьих заказов начисляется бонус</span>
<select v-model="refereeUserId" class="select manager-field w-full">
<option value="">Выберите клиента</option>
<option v-for="user in refereeOptions" :key="user.id" :value="user.id">
{{ userOptionLabel(user) }}
</option>
</select>
</label>
<label class="form-control">
<span class="label-text">Процент бонусной программы</span>
<input
v-model="bonusPercent"
type="number"
min="0.01"
max="100"
step="0.01"
class="input manager-field w-full"
placeholder="5"
>
</label>
<div class="mt-4">
<button
class="btn btn-primary border-0"
:disabled="createReferralMutation.loading.value || usersQuery.loading.value"
@click="createReferral"
:disabled="createBonusProgramLinkMutation.loading.value || usersQuery.loading.value"
@click="createBonusAccountLink"
>
{{ createReferralMutation.loading.value ? 'Сохраняем...' : 'Создать связь' }}
{{ createBonusProgramLinkMutation.loading.value ? 'Генерируем...' : 'Создать ссылку' }}
</button>
</div>
</div>
@@ -159,41 +104,36 @@ async function createReferral() {
{{ errorMessage }}
</div>
<div v-if="createdReferralId" class="surface-card rounded-3xl p-5 text-sm text-[#123824]">
Создана связь: <span class="font-semibold">{{ createdReferralId }}</span>
</div>
<div class="space-y-4">
<div class="flex items-center justify-between gap-3">
<h2 class="text-lg font-bold text-[#123824]">Текущие бонусные связки</h2>
<span class="text-sm text-[#466653]">{{ referralLinks.length }}</span>
<article v-if="bonusProgramLink" class="surface-card rounded-3xl p-5 space-y-4">
<div class="space-y-2">
<p class="text-sm font-semibold text-[#123824]">Ссылка в бонусный кабинет</p>
<p class="text-sm text-[#466653]">
Эту ссылку менеджер может сразу отправить клиенту.
</p>
<div class="rounded-[20px] bg-[#f8fbf9] px-4 py-3 text-sm font-semibold text-[#123824] break-all">
{{ bonusProgramLink }}
</div>
<p v-if="bonusProgramLinkExpiresAt" class="text-xs text-[#5c7b69]">
Действует до {{ formatDateTime(bonusProgramLinkExpiresAt) }}
</p>
</div>
<div v-if="linksQuery.loading.value" class="manager-empty-state">
Загружаем связки...
</div>
<div v-else-if="referralLinks.length === 0" class="manager-empty-state">
Бонусных связок пока нет.
</div>
<div v-else class="space-y-3">
<article
v-for="link in referralLinks"
:key="link.id"
class="surface-card rounded-3xl p-5"
<div class="flex flex-wrap gap-3">
<a
:href="bonusProgramLink"
target="_blank"
rel="noreferrer"
class="btn rounded-full border border-[#d7e9de] bg-white px-5 text-[#123824] hover:bg-[#f3f8f5]"
>
<div class="space-y-2">
<p class="text-sm font-semibold text-[#123824]">
{{ link.referrerName }} получает {{ link.bonusPercent }}% с заказов {{ link.refereeName }}
</p>
<p class="text-sm text-[#466653]">
{{ link.referrerCompanyName || link.referrerEmail }} {{ link.refereeCompanyName || link.refereeEmail }}
</p>
<p class="text-xs text-[#5c7b69]">
Создано {{ new Date(link.createdAt).toLocaleString() }}
</p>
</div>
</article>
Открыть
</a>
<button
class="btn rounded-full border-0 bg-[#139957] px-5 text-white hover:bg-[#0d854a]"
@click="copyBonusProgramLink"
>
Скопировать
</button>
</div>
</div>
</article>
</section>
</template>

View File

@@ -33,7 +33,7 @@ async function addBonus() {
<section class="space-y-6 max-w-3xl">
<UiBackHeader
to="/admin/bonuses/balances"
back-label="Назад к бонусам"
back-label="Назад к бонусным счетам"
title="Добавить бонусную транзакцию"
/>

View File

@@ -0,0 +1,394 @@
<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[];
};
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 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'),
};
}
function formFor(item: CatalogSettingItem) {
forms[item.productType] ??= createForm(item);
return forms[item.productType];
}
function addOption(form: CatalogSettingForm, group: OptionGroupDefinition, rawValue: string) {
const value = normalizeOptionEntry(rawValue, group.kind);
if (!value) {
return;
}
form[group.key] = normalizeOptionList([...form[group.key], value], group.kind);
}
function removeOption(form: CatalogSettingForm, groupKey: OptionKey, value: string) {
form[groupKey] = form[groupKey].filter((item) => item !== value);
}
function openAddOptionPrompt(form: CatalogSettingForm, group: OptionGroupDefinition) {
const value = window.prompt(group.placeholder, '');
if (value == null) {
return;
}
addOption(form, group, 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');
}
function enabledCustomizationCount(form: CatalogSettingForm) {
return [
form.allowCustomLength,
form.allowCustomSleeveBrand,
form.allowCustomLabel,
].filter(Boolean).length;
}
function filledParameterGroupCount(form: CatalogSettingForm) {
return OPTION_GROUPS.filter((group) => form[group.key].length > 0).length;
}
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-3">
<details
v-for="item in settings"
:key="item.productType"
class="group rounded-[28px] bg-white shadow-[0_18px_38px_rgba(18,56,36,0.08)]"
>
<summary class="flex cursor-pointer list-none items-center justify-between gap-4 p-5">
<div class="min-w-0 space-y-1">
<h2 class="text-xl font-bold text-[#123824]">{{ item.productType }}</h2>
<p class="text-sm text-[#5a7667]">
{{ filledParameterGroupCount(formFor(item)) }} параметров, {{ enabledCustomizationCount(formFor(item)) }} кастомные возможности
</p>
</div>
<div class="flex items-center gap-3">
<span class="hidden text-sm font-semibold text-[#6a8a78] md:inline">Открыть</span>
<span class="inline-flex h-10 w-10 items-center justify-center rounded-full bg-[#eef7f1] text-[#1d5a3c] transition group-open:rotate-180">
<svg viewBox="0 0 20 20" fill="currentColor" class="h-5 w-5">
<path fill-rule="evenodd" d="M5.23 7.21a.75.75 0 0 1 1.06.02L10 11.168l3.71-3.938a.75.75 0 1 1 1.08 1.04l-4.25 4.512a.75.75 0 0 1-1.08 0L5.21 8.27a.75.75 0 0 1 .02-1.06Z" clip-rule="evenodd" />
</svg>
</span>
</div>
</summary>
<div class="space-y-5 border-t border-[#edf4ef] p-5">
<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="group rounded-[20px] bg-white p-4"
>
<div class="space-y-3">
<div class="flex items-center justify-between gap-3">
<div class="text-sm font-semibold text-[#123824]">{{ group.label }}</div>
<button
type="button"
class="inline-flex h-7 w-7 items-center justify-center rounded-full border border-[#d6e7dc] text-sm font-semibold text-[#6a8a78] opacity-0 transition group-hover:opacity-100 hover:border-[#9ccbb0] hover:text-[#155c3a]"
@click="openAddOptionPrompt(formFor(item), group)"
>
+
</button>
</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="group/chip 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] opacity-0 transition group-hover/chip:opacity-100">×</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>
</div>
</div>
</div>
</div>
</details>
</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>

View File

@@ -1,7 +1,7 @@
<script setup lang="ts">
import CatalogConfigurator from '~/components/catalog/CatalogConfigurator.vue';
import CatalogProductTypeList from '~/components/catalog/CatalogProductTypeList.vue';
</script>
<template>
<CatalogConfigurator />
<CatalogProductTypeList />
</template>

View File

@@ -1,7 +0,0 @@
<script setup lang="ts">
import CatalogConfigurator from '~/components/catalog/CatalogConfigurator.vue';
</script>
<template>
<CatalogConfigurator />
</template>

View File

@@ -0,0 +1,11 @@
<script setup lang="ts">
import CatalogConfigurator from '~/components/catalog/CatalogConfigurator.vue';
const route = useRoute();
const productTypeSlug = computed(() => String(route.params.slug ?? ''));
</script>
<template>
<CatalogConfigurator :product-type-slug="productTypeSlug" />
</template>

View File

@@ -0,0 +1,7 @@
<script setup lang="ts">
import CatalogProductTypeList from '~/components/catalog/CatalogProductTypeList.vue';
</script>
<template>
<CatalogProductTypeList />
</template>

View File

@@ -17,6 +17,22 @@ const syncDashboardQuery = useQuery(IntegrationSyncDashboardDocument);
const dashboard = computed(() => syncDashboardQuery.result.value?.integrationSyncDashboard ?? null);
const syncItems = computed<SyncItem[]>(() => dashboard.value?.items ?? []);
function itemIsHealthy(item: SyncItem) {
return item.syncedCount > 0 && Boolean(item.lastSyncedAt);
}
function statusLabel(item: SyncItem) {
return itemIsHealthy(item) ? 'Работает' : 'Нет данных';
}
function syncSummary(item: SyncItem) {
if (!item.syncedCount) {
return 'Данных пока нет.';
}
return `Загружено ${item.syncedCount} записей.`;
}
function formatDateTime(value?: string | null) {
if (!value) {
return 'Пока нет';
@@ -33,124 +49,69 @@ function formatDateTime(value?: string | null) {
<template>
<section class="space-y-6">
<div class="space-y-3">
<div class="space-y-2">
<h1 class="text-3xl font-extrabold text-[#0f2f20]">1С</h1>
<p class="max-w-3xl text-sm leading-6 text-[#557562]">
Витрина контроля будущей интеграции с 1С. Здесь видно, какие контуры уже есть в данных,
когда в них была последняя активность и под какие workers мы потом соберём реальную синхронизацию.
<p class="text-sm leading-6 text-[#557562]">
Статус синхронизации по ключевым событиям.
</p>
</div>
<div v-if="syncDashboardQuery.loading.value" class="manager-empty-state">
Загружаем контур синхронизации...
Загружаем статусы...
</div>
<template v-else-if="dashboard">
<section class="grid gap-4 md:grid-cols-3">
<article class="surface-card rounded-[28px] bg-white p-5">
<p class="text-xs font-semibold uppercase tracking-[0.12em] text-[#688676]">Заказы</p>
<p class="mt-3 text-3xl font-black tracking-[-0.05em] text-[#123824]">
{{ dashboard.totalOrders }}
</p>
<p class="mt-2 text-sm text-[#557562]">
Последняя активность: {{ formatDateTime(dashboard.lastActivityAt) }}
</p>
</article>
<article class="surface-card rounded-[28px] bg-white p-5">
<p class="text-xs font-semibold uppercase tracking-[0.12em] text-[#688676]">Каталог</p>
<p class="mt-3 text-3xl font-black tracking-[-0.05em] text-[#123824]">
{{ dashboard.totalProducts }}
</p>
<p class="mt-2 text-sm text-[#557562]">
Активных позиций сейчас в базе
</p>
</article>
<article class="surface-card rounded-[28px] bg-white p-5">
<p class="text-xs font-semibold uppercase tracking-[0.12em] text-[#688676]">Клиенты</p>
<p class="mt-3 text-3xl font-black tracking-[-0.05em] text-[#123824]">
{{ dashboard.totalClients }}
</p>
<p class="mt-2 text-sm text-[#557562]">
Клиентских аккаунтов готовы к будущей связке с 1С
</p>
</article>
</section>
<section class="grid gap-4 xl:grid-cols-2">
<section class="space-y-4">
<article
v-for="item in syncItems"
:key="item.id"
class="surface-card rounded-[28px] bg-white p-5"
class="rounded-[28px] bg-white p-5 shadow-[0_18px_38px_rgba(18,56,36,0.08)]"
>
<div class="flex flex-wrap items-start justify-between gap-3">
<div class="min-w-0">
<div class="flex flex-wrap items-center gap-2">
<span class="rounded-full bg-[#eef5f0] px-3 py-1 text-xs font-bold uppercase tracking-[0.12em] text-[#4e7060]">
<div class="flex flex-col gap-4 md:flex-row md:items-start md:justify-between">
<div class="min-w-0 space-y-3">
<div class="flex flex-wrap items-center gap-3">
<span
class="inline-flex items-center gap-2 rounded-full px-3 py-1 text-xs font-bold uppercase tracking-[0.12em]"
:class="itemIsHealthy(item) ? 'bg-[#e8f5ec] text-[#1c6b45]' : 'bg-[#fff1ec] text-[#b54b2f]'"
>
<span
class="h-2.5 w-2.5 rounded-full"
:class="itemIsHealthy(item) ? 'bg-[#1c6b45]' : 'bg-[#d76745]'"
/>
{{ statusLabel(item) }}
</span>
<span class="text-xs font-semibold uppercase tracking-[0.12em] text-[#6a8a76]">
{{ item.source }}
</span>
<span class="rounded-full bg-[#fff8dc] px-3 py-1 text-xs font-bold text-[#7a5b00]">
{{ item.status }}
</span>
</div>
<h2 class="mt-3 text-xl font-bold text-[#123824]">{{ item.title }}</h2>
<p class="mt-2 text-sm leading-6 text-[#557562]">{{ item.description }}</p>
<div class="space-y-1">
<h2 class="text-xl font-bold text-[#123824]">{{ item.title }}</h2>
<p class="text-sm text-[#557562]">
Последний run: {{ formatDateTime(item.lastSyncedAt) }}
</p>
</div>
<p class="text-sm leading-6 text-[#123824]">
{{ syncSummary(item) }}
</p>
<p class="text-sm leading-6 text-[#557562]">
{{ item.note }}
</p>
</div>
<div class="rounded-[20px] bg-[#f7fbf8] px-4 py-3 text-right">
<div class="rounded-[20px] bg-[#f7fbf8] px-4 py-3 text-left md:min-w-[180px] md:text-right">
<p class="text-xs font-semibold uppercase tracking-[0.12em] text-[#688676]">
Синхронизировано
Обновлений
</p>
<p class="mt-2 text-2xl font-black tracking-[-0.04em] text-[#123824]">
{{ item.syncedCount }}
</p>
</div>
</div>
<div class="mt-5 grid gap-3 md:grid-cols-2">
<div class="rounded-[20px] bg-[#f8fbf9] px-4 py-3">
<p class="text-xs font-semibold uppercase tracking-[0.12em] text-[#688676]">
Последняя активность
</p>
<p class="mt-2 text-sm font-semibold text-[#123824]">
{{ formatDateTime(item.lastSyncedAt) }}
</p>
</div>
<div class="rounded-[20px] bg-[#f8fbf9] px-4 py-3">
<p class="text-xs font-semibold uppercase tracking-[0.12em] text-[#688676]">
Следующий шаг
</p>
<p class="mt-2 text-sm font-semibold text-[#123824]">
{{ item.note }}
</p>
</div>
</div>
</article>
</section>
<article class="surface-card rounded-[28px] bg-white p-5">
<h2 class="text-xl font-bold text-[#123824]">Контур синхронизации</h2>
<div class="mt-4 grid gap-3 md:grid-cols-4">
<div class="rounded-[22px] bg-[#f8fbf9] px-4 py-4">
<p class="text-xs font-semibold uppercase tracking-[0.12em] text-[#688676]">Webhooks</p>
<p class="mt-2 text-sm leading-6 text-[#123824]">События создания и обновления заказов из 1С.</p>
</div>
<div class="rounded-[22px] bg-[#f8fbf9] px-4 py-4">
<p class="text-xs font-semibold uppercase tracking-[0.12em] text-[#688676]">Pull jobs</p>
<p class="mt-2 text-sm leading-6 text-[#123824]">Регулярная загрузка каталога, остатков и справочников.</p>
</div>
<div class="rounded-[22px] bg-[#f8fbf9] px-4 py-4">
<p class="text-xs font-semibold uppercase tracking-[0.12em] text-[#688676]">Workers</p>
<p class="mt-2 text-sm leading-6 text-[#123824]">Отдельные воркеры для заказов, статусов, каталога и складов.</p>
</div>
<div class="rounded-[22px] bg-[#f8fbf9] px-4 py-4">
<p class="text-xs font-semibold uppercase tracking-[0.12em] text-[#688676]">Журнал</p>
<p class="mt-2 text-sm leading-6 text-[#123824]">Контроль последнего sync, объёма данных и состояния канала.</p>
</div>
</div>
</article>
</template>
</section>
</template>

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 25 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 36 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 44 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 6.0 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 6.1 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 6.2 KiB

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1440" height="900" viewBox="0 0 1440 900" fill="none"><rect width="1440" height="900" fill="#f4f4f4" /><rect x="24" y="24" width="1392" height="852" rx="28" fill="#ffffff" stroke="#d5d5d5" stroke-width="1.5" /><rect x="24" y="24" width="1392" height="56" rx="28" fill="#fafafa" stroke="#d5d5d5" stroke-width="1.5" /><rect x="24" y="52" width="1392" height="28" rx="0" fill="#fafafa" stroke="#fafafa" stroke-width="1.5" /><circle cx="58" cy="52" r="7" fill="#b7b7b7" /><circle cx="82" cy="52" r="7" fill="#d2d2d2" /><circle cx="106" cy="52" r="7" fill="#d4d4d4" /><text x="136" y="58" font-family="&quot;Times New Roman&quot;, Times, serif" font-size="17" font-weight="700" fill="#181818" text-anchor="start">Каталог продукции</text><rect x="820" y="36" width="104" height="34" rx="17" fill="#181818" stroke="#181818" stroke-width="1.5" /><text x="872" y="58" font-family="&quot;Times New Roman&quot;, Times, serif" font-size="13" font-weight="700" fill="#ffffff" text-anchor="middle">Каталог</text><rect x="936" y="36" width="134" height="34" rx="17" fill="#f7f7f7" stroke="#d5d5d5" stroke-width="1.5" /><text x="1003" y="58" font-family="&quot;Times New Roman&quot;, Times, serif" font-size="13" font-weight="700" fill="#545454" text-anchor="middle">Мои заказы</text><rect x="1082" y="36" width="104" height="34" rx="17" fill="#f7f7f7" stroke="#d5d5d5" stroke-width="1.5" /><text x="1134" y="58" font-family="&quot;Times New Roman&quot;, Times, serif" font-size="13" font-weight="700" fill="#545454" text-anchor="middle">Корзина</text><rect x="1198" y="36" width="104" height="34" rx="17" fill="#f7f7f7" stroke="#d5d5d5" stroke-width="1.5" /><text x="1250" y="58" font-family="&quot;Times New Roman&quot;, Times, serif" font-size="13" font-weight="700" fill="#545454" text-anchor="middle">Профиль</text><text x="72" y="132" font-family="&quot;Times New Roman&quot;, Times, serif" font-size="32" font-weight="800" fill="#181818" text-anchor="start">Каталог</text><rect x="72" y="168" width="600" height="54" rx="27" fill="#ffffff" stroke="#d5d5d5" stroke-width="1.5" /><text x="98" y="201" font-family="&quot;Times New Roman&quot;, Times, serif" font-size="15" font-weight="500" fill="#777777" text-anchor="start">Поиск по типу товара</text><rect x="72" y="260" width="396" height="202" rx="26" fill="#ffffff" stroke="#d5d5d5" stroke-width="1.5" /><rect x="92" y="280" width="356" height="118" rx="22" fill="#f0f0f0" stroke="#d5d5d5" stroke-width="1.5" /><text x="100" y="430" font-family="&quot;Times New Roman&quot;, Times, serif" font-size="22" font-weight="800" fill="#181818" text-anchor="start">Стретч-пленка</text><rect x="504" y="260" width="396" height="202" rx="26" fill="#ffffff" stroke="#d5d5d5" stroke-width="1.5" /><rect x="524" y="280" width="356" height="118" rx="22" fill="#f0f0f0" stroke="#d5d5d5" stroke-width="1.5" /><text x="532" y="430" font-family="&quot;Times New Roman&quot;, Times, serif" font-size="22" font-weight="800" fill="#181818" text-anchor="start">Скотч</text><rect x="936" y="260" width="396" height="202" rx="26" fill="#ffffff" stroke="#d5d5d5" stroke-width="1.5" /><rect x="956" y="280" width="356" height="118" rx="22" fill="#f0f0f0" stroke="#d5d5d5" stroke-width="1.5" /><text x="964" y="430" font-family="&quot;Times New Roman&quot;, Times, serif" font-size="22" font-weight="800" fill="#181818" text-anchor="start">Пакеты</text><rect x="72" y="498" width="396" height="202" rx="26" fill="#ffffff" stroke="#d5d5d5" stroke-width="1.5" /><rect x="92" y="518" width="356" height="118" rx="22" fill="#f0f0f0" stroke="#d5d5d5" stroke-width="1.5" /><text x="100" y="668" font-family="&quot;Times New Roman&quot;, Times, serif" font-size="22" font-weight="800" fill="#181818" text-anchor="start">Пленка ПВД</text><rect x="504" y="498" width="396" height="202" rx="26" fill="#ffffff" stroke="#d5d5d5" stroke-width="1.5" /><rect x="524" y="518" width="356" height="118" rx="22" fill="#f0f0f0" stroke="#d5d5d5" stroke-width="1.5" /><text x="532" y="668" font-family="&quot;Times New Roman&quot;, Times, serif" font-size="22" font-weight="800" fill="#181818" text-anchor="start">Воздушно-пузырьковая</text><rect x="936" y="498" width="396" height="202" rx="26" fill="#ffffff" stroke="#d5d5d5" stroke-width="1.5" /><rect x="956" y="518" width="356" height="118" rx="22" fill="#f0f0f0" stroke="#d5d5d5" stroke-width="1.5" /><text x="964" y="668" font-family="&quot;Times New Roman&quot;, Times, serif" font-size="22" font-weight="800" fill="#181818" text-anchor="start">Картон</text></svg>

After

Width:  |  Height:  |  Size: 4.6 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 6.4 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 5.7 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 5.3 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 5.7 KiB

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1440" height="900" viewBox="0 0 1440 900" fill="none"><rect width="1440" height="900" fill="#f4f4f4" /><rect x="24" y="24" width="1392" height="852" rx="28" fill="#ffffff" stroke="#d5d5d5" stroke-width="1.5" /><rect x="24" y="24" width="1392" height="56" rx="28" fill="#fafafa" stroke="#d5d5d5" stroke-width="1.5" /><rect x="24" y="52" width="1392" height="28" rx="0" fill="#fafafa" stroke="#fafafa" stroke-width="1.5" /><circle cx="58" cy="52" r="7" fill="#b7b7b7" /><circle cx="82" cy="52" r="7" fill="#d2d2d2" /><circle cx="106" cy="52" r="7" fill="#d4d4d4" /><text x="136" y="58" font-family="&quot;Times New Roman&quot;, Times, serif" font-size="17" font-weight="700" fill="#181818" text-anchor="start">Главная страница клиента</text><rect x="820" y="36" width="104" height="34" rx="17" fill="#181818" stroke="#181818" stroke-width="1.5" /><text x="872" y="58" font-family="&quot;Times New Roman&quot;, Times, serif" font-size="13" font-weight="700" fill="#ffffff" text-anchor="middle">Каталог</text><rect x="936" y="36" width="134" height="34" rx="17" fill="#f7f7f7" stroke="#d5d5d5" stroke-width="1.5" /><text x="1003" y="58" font-family="&quot;Times New Roman&quot;, Times, serif" font-size="13" font-weight="700" fill="#545454" text-anchor="middle">Мои заказы</text><rect x="1082" y="36" width="104" height="34" rx="17" fill="#f7f7f7" stroke="#d5d5d5" stroke-width="1.5" /><text x="1134" y="58" font-family="&quot;Times New Roman&quot;, Times, serif" font-size="13" font-weight="700" fill="#545454" text-anchor="middle">Корзина</text><rect x="1198" y="36" width="104" height="34" rx="17" fill="#f7f7f7" stroke="#d5d5d5" stroke-width="1.5" /><text x="1250" y="58" font-family="&quot;Times New Roman&quot;, Times, serif" font-size="13" font-weight="700" fill="#545454" text-anchor="middle">Профиль</text><text x="72" y="132" font-family="&quot;Times New Roman&quot;, Times, serif" font-size="32" font-weight="800" fill="#181818" text-anchor="start">Каталог</text><rect x="72" y="168" width="600" height="54" rx="27" fill="#ffffff" stroke="#d5d5d5" stroke-width="1.5" /><text x="98" y="201" font-family="&quot;Times New Roman&quot;, Times, serif" font-size="15" font-weight="500" fill="#777777" text-anchor="start">Поиск по типу товара</text><rect x="72" y="260" width="396" height="202" rx="26" fill="#ffffff" stroke="#d5d5d5" stroke-width="1.5" /><rect x="92" y="280" width="356" height="118" rx="22" fill="#f0f0f0" stroke="#d5d5d5" stroke-width="1.5" /><text x="100" y="430" font-family="&quot;Times New Roman&quot;, Times, serif" font-size="22" font-weight="800" fill="#181818" text-anchor="start">Стретч-пленка</text><rect x="504" y="260" width="396" height="202" rx="26" fill="#ffffff" stroke="#d5d5d5" stroke-width="1.5" /><rect x="524" y="280" width="356" height="118" rx="22" fill="#f0f0f0" stroke="#d5d5d5" stroke-width="1.5" /><text x="532" y="430" font-family="&quot;Times New Roman&quot;, Times, serif" font-size="22" font-weight="800" fill="#181818" text-anchor="start">Скотч</text><rect x="936" y="260" width="396" height="202" rx="26" fill="#ffffff" stroke="#d5d5d5" stroke-width="1.5" /><rect x="956" y="280" width="356" height="118" rx="22" fill="#f0f0f0" stroke="#d5d5d5" stroke-width="1.5" /><text x="964" y="430" font-family="&quot;Times New Roman&quot;, Times, serif" font-size="22" font-weight="800" fill="#181818" text-anchor="start">Пакеты</text><rect x="72" y="498" width="396" height="202" rx="26" fill="#ffffff" stroke="#d5d5d5" stroke-width="1.5" /><rect x="92" y="518" width="356" height="118" rx="22" fill="#f0f0f0" stroke="#d5d5d5" stroke-width="1.5" /><text x="100" y="668" font-family="&quot;Times New Roman&quot;, Times, serif" font-size="22" font-weight="800" fill="#181818" text-anchor="start">Пленка ПВД</text><rect x="504" y="498" width="396" height="202" rx="26" fill="#ffffff" stroke="#d5d5d5" stroke-width="1.5" /><rect x="524" y="518" width="356" height="118" rx="22" fill="#f0f0f0" stroke="#d5d5d5" stroke-width="1.5" /><text x="532" y="668" font-family="&quot;Times New Roman&quot;, Times, serif" font-size="22" font-weight="800" fill="#181818" text-anchor="start">Воздушно-пузырьковая</text><rect x="936" y="498" width="396" height="202" rx="26" fill="#ffffff" stroke="#d5d5d5" stroke-width="1.5" /><rect x="956" y="518" width="356" height="118" rx="22" fill="#f0f0f0" stroke="#d5d5d5" stroke-width="1.5" /><text x="964" y="668" font-family="&quot;Times New Roman&quot;, Times, serif" font-size="22" font-weight="800" fill="#181818" text-anchor="start">Картон</text></svg>

After

Width:  |  Height:  |  Size: 4.6 KiB

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1440" height="760" viewBox="0 0 1440 760" fill="none"><rect width="1440" height="760" fill="#f4f4f4" /><rect x="24" y="24" width="1392" height="712" rx="28" fill="#ffffff" stroke="#d5d5d5" stroke-width="1.5" /><rect x="24" y="24" width="1392" height="56" rx="28" fill="#fafafa" stroke="#d5d5d5" stroke-width="1.5" /><rect x="24" y="52" width="1392" height="28" rx="0" fill="#fafafa" stroke="#fafafa" stroke-width="1.5" /><circle cx="58" cy="52" r="7" fill="#b7b7b7" /><circle cx="82" cy="52" r="7" fill="#d2d2d2" /><circle cx="106" cy="52" r="7" fill="#d4d4d4" /><text x="136" y="58" font-family="&quot;Times New Roman&quot;, Times, serif" font-size="17" font-weight="700" fill="#181818" text-anchor="start">Логин</text><rect x="450" y="130" width="540" height="520" rx="32" fill="#ffffff" stroke="#d5d5d5" stroke-width="1.5" /><text x="720" y="196" font-family="&quot;Times New Roman&quot;, Times, serif" font-size="14" font-weight="800" fill="#545454" text-anchor="middle">Фрегат</text><text x="720" y="244" font-family="&quot;Times New Roman&quot;, Times, serif" font-size="36" font-weight="800" fill="#181818" text-anchor="middle">Вход</text><text x="510" y="292" font-family="&quot;Times New Roman&quot;, Times, serif" font-size="13" font-weight="700" fill="#545454" text-anchor="start">E-mail</text><rect x="510" y="304" width="420" height="48" rx="16" fill="#ffffff" stroke="#d5d5d5" stroke-width="1.5" /><rect x="510" y="386" width="420" height="44" rx="22" fill="#181818" stroke="#181818" stroke-width="1.5" /><text x="720" y="414" font-family="&quot;Times New Roman&quot;, Times, serif" font-size="14" font-weight="700" fill="#ffffff" text-anchor="middle">Получить код</text><line x1="510" y1="464" x2="930" y2="464" stroke="#d5d5d5" stroke-width="1.5" /><text x="720" y="492" font-family="&quot;Times New Roman&quot;, Times, serif" font-size="13" font-weight="700" fill="#545454" text-anchor="middle">или войти через</text><rect x="510" y="526" width="196" height="44" rx="22" fill="#e8e8e8" stroke="#d5d5d5" stroke-width="1.5" /><text x="608" y="554" font-family="&quot;Times New Roman&quot;, Times, serif" font-size="14" font-weight="700" fill="#545454" text-anchor="middle">Telegram</text><rect x="734" y="526" width="196" height="44" rx="22" fill="#e8e8e8" stroke="#d5d5d5" stroke-width="1.5" /><text x="832" y="554" font-family="&quot;Times New Roman&quot;, Times, serif" font-size="14" font-weight="700" fill="#545454" text-anchor="middle">Max</text></svg>

After

Width:  |  Height:  |  Size: 2.5 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 5.8 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 7.0 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 11 KiB

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1440" height="820" viewBox="0 0 1440 820" fill="none"><rect width="1440" height="820" fill="#f4f4f4" /><rect x="24" y="24" width="1392" height="772" rx="28" fill="#ffffff" stroke="#d5d5d5" stroke-width="1.5" /><rect x="24" y="24" width="1392" height="56" rx="28" fill="#fafafa" stroke="#d5d5d5" stroke-width="1.5" /><rect x="24" y="52" width="1392" height="28" rx="0" fill="#fafafa" stroke="#fafafa" stroke-width="1.5" /><circle cx="58" cy="52" r="7" fill="#b7b7b7" /><circle cx="82" cy="52" r="7" fill="#d2d2d2" /><circle cx="106" cy="52" r="7" fill="#d4d4d4" /><text x="136" y="58" font-family="&quot;Times New Roman&quot;, Times, serif" font-size="17" font-weight="700" fill="#181818" text-anchor="start">Профиль клиента</text><rect x="820" y="36" width="104" height="34" rx="17" fill="#f7f7f7" stroke="#d5d5d5" stroke-width="1.5" /><text x="872" y="58" font-family="&quot;Times New Roman&quot;, Times, serif" font-size="13" font-weight="700" fill="#545454" text-anchor="middle">Каталог</text><rect x="936" y="36" width="134" height="34" rx="17" fill="#f7f7f7" stroke="#d5d5d5" stroke-width="1.5" /><text x="1003" y="58" font-family="&quot;Times New Roman&quot;, Times, serif" font-size="13" font-weight="700" fill="#545454" text-anchor="middle">Мои заказы</text><rect x="1082" y="36" width="104" height="34" rx="17" fill="#f7f7f7" stroke="#d5d5d5" stroke-width="1.5" /><text x="1134" y="58" font-family="&quot;Times New Roman&quot;, Times, serif" font-size="13" font-weight="700" fill="#545454" text-anchor="middle">Корзина</text><rect x="1198" y="36" width="104" height="34" rx="17" fill="#181818" stroke="#181818" stroke-width="1.5" /><text x="1250" y="58" font-family="&quot;Times New Roman&quot;, Times, serif" font-size="13" font-weight="700" fill="#ffffff" text-anchor="middle">Профиль</text><text x="72" y="132" font-family="&quot;Times New Roman&quot;, Times, serif" font-size="32" font-weight="800" fill="#181818" text-anchor="start">Профиль</text><rect x="72" y="210" width="388" height="104" rx="24" fill="#ffffff" stroke="#d5d5d5" stroke-width="1.5" /><circle cx="116" cy="262" r="24" fill="#f0f0f0" /><text x="154" y="258" font-family="&quot;Times New Roman&quot;, Times, serif" font-size="17" font-weight="800" fill="#181818" text-anchor="start">Карточка контрагента</text><text x="154" y="282" font-family="&quot;Times New Roman&quot;, Times, serif" font-size="14" font-weight="500" fill="#545454" text-anchor="start">Реквизиты и ИНН</text><rect x="484" y="210" width="388" height="104" rx="24" fill="#ffffff" stroke="#d5d5d5" stroke-width="1.5" /><circle cx="528" cy="262" r="24" fill="#f0f0f0" /><text x="566" y="258" font-family="&quot;Times New Roman&quot;, Times, serif" font-size="17" font-weight="800" fill="#181818" text-anchor="start">Уведомления</text><text x="566" y="282" font-family="&quot;Times New Roman&quot;, Times, serif" font-size="14" font-weight="500" fill="#545454" text-anchor="start">Telegram и Max</text><rect x="896" y="210" width="388" height="104" rx="24" fill="#ffffff" stroke="#d5d5d5" stroke-width="1.5" /><circle cx="940" cy="262" r="24" fill="#f0f0f0" /><text x="978" y="258" font-family="&quot;Times New Roman&quot;, Times, serif" font-size="17" font-weight="800" fill="#181818" text-anchor="start">Адреса доставки</text><text x="978" y="282" font-family="&quot;Times New Roman&quot;, Times, serif" font-size="14" font-weight="500" fill="#545454" text-anchor="start">Список адресов</text></svg>

After

Width:  |  Height:  |  Size: 3.5 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 7.4 KiB

View File

@@ -0,0 +1,35 @@
import { mkdir } from 'node:fs/promises';
import { dirname, join, relative } from 'node:path';
import { fileURLToPath } from 'node:url';
import { spawnSync } from 'node:child_process';
// Usage: node docs/scripts/build-typst-tz.mjs
// Source: docs/tz-fregat.typ
// Output: docs/export/tz-fregat.pdf
const __dirname = dirname(fileURLToPath(import.meta.url));
const docsDir = join(__dirname, '..');
const sourceFile = join(docsDir, 'tz-fregat.typ');
const exportDir = join(docsDir, 'export');
const pdfFile = join(exportDir, 'tz-fregat.pdf');
await mkdir(exportDir, { recursive: true });
const compileResult = spawnSync('typst', [
'compile',
'--root',
'.',
relative(docsDir, sourceFile),
relative(docsDir, pdfFile),
], {
cwd: docsDir,
encoding: 'utf8',
});
if (compileResult.status !== 0) {
process.stderr.write(compileResult.stderr);
process.stderr.write(compileResult.stdout);
throw new Error('Typst PDF build failed');
}
process.stdout.write(`Generated ${relative(docsDir, pdfFile)}\n`);

View File

@@ -0,0 +1,493 @@
import { mkdirSync, writeFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
const __dirname = dirname(fileURLToPath(import.meta.url));
const outDir = join(__dirname, '..', 'public', 'prototypes');
const W = 1440;
const C = {
page: '#f4f4f4',
paper: '#ffffff',
panel: '#ffffff',
soft: '#f7f7f7',
line: '#d5d5d5',
dark: '#181818',
mid: '#545454',
muted: '#777777',
fill: '#e8e8e8',
fill2: '#f0f0f0',
};
const font = '"Times New Roman", Times, serif';
function esc(value) {
return String(value)
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;');
}
function attrs(values) {
return Object.entries(values)
.filter(([, value]) => value !== undefined && value !== null)
.map(([key, value]) => `${key}="${esc(value)}"`)
.join(' ');
}
function rect(x, y, width, height, options = {}) {
const {
rx = 18,
fill = C.panel,
stroke = C.line,
sw = 1.5,
} = options;
return `<rect ${attrs({ x, y, width, height, rx, fill, stroke, 'stroke-width': sw })} />`;
}
function line(x1, y1, x2, y2, options = {}) {
return `<line ${attrs({
x1,
y1,
x2,
y2,
stroke: options.stroke ?? C.line,
'stroke-width': options.sw ?? 1.5,
})} />`;
}
function text(x, y, value, options = {}) {
const {
size = 16,
weight = 500,
fill = C.dark,
anchor = 'start',
} = options;
return `<text ${attrs({
x,
y,
'font-family': font,
'font-size': size,
'font-weight': weight,
fill,
'text-anchor': anchor,
})}>${esc(value)}</text>`;
}
function circle(cx, cy, r, options = {}) {
return `<circle ${attrs({
cx,
cy,
r,
fill: options.fill ?? C.fill,
stroke: options.stroke,
'stroke-width': options.sw,
})} />`;
}
function chip(x, y, value, options = {}) {
const width = options.width ?? Math.max(76, value.length * 9 + 28);
const selected = options.selected ?? false;
return [
rect(x, y, width, 34, {
rx: 17,
fill: selected ? C.dark : C.soft,
stroke: selected ? C.dark : C.line,
}),
text(x + width / 2, y + 22, value, {
size: 13,
weight: 700,
fill: selected ? '#ffffff' : C.mid,
anchor: 'middle',
}),
].join('');
}
function input(x, y, width, label) {
return [
text(x, y - 12, label, { size: 13, weight: 700, fill: C.mid }),
rect(x, y, width, 48, { rx: 16, fill: C.paper }),
].join('');
}
function button(x, y, width, label, options = {}) {
const dark = options.dark ?? false;
return [
rect(x, y, width, 44, {
rx: 22,
fill: dark ? C.dark : C.fill,
stroke: dark ? C.dark : C.line,
}),
text(x + width / 2, y + 28, label, {
size: 14,
weight: 700,
fill: dark ? '#ffffff' : C.mid,
anchor: 'middle',
}),
].join('');
}
function topShell(label, nav = [], active = '') {
const parts = [
rect(24, 24, 1392, 56, { rx: 28, fill: '#fafafa' }),
rect(24, 52, 1392, 28, { rx: 0, fill: '#fafafa', stroke: '#fafafa' }),
circle(58, 52, 7, { fill: '#b7b7b7' }),
circle(82, 52, 7, { fill: '#d2d2d2' }),
circle(106, 52, 7, { fill: '#d4d4d4' }),
text(136, 58, label, { size: 17, weight: 700 }),
];
let x = 820;
for (const item of nav) {
parts.push(chip(x, 36, item, { width: Math.max(88, item.length * 10 + 34), selected: item === active }));
x += Math.max(88, item.length * 10 + 34) + 12;
}
return parts.join('');
}
function page(label, height, body, options = {}) {
const nav = options.nav ?? ['Каталог', 'Мои заказы', 'Корзина', 'Профиль'];
const active = options.active ?? '';
return `<svg xmlns="http://www.w3.org/2000/svg" width="${W}" height="${height}" viewBox="0 0 ${W} ${height}" fill="none"><rect width="${W}" height="${height}" fill="${C.page}" />${rect(24, 24, 1392, height - 48, { rx: 28, fill: C.paper })}${topShell(label, nav, active)}${body.join('')}</svg>`;
}
function titleBlock(title, y = 132, x = 72) {
return text(x, y, title, { size: 32, weight: 800 });
}
function searchHero(title, placeholder, controls = []) {
const parts = [
titleBlock(title),
rect(72, 168, 600, 54, { rx: 27, fill: C.paper }),
text(98, 201, placeholder, { size: 15, weight: 500, fill: C.muted }),
];
let x = 700;
for (const control of controls) {
parts.push(chip(x, 178, control, { width: Math.max(120, control.length * 9 + 34), selected: control === controls[0] }));
x += Math.max(120, control.length * 9 + 34) + 12;
}
return parts.join('');
}
function catalogCards(y = 260) {
const cards = ['Стретч-пленка', 'Скотч', 'Пакеты', 'Пленка ПВД', 'Воздушно-пузырьковая', 'Картон'];
const parts = [];
cards.forEach((name, index) => {
const col = index % 3;
const row = Math.floor(index / 3);
const x = 72 + col * 432;
const yy = y + row * 238;
parts.push(rect(x, yy, 396, 202, { rx: 26 }));
parts.push(rect(x + 20, yy + 20, 356, 118, { rx: 22, fill: C.fill2 }));
parts.push(text(x + 28, yy + 170, name, { size: 22, weight: 800 }));
});
return parts.join('');
}
function orderRows(x, y, width, rows, options = {}) {
const parts = [];
const rowH = options.rowH ?? 72;
rows.forEach((row, index) => {
const yy = y + index * (rowH + 12);
parts.push(rect(x, yy, width, rowH, { rx: 20, fill: index % 2 ? '#fbfbfb' : C.paper }));
parts.push(text(x + 24, yy + 30, row[0], { size: 17, weight: 800 }));
parts.push(text(x + 24, yy + 54, row[1], { size: 14, weight: 500, fill: C.mid }));
if (row[2]) {
parts.push(chip(x + width - 210, yy + 18, row[2], { width: 150 }));
}
});
return parts.join('');
}
function cardGrid(x, y, labels, columns = 3) {
const parts = [];
labels.forEach((label, index) => {
const col = index % columns;
const row = Math.floor(index / columns);
const w = columns === 4 ? 300 : 388;
const xx = x + col * (w + 24);
const yy = y + row * 128;
parts.push(rect(xx, yy, w, 104, { rx: 24 }));
parts.push(circle(xx + 44, yy + 52, 24, { fill: C.fill2 }));
parts.push(text(xx + 82, yy + 48, label[0], { size: 17, weight: 800 }));
if (label[1]) {
parts.push(text(xx + 82, yy + 72, label[1], { size: 14, weight: 500, fill: C.mid }));
}
});
return parts.join('');
}
const pages = {
'dashboard.svg': page('Главная страница клиента', 900, [
searchHero('Каталог', 'Поиск по типу товара', []),
catalogCards(260),
], { active: 'Каталог' }),
'catalog-grid.svg': page('Каталог продукции', 900, [
searchHero('Каталог', 'Поиск по типу товара', []),
catalogCards(260),
], { active: 'Каталог' }),
'product-card.svg': page('Карточка товара', 1040, [
button(72, 116, 110, 'Назад'),
titleBlock('Алюминиевый скотч', 166),
rect(72, 220, 400, 330, { rx: 32 }),
rect(102, 252, 340, 228, { rx: 26, fill: C.fill2 }),
text(272, 510, 'Изображение товара', { size: 16, weight: 700, fill: C.mid, anchor: 'middle' }),
rect(504, 220, 536, 330, { rx: 32 }),
text(536, 258, 'Параметры', { size: 22, weight: 800 }),
text(536, 304, 'Ширина', { size: 14, weight: 700, fill: C.mid }),
chip(536, 320, '48 мм', { selected: true }),
chip(628, 320, '75 мм'),
text(780, 304, 'Длина', { size: 14, weight: 700, fill: C.mid }),
chip(780, 320, '25 м', { selected: true }),
chip(862, 320, '50 м'),
chip(944, 320, '100 м'),
text(536, 386, 'Толщина', { size: 14, weight: 700, fill: C.mid }),
chip(536, 402, '43 мкм', { selected: true }),
chip(638, 402, '45 мкм'),
text(780, 386, 'Втулка', { size: 14, weight: 700, fill: C.mid }),
chip(780, 402, 'Стандарт', { selected: true, width: 112 }),
chip(904, 402, 'Логотип', { width: 104 }),
text(536, 468, 'Цвет', { size: 14, weight: 700, fill: C.mid }),
chip(536, 484, 'Серебристый', { selected: true, width: 126 }),
text(780, 468, 'Надпись', { size: 14, weight: 700, fill: C.mid }),
chip(780, 484, 'Без надписи', { selected: true, width: 136 }),
rect(1072, 220, 296, 330, { rx: 32 }),
text(1100, 262, 'FRG-ALU-48-50', { size: 20, weight: 800 }),
text(1100, 310, 'В наличии', { size: 16, weight: 700, fill: C.mid }),
text(1100, 342, '2 140', { size: 38, weight: 800 }),
button(1100, 394, 220, 'В корзину', { dark: true }),
text(72, 624, 'Доступные варианты', { size: 24, weight: 800 }),
rect(72, 652, 1296, 258, { rx: 24 }),
text(104, 698, 'SKU', { size: 14, weight: 800, fill: C.mid }),
text(312, 698, 'Ширина', { size: 14, weight: 800, fill: C.mid }),
text(470, 698, 'Длина', { size: 14, weight: 800, fill: C.mid }),
text(620, 698, 'Толщина', { size: 14, weight: 800, fill: C.mid }),
text(790, 698, 'Втулка', { size: 14, weight: 800, fill: C.mid }),
text(970, 698, 'Остаток', { size: 14, weight: 800, fill: C.mid }),
text(1160, 698, 'Действие', { size: 14, weight: 800, fill: C.mid }),
line(96, 716, 1344, 716),
orderRows(96, 738, 1248, [
['FRG-ALU-48-50', '48 мм · 50 м · 43 мкм · стандарт', 'В корзину'],
['FRG-ALU-75-50', '75 мм · 50 м · 45 мкм · стандарт', 'В корзину'],
], { rowH: 64 }),
], { active: 'Каталог' }),
'cart.svg': page('Корзина', 900, [
titleBlock('Корзина'),
rect(72, 168, 1296, 68, { rx: 24, fill: C.soft }),
text(102, 210, 'Заполните карточку контрагента перед оформлением заявки', { size: 16, weight: 700, fill: C.mid }),
text(72, 284, 'Состав заказа', { size: 24, weight: 800 }),
rect(72, 312, 760, 330, { rx: 28 }),
orderRows(104, 344, 696, [
['Стретч-пленка', '48 мм · 50 м · 43 мкм', '2 шт'],
['Скотч упаковочный', '75 мм · 66 м', '4 шт'],
['Пакет ПВД', '300 x 400 мм', '1 шт'],
], { rowH: 72 }),
rect(872, 312, 496, 330, { rx: 28 }),
text(904, 354, 'Информация о доставке', { size: 22, weight: 800 }),
chip(904, 390, 'Склад клиента', { selected: true, width: 160 }),
chip(904, 444, 'Новый адрес', { width: 148 }),
input(904, 532, 380, 'Комментарий'),
button(904, 610, 260, 'Оформить заявку', { dark: true }),
], { active: 'Корзина' }),
'client-order.svg': page('Карточка заказа клиента', 860, [
button(72, 116, 190, 'Назад к моим заказам'),
titleBlock('Заказ FRG-1024', 170),
rect(72, 220, 1296, 118, { rx: 28 }),
text(104, 262, 'Статус заказа', { size: 16, weight: 700, fill: C.mid }),
chip(104, 282, 'Предложение', { selected: true, width: 148 }),
chip(282, 282, 'Подтвердить', { width: 150 }),
chip(456, 282, 'Отклонить', { width: 130 }),
text(72, 394, 'Состав заказа', { size: 24, weight: 800 }),
rect(72, 426, 1296, 260, { rx: 28 }),
orderRows(104, 458, 1232, [
['Стретч-пленка', '48 мм · 50 м · количество 2', 'Цена задана'],
['Скотч упаковочный', '75 мм · 66 м · количество 4', 'Цена задана'],
], { rowH: 76 }),
rect(72, 720, 1296, 80, { rx: 24, fill: C.soft }),
text(104, 754, 'Доставка', { size: 17, weight: 800 }),
text(260, 754, 'Адрес, срок и стоимость доставки показываются в одной строке', { size: 15, weight: 500, fill: C.mid }),
], { active: 'Мои заказы' }),
'login.svg': page('Логин', 760, [
rect(450, 130, 540, 520, { rx: 32 }),
text(720, 196, 'Фрегат', { size: 14, weight: 800, fill: C.mid, anchor: 'middle' }),
text(720, 244, 'Вход', { size: 36, weight: 800, anchor: 'middle' }),
input(510, 304, 420, 'E-mail'),
button(510, 386, 420, 'Получить код', { dark: true }),
line(510, 464, 930, 464),
text(720, 492, 'или войти через', { size: 13, weight: 700, fill: C.mid, anchor: 'middle' }),
button(510, 526, 196, 'Telegram'),
button(734, 526, 196, 'Max'),
], { nav: [] }),
'bonus-cabinet.svg': page('Бонусный кабинет', 940, [
titleBlock('Чёрный кабинет бонусной программы'),
rect(72, 178, 820, 250, { rx: 30 }),
text(112, 230, 'Аккаунт', { size: 15, weight: 700, fill: C.mid }),
text(112, 280, 'Клиент бонусной программы', { size: 32, weight: 800, fill: C.dark }),
text(112, 354, 'Доступный баланс', { size: 15, weight: 700, fill: C.mid }),
text(112, 398, '12 400', { size: 48, weight: 800, fill: C.dark }),
rect(928, 178, 440, 250, { rx: 30 }),
text(968, 230, 'Вывод бонусов', { size: 20, weight: 800, fill: C.dark }),
input(968, 292, 320, 'Сумма заявки'),
button(968, 370, 280, 'Подать заявку', { dark: false }),
rect(72, 472, 620, 300, { rx: 30 }),
text(112, 522, 'История бонусов', { size: 24, weight: 800, fill: C.dark }),
orderRows(112, 552, 540, [
['+1 500', 'Начисление по заказу', ''],
['+900', 'Реферальное начисление', ''],
], { rowH: 68 }),
rect(748, 472, 620, 300, { rx: 30 }),
text(788, 522, 'Вознаграждения', { size: 24, weight: 800, fill: C.dark }),
button(788, 566, 170, 'Ozon 3000'),
button(980, 566, 210, 'Wildberries 4000'),
button(788, 634, 190, 'М.Видео 5000'),
], { active: 'Профиль' }),
'client-list.svg': page('Клиенты', 900, [
searchHero('Клиенты', 'Имя, компания или email', ['Пригласить']),
cardGrid(72, 270, [
['Иван Петров', 'ООО Альфа'],
['Мария Соколова', 'ИП Соколова'],
['Дмитрий Иванов', 'ООО Север'],
['Анна Смирнова', 'ООО Вектор'],
['Павел Морозов', 'Завод Мир'],
['Елена Орлова', 'ТД Орлова'],
], 3),
], { nav: ['Заказы', 'Бонусы', 'Настройки'], active: 'Заказы' }),
'client-card.svg': page('Карточка клиента', 880, [
button(72, 116, 170, 'Назад к клиентам'),
titleBlock('Клиент Иван Петров', 170),
cardGrid(72, 224, [
['Email', 'client@company.ru'],
['Telegram', 'Подключен'],
['Компания', 'ООО Альфа'],
['ИНН', '7700000000'],
], 4),
text(72, 500, 'Заказы пользователя', { size: 24, weight: 800 }),
rect(72, 532, 1296, 240, { rx: 28 }),
orderRows(104, 564, 1232, [
['FRG-1024', 'Стретч-пленка · Москва', 'В работе'],
['FRG-1017', 'Скотч · Санкт-Петербург', 'Завершен'],
], { rowH: 72 }),
], { nav: ['Заказы', 'Бонусы', 'Настройки'], active: 'Заказы' }),
'manager-order.svg': page('Обработка заявки', 900, [
button(72, 116, 170, 'Назад к заказам'),
titleBlock('Заказ FRG-1024', 170),
rect(72, 220, 1296, 118, { rx: 28 }),
text(104, 262, 'Статус заказа', { size: 16, weight: 700, fill: C.mid }),
chip(104, 282, 'В обработке', { selected: true, width: 148 }),
chip(282, 282, 'Предложение', { width: 150 }),
rect(72, 382, 920, 300, { rx: 28 }),
text(104, 426, 'Состав заказа', { size: 24, weight: 800 }),
orderRows(104, 460, 856, [
['Стретч-пленка', 'Количество 2 · цена редактируется', 'Цена'],
['Скотч упаковочный', 'Количество 4 · цена редактируется', 'Цена'],
], { rowH: 76 }),
rect(1028, 382, 340, 300, { rx: 28 }),
text(1060, 426, 'Условия', { size: 24, weight: 800 }),
input(1060, 484, 250, 'Срок доставки'),
input(1060, 578, 250, 'Стоимость доставки'),
button(1060, 650, 230, 'Сохранить', { dark: true }),
], { nav: ['Заказы', 'Бонусы', 'Настройки'], active: 'Заказы' }),
'manager-orders.svg': page('Заказы менеджера', 920, [
searchHero('Заказы', 'Номер заказа, клиент, адрес или товар', ['Список', 'Календарь']),
chip(72, 250, 'Все', { selected: true, width: 88 }),
chip(174, 250, 'Заявки', { width: 112 }),
chip(300, 250, 'Предложения', { width: 150 }),
chip(464, 250, 'В работе', { width: 126 }),
chip(604, 250, 'Закрытые', { width: 126 }),
rect(72, 320, 1296, 430, { rx: 30 }),
orderRows(104, 356, 1232, [
['FRG-1024', 'Иван Петров · Стретч-пленка · Москва', 'Заявка'],
['FRG-1025', 'Мария Соколова · Скотч · Казань', 'Предложение'],
['FRG-1026', 'Дмитрий Иванов · Пакеты · СПб', 'В работе'],
['FRG-1027', 'Анна Смирнова · Пленка ПВД · Москва', 'Закрыт'],
], { rowH: 76 }),
], { nav: ['Заказы', 'Бонусы', 'Настройки'], active: 'Заказы' }),
'catalog-settings.svg': page('Настройки каталога', 980, [
titleBlock('Каталог'),
rect(72, 184, 1296, 104, { rx: 28 }),
text(104, 226, 'Стретч-пленка', { size: 22, weight: 800 }),
text(104, 256, '6 параметров, 3 кастомные возможности', { size: 15, weight: 500, fill: C.mid }),
rect(72, 318, 1296, 446, { rx: 28 }),
text(104, 362, 'Кастомные возможности', { size: 22, weight: 800 }),
chip(104, 390, 'Любая длина', { selected: true, width: 140 }),
chip(262, 390, 'Логотип на втулке', { width: 190 }),
chip(470, 390, 'Нанесение надписи', { width: 200 }),
text(104, 478, 'Диапазон длины', { size: 18, weight: 800 }),
input(104, 520, 240, 'Мин. длина, м'),
input(378, 520, 240, 'Макс. длина, м'),
input(652, 520, 240, 'Шаг, м'),
text(104, 638, 'Параметры', { size: 18, weight: 800 }),
chip(104, 664, 'Ширина', { width: 110 }),
chip(232, 664, 'Длина', { width: 100 }),
chip(350, 664, 'Толщина', { width: 120 }),
chip(488, 664, 'Втулка', { width: 108 }),
chip(614, 664, 'Цвет', { width: 96 }),
chip(728, 664, 'Надпись', { width: 120 }),
button(1100, 804, 190, 'Сохранить', { dark: true }),
], { nav: ['Заказы', 'Бонусы', 'Настройки'], active: 'Настройки' }),
'sync-settings.svg': page('Настройки синхронизации', 900, [
titleBlock('1С'),
text(72, 168, 'Статус загрузки файлов обмена', { size: 16, weight: 500, fill: C.mid }),
cardGrid(72, 230, [
['counterparties_snapshot', 'Контрагенты'],
['catalog_snapshot', 'Каталог и остатки'],
['balances_snapshot', 'Задолженность клиентов'],
['orders_snapshot', 'Заказы клиентов'],
], 4),
rect(72, 450, 1296, 250, { rx: 28 }),
text(104, 494, 'Последние загрузки', { size: 24, weight: 800 }),
orderRows(104, 530, 1232, [
['Контрагенты', 'Загружены реквизиты и признаки доступа', 'Работает'],
['Каталог и остатки', 'Загружено 2 418 записей · последний run сегодня', 'Работает'],
['Задолженность клиентов', 'Баланс по клиентам с личным кабинетом', 'Работает'],
['Заказы клиентов', 'Статусы заказов за рабочий период', 'Работает'],
], { rowH: 62 }),
], { nav: ['Заказы', 'Бонусы', 'Настройки'], active: 'Настройки' }),
'profile.svg': page('Профиль клиента', 820, [
titleBlock('Профиль'),
cardGrid(72, 210, [
['Карточка контрагента', 'Реквизиты и ИНН'],
['Уведомления', 'Telegram и Max'],
['Адреса доставки', 'Список адресов'],
], 3),
], { active: 'Профиль' }),
'bonus-manager.svg': page('Бонусная система менеджера', 920, [
searchHero('Бонусы', 'Клиент, связанный клиент или email', ['Добавить']),
chip(72, 250, 'Балансы', { selected: true, width: 120 }),
chip(208, 250, 'Заявки', { width: 110 }),
chip(334, 250, 'Награды', { width: 116 }),
cardGrid(72, 320, [
['Иван Петров', '12 400 ₽'],
['Мария Соколова', '8 250 ₽'],
['Дмитрий Иванов', '5 100 ₽'],
['Анна Смирнова', '2 900 ₽'],
], 4),
rect(72, 610, 1296, 170, { rx: 28 }),
text(104, 654, 'Заявки на выплату', { size: 24, weight: 800 }),
orderRows(104, 686, 1232, [
['WD-01A23F', 'Иван Петров · на проверке', '12 000 ₽'],
], { rowH: 68 }),
], { nav: ['Заказы', 'Бонусы', 'Настройки'], active: 'Бонусы' }),
};
mkdirSync(outDir, { recursive: true });
for (const [fileName, content] of Object.entries(pages)) {
writeFileSync(join(outDir, fileName), `${content}\n`, 'utf8');
}

2752
docs/tz-fregat.typ Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -10,6 +10,7 @@ query ClientProducts {
thicknessMicron
sleeveBrand
quantityPerBox
tags
isCustomizable
availableInWarehouses {
availableQty

View File

@@ -0,0 +1,18 @@
query CatalogProductTypeSettings {
catalogProductTypeSettings {
productType
showQuantityPerBox
allowCustomLength
customLengthMinM
customLengthMaxM
customLengthStepM
allowCustomSleeveBrand
allowCustomLabel
widthOptionsMm
lengthOptionsM
thicknessOptionsMicron
sleeveOptions
colorOptions
labelOptions
}
}

View File

@@ -0,0 +1,18 @@
mutation UpsertCatalogProductTypeSetting($input: UpsertCatalogProductTypeSettingInput!) {
upsertCatalogProductTypeSetting(input: $input) {
productType
showQuantityPerBox
allowCustomLength
customLengthMinM
customLengthMaxM
customLengthStepM
allowCustomSleeveBrand
allowCustomLabel
widthOptionsMm
lengthOptionsM
thicknessOptionsMicron
sleeveOptions
colorOptions
labelOptions
}
}

View File

@@ -242,11 +242,29 @@ type Product {
thicknessMicron: Int
sleeveBrand: String
quantityPerBox: String
tags: [String!]!
isCustomizable: Boolean!
isActive: Boolean!
availableInWarehouses: [ProductWarehouseBalance!]!
}
type CatalogProductTypeSetting {
productType: String!
showQuantityPerBox: Boolean!
allowCustomLength: Boolean!
customLengthMinM: Int
customLengthMaxM: Int
customLengthStepM: Int
allowCustomSleeveBrand: Boolean!
allowCustomLabel: Boolean!
widthOptionsMm: [Int!]!
lengthOptionsM: [Int!]!
thicknessOptionsMicron: [Int!]!
sleeveOptions: [String!]!
colorOptions: [String!]!
labelOptions: [String!]!
}
type CartItem {
id: ID!
productId: ID!
@@ -410,6 +428,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!]!
@@ -490,6 +509,23 @@ input UpdateCartItemQuantityInput {
quantity: Float!
}
input UpsertCatalogProductTypeSettingInput {
productType: String!
showQuantityPerBox: Boolean!
allowCustomLength: Boolean!
customLengthMinM: Int
customLengthMaxM: Int
customLengthStepM: Int
allowCustomSleeveBrand: Boolean!
allowCustomLabel: Boolean!
widthOptionsMm: [Int!]!
lengthOptionsM: [Int!]!
thicknessOptionsMicron: [Int!]!
sleeveOptions: [String!]!
colorOptions: [String!]!
labelOptions: [String!]!
}
input ReadyOrderItemInput {
productId: ID!
quantity: Float!
@@ -553,6 +589,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!

View File

@@ -10,7 +10,10 @@
"postinstall": "nuxt prepare",
"codegen": "graphql-codegen --config codegen.ts",
"storybook": "storybook dev -p 6006",
"build-storybook": "storybook build"
"build-storybook": "storybook build",
"docs:dev": "vitepress dev docs",
"docs:build": "vitepress build docs",
"docs:preview": "vitepress preview docs"
},
"dependencies": {
"@apollo/client": "^3.14.1",
@@ -24,6 +27,7 @@
"@vue/apollo-composable": "^4.2.2",
"daisyui": "^5.5.19",
"graphql": "^16.13.2",
"mermaid": "^11.14.0",
"nuxt": "^4.4.2",
"vue": "^3.5.30",
"vue-router": "^5.0.4"
@@ -34,9 +38,11 @@
"@graphql-codegen/typescript": "^5.0.9",
"@graphql-codegen/typescript-operations": "^5.0.9",
"@graphql-codegen/typescript-vue-apollo": "^5.0.0",
"@mermaid-js/mermaid-cli": "^11.14.0",
"@storybook/addon-essentials": "8.6.14",
"@storybook/vue3-vite": "^8.6.14",
"storybook": "^8.6.14",
"typescript": "5.9.2"
"typescript": "5.9.2",
"vitepress": "1.6.4"
}
}

2953
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

9
pnpm-workspace.yaml Normal file
View File

@@ -0,0 +1,9 @@
packages:
- .
allowBuilds:
"@parcel/watcher": true
esbuild: true
puppeteer: true
unrs-resolver: true
vue-demi: true