Compare commits
67 Commits
86eee08d87
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f9be58d061 | ||
|
|
898171cb5f | ||
|
|
5f68cc80b1 | ||
|
|
0ce037df6e | ||
|
|
d3f56efac1 | ||
|
|
49e0e444d9 | ||
|
|
6a49cbcc30 | ||
|
|
107623ca92 | ||
|
|
dfc053b723 | ||
|
|
81efc78029 | ||
|
|
948385bc32 | ||
|
|
283abf95a1 | ||
|
|
fb22a6b11d | ||
|
|
b971444976 | ||
|
|
415fcf40fe | ||
|
|
d9cefb9f54 | ||
|
|
a52eb45ca3 | ||
|
|
3d790b2102 | ||
|
|
790b3a1d99 | ||
|
|
3885782afd | ||
|
|
d21ff3437f | ||
|
|
d86d817bce | ||
|
|
234f46c082 | ||
|
|
bbd9dcfb5a | ||
|
|
ac312a3a62 | ||
|
|
0a96adbb78 | ||
|
|
98ae168a93 | ||
|
|
e050fd55a5 | ||
|
|
58e9d6806d | ||
|
|
542ad1b648 | ||
|
|
b7a5018c6e | ||
|
|
eb6dcf9a52 | ||
|
|
d514eac990 | ||
|
|
3a3bd09a8c | ||
|
|
fc6117c8f5 | ||
|
|
fccb3039bf | ||
|
|
46bb36d63c | ||
|
|
ef0622fe89 | ||
|
|
df721e273d | ||
|
|
3b3959ced0 | ||
|
|
e8ff766c24 | ||
|
|
03ac74e10b | ||
|
|
09054647aa | ||
|
|
93074c5c14 | ||
|
|
73adbb76c7 | ||
|
|
0236d88b20 | ||
|
|
76ab87620e | ||
|
|
21e40d3fa1 | ||
|
|
848b491a90 | ||
|
|
28b29480bc | ||
|
|
2b134940f0 | ||
|
|
6f1df4bf00 | ||
|
|
872dba648c | ||
|
|
de5fc6b4a8 | ||
|
|
d6f1a03501 | ||
|
|
a5fd0a7d5e | ||
|
|
7ed5fbd66d | ||
|
|
e8fbe84e4f | ||
|
|
f8880d75c6 | ||
|
|
21ce43b790 | ||
|
|
8d6bc7346c | ||
|
|
5173956b06 | ||
|
|
249e081dec | ||
|
|
345301e138 | ||
|
|
af5d06f990 | ||
|
|
647947d9cb | ||
|
|
a54b4f4405 |
2
.gitignore
vendored
@@ -5,6 +5,8 @@
|
||||
.nitro
|
||||
.cache
|
||||
dist
|
||||
docs/.vitepress/cache
|
||||
docs/export
|
||||
|
||||
# Node dependencies
|
||||
node_modules
|
||||
|
||||
@@ -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
|
||||
|
||||
10
app/app.vue
@@ -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: 'Сообщения',
|
||||
|
||||
@@ -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,26 +245,40 @@ 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;
|
||||
}
|
||||
|
||||
function visibleFieldsByColumn(group: ProductGroup) {
|
||||
const visibleKeys = new Set(visibleFields(group).map((field) => field.key));
|
||||
|
||||
const leftColumn = parameterFields.filter((field) => (
|
||||
visibleKeys.has(field.key)
|
||||
&& ['widthMm', 'lengthM'].includes(field.key)
|
||||
));
|
||||
|
||||
const rightColumn = parameterFields.filter((field) => (
|
||||
visibleKeys.has(field.key)
|
||||
&& ['thicknessMicron', 'quantityPerBox', 'sleeveBrand'].includes(field.key)
|
||||
));
|
||||
|
||||
return { leftColumn, rightColumn };
|
||||
return getAllFieldOptions(group, field.key).length > 1;
|
||||
});
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
return productGroups.value[currentGroupIndex.value - 1] ?? null;
|
||||
});
|
||||
const nextGroup = computed(() => {
|
||||
if (currentGroupIndex.value < 0 || currentGroupIndex.value >= productGroups.value.length - 1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
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(group.typeLabel, group.key)"
|
||||
:alt="`Превью группы ${group.typeLabel}`"
|
||||
class="aspect-square w-full rounded-[24px] object-cover"
|
||||
: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(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"
|
||||
v-if="customizationDetails(selectedGroup).length"
|
||||
class="rounded-[28px] border border-[#dce9e1] bg-[#f7fbf8] p-4"
|
||||
>
|
||||
<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',
|
||||
]"
|
||||
<div class="space-y-2">
|
||||
<p
|
||||
v-for="note in customizationDetails(selectedGroup)"
|
||||
:key="`${selectedGroup.key}-${note}`"
|
||||
class="text-sm leading-6 text-[#456555]"
|
||||
>
|
||||
<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>
|
||||
{{ note }}
|
||||
</p>
|
||||
</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"
|
||||
<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)]"
|
||||
>
|
||||
<p class="text-sm font-semibold text-[#163624]">{{ field.label }}</p>
|
||||
|
||||
<div class="mt-3 flex flex-wrap gap-2">
|
||||
<div class="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"
|
||||
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(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',
|
||||
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="`${group.key}-${field.key}`"
|
||||
:checked="getGroupState(group)[field.key] === option"
|
||||
@change="updateField(group, field.key, option)"
|
||||
: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>
|
||||
</div>
|
||||
</div>
|
||||
</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="p-4 md:p-5 xl:col-span-1">
|
||||
<div class="flex h-full flex-col justify-between gap-4">
|
||||
<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>
|
||||
|
||||
<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)"
|
||||
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="rounded-[22px] border border-base-300 bg-base-100 px-2 py-1">
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<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 btn-sm"
|
||||
:disabled="selectedQty(group) === 0"
|
||||
@click="decrementSelected(group)"
|
||||
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-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)">
|
||||
<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>
|
||||
|
||||
<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="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 v-else class="alert surface-card border-0">По текущему запросу товары не найдены.</div>
|
||||
</div>
|
||||
<div v-else class="alert surface-card border-0">Такой тип товара не найден.</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
127
app/components/catalog/CatalogProductTypeList.vue
Normal 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>
|
||||
@@ -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 {
|
||||
@@ -2966,3 +3062,45 @@ export function useIntegrationSyncDashboardLazyQuery(options: VueApolloComposabl
|
||||
return VueApolloComposable.useLazyQuery<IntegrationSyncDashboardQuery, IntegrationSyncDashboardQueryVariables>(IntegrationSyncDashboardDocument, {}, options);
|
||||
}
|
||||
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>;
|
||||
@@ -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"
|
||||
>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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 = 'Нельзя связать клиента с самим собой.';
|
||||
return;
|
||||
}
|
||||
|
||||
const normalizedBonusPercent = Number(bonusPercent.value);
|
||||
if (!Number.isFinite(normalizedBonusPercent) || normalizedBonusPercent <= 0 || normalizedBonusPercent > 100) {
|
||||
errorMessage.value = 'Укажите процент бонуса от 0.01 до 100.';
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await createReferralMutation.mutate({
|
||||
input: {
|
||||
referrerUserId: referrerUserId.value,
|
||||
refereeUserId: refereeUserId.value,
|
||||
bonusPercent: normalizedBonusPercent,
|
||||
},
|
||||
const bonusLinkResponse = await createBonusProgramLinkMutation.mutate({
|
||||
userId: userId.value,
|
||||
});
|
||||
|
||||
createdReferralId.value = response?.data?.createReferral.id ?? '';
|
||||
refereeUserId.value = '';
|
||||
await linksQuery.refetch();
|
||||
} catch (error: unknown) {
|
||||
errorMessage.value = error instanceof Error
|
||||
? error.message
|
||||
: 'Не удалось создать бонусную связку.';
|
||||
const bonusLinkPayload: CreateBonusProgramLinkMutation['createBonusProgramLink'] | undefined = bonusLinkResponse?.data?.createBonusProgramLink;
|
||||
if (!bonusLinkPayload?.url) {
|
||||
errorMessage.value = createBonusProgramLinkMutation.error.value?.message || 'Не удалось сгенерировать ссылку.';
|
||||
return;
|
||||
}
|
||||
|
||||
bonusProgramLink.value = bonusLinkPayload.url;
|
||||
bonusProgramLinkExpiresAt.value = bonusLinkPayload.expiresAt;
|
||||
}
|
||||
|
||||
async function copyBonusProgramLink() {
|
||||
if (!bonusProgramLink.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
await navigator.clipboard.writeText(bonusProgramLink.value);
|
||||
}
|
||||
|
||||
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>
|
||||
</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"
|
||||
>
|
||||
<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]">
|
||||
{{ link.referrerName }} получает {{ link.bonusPercent }}% с заказов {{ link.refereeName }}
|
||||
</p>
|
||||
<p class="text-sm font-semibold text-[#123824]">Ссылка в бонусный кабинет</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() }}
|
||||
<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 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]"
|
||||
>
|
||||
Открыть
|
||||
</a>
|
||||
<button
|
||||
class="btn rounded-full border-0 bg-[#139957] px-5 text-white hover:bg-[#0d854a]"
|
||||
@click="copyBonusProgramLink"
|
||||
>
|
||||
Скопировать
|
||||
</button>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
@@ -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="Добавить бонусную транзакцию"
|
||||
/>
|
||||
|
||||
|
||||
394
app/pages/catalog-settings.vue
Normal 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>
|
||||
@@ -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>
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import CatalogConfigurator from '~/components/catalog/CatalogConfigurator.vue';
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<CatalogConfigurator />
|
||||
</template>
|
||||
11
app/pages/products/[slug].vue
Normal 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>
|
||||
7
app/pages/products/index.vue
Normal file
@@ -0,0 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import CatalogProductTypeList from '~/components/catalog/CatalogProductTypeList.vue';
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<CatalogProductTypeList />
|
||||
</template>
|
||||
@@ -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>
|
||||
|
||||
<div class="rounded-[20px] bg-[#f7fbf8] px-4 py-3 text-right">
|
||||
<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-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>
|
||||
|
||||
1
docs/public/diagrams/architecture-overview.svg
Normal file
|
After Width: | Height: | Size: 25 KiB |
1
docs/public/diagrams/component-map.svg
Normal file
|
After Width: | Height: | Size: 36 KiB |
1
docs/public/diagrams/infrastructure-topology.svg
Normal file
|
After Width: | Height: | Size: 44 KiB |
1
docs/public/prototypes/bonus-cabinet.svg
Normal file
|
After Width: | Height: | Size: 6.0 KiB |
1
docs/public/prototypes/bonus-manager.svg
Normal file
|
After Width: | Height: | Size: 6.1 KiB |
1
docs/public/prototypes/cart.svg
Normal file
|
After Width: | Height: | Size: 6.2 KiB |
1
docs/public/prototypes/catalog-grid.svg
Normal 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=""Times New Roman", 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=""Times New Roman", 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=""Times New Roman", 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=""Times New Roman", 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=""Times New Roman", Times, serif" font-size="13" font-weight="700" fill="#545454" text-anchor="middle">Профиль</text><text x="72" y="132" font-family=""Times New Roman", 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=""Times New Roman", 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=""Times New Roman", 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=""Times New Roman", 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=""Times New Roman", 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=""Times New Roman", 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=""Times New Roman", 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=""Times New Roman", Times, serif" font-size="22" font-weight="800" fill="#181818" text-anchor="start">Картон</text></svg>
|
||||
|
After Width: | Height: | Size: 4.6 KiB |
1
docs/public/prototypes/catalog-settings.svg
Normal file
|
After Width: | Height: | Size: 6.4 KiB |
1
docs/public/prototypes/client-card.svg
Normal file
|
After Width: | Height: | Size: 5.7 KiB |
1
docs/public/prototypes/client-list.svg
Normal file
|
After Width: | Height: | Size: 5.3 KiB |
1
docs/public/prototypes/client-order.svg
Normal file
|
After Width: | Height: | Size: 5.7 KiB |
1
docs/public/prototypes/dashboard.svg
Normal 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=""Times New Roman", 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=""Times New Roman", 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=""Times New Roman", 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=""Times New Roman", 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=""Times New Roman", Times, serif" font-size="13" font-weight="700" fill="#545454" text-anchor="middle">Профиль</text><text x="72" y="132" font-family=""Times New Roman", 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=""Times New Roman", 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=""Times New Roman", 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=""Times New Roman", 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=""Times New Roman", 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=""Times New Roman", 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=""Times New Roman", 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=""Times New Roman", Times, serif" font-size="22" font-weight="800" fill="#181818" text-anchor="start">Картон</text></svg>
|
||||
|
After Width: | Height: | Size: 4.6 KiB |
1
docs/public/prototypes/login.svg
Normal 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=""Times New Roman", 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=""Times New Roman", Times, serif" font-size="14" font-weight="800" fill="#545454" text-anchor="middle">Фрегат</text><text x="720" y="244" font-family=""Times New Roman", Times, serif" font-size="36" font-weight="800" fill="#181818" text-anchor="middle">Вход</text><text x="510" y="292" font-family=""Times New Roman", 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=""Times New Roman", 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=""Times New Roman", 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=""Times New Roman", 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=""Times New Roman", Times, serif" font-size="14" font-weight="700" fill="#545454" text-anchor="middle">Max</text></svg>
|
||||
|
After Width: | Height: | Size: 2.5 KiB |
1
docs/public/prototypes/manager-order.svg
Normal file
|
After Width: | Height: | Size: 5.8 KiB |
1
docs/public/prototypes/manager-orders.svg
Normal file
|
After Width: | Height: | Size: 7.0 KiB |
1
docs/public/prototypes/product-card.svg
Normal file
|
After Width: | Height: | Size: 11 KiB |
1
docs/public/prototypes/profile.svg
Normal 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=""Times New Roman", 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=""Times New Roman", 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=""Times New Roman", 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=""Times New Roman", 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=""Times New Roman", Times, serif" font-size="13" font-weight="700" fill="#ffffff" text-anchor="middle">Профиль</text><text x="72" y="132" font-family=""Times New Roman", 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=""Times New Roman", Times, serif" font-size="17" font-weight="800" fill="#181818" text-anchor="start">Карточка контрагента</text><text x="154" y="282" font-family=""Times New Roman", 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=""Times New Roman", Times, serif" font-size="17" font-weight="800" fill="#181818" text-anchor="start">Уведомления</text><text x="566" y="282" font-family=""Times New Roman", 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=""Times New Roman", Times, serif" font-size="17" font-weight="800" fill="#181818" text-anchor="start">Адреса доставки</text><text x="978" y="282" font-family=""Times New Roman", Times, serif" font-size="14" font-weight="500" fill="#545454" text-anchor="start">Список адресов</text></svg>
|
||||
|
After Width: | Height: | Size: 3.5 KiB |
1
docs/public/prototypes/sync-settings.svg
Normal file
|
After Width: | Height: | Size: 7.4 KiB |
35
docs/scripts/build-typst-tz.mjs
Normal 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`);
|
||||
493
docs/scripts/generate-prototypes.mjs
Normal 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('&', '&')
|
||||
.replaceAll('<', '<')
|
||||
.replaceAll('>', '>')
|
||||
.replaceAll('"', '"');
|
||||
}
|
||||
|
||||
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
@@ -10,6 +10,7 @@ query ClientProducts {
|
||||
thicknessMicron
|
||||
sleeveBrand
|
||||
quantityPerBox
|
||||
tags
|
||||
isCustomizable
|
||||
availableInWarehouses {
|
||||
availableQty
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
query CatalogProductTypeSettings {
|
||||
catalogProductTypeSettings {
|
||||
productType
|
||||
showQuantityPerBox
|
||||
allowCustomLength
|
||||
customLengthMinM
|
||||
customLengthMaxM
|
||||
customLengthStepM
|
||||
allowCustomSleeveBrand
|
||||
allowCustomLabel
|
||||
widthOptionsMm
|
||||
lengthOptionsM
|
||||
thicknessOptionsMicron
|
||||
sleeveOptions
|
||||
colorOptions
|
||||
labelOptions
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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!
|
||||
|
||||
10
package.json
@@ -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
9
pnpm-workspace.yaml
Normal file
@@ -0,0 +1,9 @@
|
||||
packages:
|
||||
- .
|
||||
|
||||
allowBuilds:
|
||||
"@parcel/watcher": true
|
||||
esbuild: true
|
||||
puppeteer: true
|
||||
unrs-resolver: true
|
||||
vue-demi: true
|
||||