Compare commits
68 Commits
722dbb89cb
...
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 | ||
|
|
86eee08d87 |
2
.gitignore
vendored
@@ -5,6 +5,8 @@
|
|||||||
.nitro
|
.nitro
|
||||||
.cache
|
.cache
|
||||||
dist
|
dist
|
||||||
|
docs/.vitepress/cache
|
||||||
|
docs/export
|
||||||
|
|
||||||
# Node dependencies
|
# Node dependencies
|
||||||
node_modules
|
node_modules
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ WORKDIR /app
|
|||||||
|
|
||||||
RUN corepack enable
|
RUN corepack enable
|
||||||
|
|
||||||
COPY package.json pnpm-lock.yaml ./
|
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
|
||||||
RUN pnpm install --frozen-lockfile
|
RUN pnpm install --frozen-lockfile
|
||||||
|
|
||||||
FROM deps AS build
|
FROM deps AS build
|
||||||
|
|||||||
10
app/app.vue
@@ -51,7 +51,7 @@ const managerPageTabs = computed(() => {
|
|||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
key: 'balances',
|
key: 'balances',
|
||||||
label: 'Балансы',
|
label: 'Бонусные счета',
|
||||||
active: route.path === '/admin/bonuses'
|
active: route.path === '/admin/bonuses'
|
||||||
|| route.path === '/admin/bonuses/balances'
|
|| route.path === '/admin/bonuses/balances'
|
||||||
|| route.path.startsWith('/admin/bonuses/balances/')
|
|| route.path.startsWith('/admin/bonuses/balances/')
|
||||||
@@ -83,6 +83,14 @@ const managerPageTabs = computed(() => {
|
|||||||
|
|
||||||
if (route.path.startsWith('/admin/settings')) {
|
if (route.path.startsWith('/admin/settings')) {
|
||||||
return [
|
return [
|
||||||
|
{
|
||||||
|
key: 'catalog',
|
||||||
|
label: 'Каталог',
|
||||||
|
active: route.path === '/admin/settings/catalog',
|
||||||
|
to: {
|
||||||
|
path: '/admin/settings/catalog',
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
key: 'messages',
|
key: 'messages',
|
||||||
label: 'Сообщения',
|
label: 'Сообщения',
|
||||||
|
|||||||
@@ -1,15 +1,28 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { useQuery } from '@vue/apollo-composable';
|
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';
|
import { useClientCart } from '~/composables/useClientCart';
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
productTypeSlug: string;
|
||||||
|
}>();
|
||||||
|
|
||||||
type ProductNode = ClientProductsQuery['clientProducts'][number];
|
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 ParamValue = number | string;
|
||||||
|
|
||||||
type ParsedProduct = ProductNode & {
|
type ParsedProduct = ProductNode & {
|
||||||
productTypeLabel: string;
|
productTypeLabel: string;
|
||||||
quantityPerBoxOptions: string[];
|
quantityPerBoxOptions: string[];
|
||||||
|
normalizedTags: string[];
|
||||||
|
colorTags: string[];
|
||||||
|
labelTags: string[];
|
||||||
};
|
};
|
||||||
|
|
||||||
type ProductGroup = {
|
type ProductGroup = {
|
||||||
@@ -24,16 +37,37 @@ type GroupState = {
|
|||||||
thicknessMicron: number | null;
|
thicknessMicron: number | null;
|
||||||
sleeveBrand: string | null;
|
sleeveBrand: string | null;
|
||||||
quantityPerBox: 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 }> = [
|
const parameterFields: Array<{ key: ParamFieldKey; label: string }> = [
|
||||||
{ key: 'widthMm', label: 'Ширина' },
|
{ key: 'widthMm', label: 'Ширина' },
|
||||||
{ key: 'lengthM', label: 'Длина' },
|
{ key: 'lengthM', label: 'Длина' },
|
||||||
{ key: 'thicknessMicron', label: 'Толщина' },
|
{ key: 'thicknessMicron', label: 'Толщина' },
|
||||||
{ key: 'sleeveBrand', label: 'Втулка' },
|
{ key: 'sleeveBrand', label: 'Втулка' },
|
||||||
{ key: 'quantityPerBox', label: 'Короб' },
|
{ key: 'quantityPerBox', label: 'Короб' },
|
||||||
|
{ key: 'colorTag', label: 'Цвет' },
|
||||||
|
{ key: 'labelTag', label: 'Надпись' },
|
||||||
];
|
];
|
||||||
|
|
||||||
const coverPresets = [
|
const coverPresets = [
|
||||||
@@ -42,11 +76,21 @@ const coverPresets = [
|
|||||||
['#e8f5ec', '#b2e0c6', '#7dd0a9'],
|
['#e8f5ec', '#b2e0c6', '#7dd0a9'],
|
||||||
];
|
];
|
||||||
|
|
||||||
const { result, loading, error } = useQuery(ClientProductsDocument);
|
const productsQuery = useQuery(ClientProductsDocument);
|
||||||
const search = ref('');
|
const catalogSettingsQuery = useQuery(CatalogProductTypeSettingsDocument);
|
||||||
const groupStates = reactive<Record<string, GroupState>>({});
|
const groupStates = reactive<Record<string, GroupState>>({});
|
||||||
const { addProduct, getQuantity, incrementQuantity, decrementQuantity } = useClientCart();
|
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) {
|
function normalizeText(value: string | null | undefined) {
|
||||||
return String(value ?? '').replaceAll(/\s+/g, ' ').trim();
|
return String(value ?? '').replaceAll(/\s+/g, ' ').trim();
|
||||||
}
|
}
|
||||||
@@ -85,10 +129,15 @@ function createProductCover(name: string, sku: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function hydrateProduct(product: ProductNode): ParsedProduct {
|
function hydrateProduct(product: ProductNode): ParsedProduct {
|
||||||
|
const normalizedTags = product.tags.map((tag) => normalizeText(tag)).filter(Boolean).sort((a, b) => a.localeCompare(b, 'ru'));
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...product,
|
...product,
|
||||||
productTypeLabel: normalizeText(product.productType) || 'Без типа',
|
productTypeLabel: normalizeText(product.productType) || 'Без типа',
|
||||||
quantityPerBoxOptions: splitBoxValues(product.quantityPerBox),
|
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 parsedProducts = computed<ParsedProduct[]>(() => {
|
||||||
const list = result.value?.clientProducts ?? [];
|
const list = productsQuery.result.value?.clientProducts ?? [];
|
||||||
const query = search.value.trim().toLowerCase();
|
|
||||||
|
|
||||||
return list
|
return list
|
||||||
.map(hydrateProduct)
|
.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);
|
.sort(compareProducts);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -160,7 +192,11 @@ const productGroups = computed<ProductGroup[]>(() => {
|
|||||||
return [...map.entries()]
|
return [...map.entries()]
|
||||||
.sort((a, b) => a[0].localeCompare(b[0], 'ru'))
|
.sort((a, b) => a[0].localeCompare(b[0], 'ru'))
|
||||||
.map(([typeLabel, products]) => ({
|
.map(([typeLabel, products]) => ({
|
||||||
key: typeLabel.toLowerCase().replaceAll(/\s+/g, '-'),
|
key: typeLabel
|
||||||
|
.toLowerCase()
|
||||||
|
.replaceAll(/[^a-z0-9а-яё]+/gi, '-')
|
||||||
|
.replaceAll(/-+/g, '-')
|
||||||
|
.replaceAll(/^-|-$/g, ''),
|
||||||
typeLabel,
|
typeLabel,
|
||||||
products: [...products].sort(compareProducts),
|
products: [...products].sort(compareProducts),
|
||||||
}));
|
}));
|
||||||
@@ -186,6 +222,20 @@ function getAllFieldOptions(group: ProductGroup, field: ParamFieldKey) {
|
|||||||
continue;
|
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];
|
const value = product[field];
|
||||||
if (value !== null && value !== undefined) {
|
if (value !== null && value !== undefined) {
|
||||||
values.add(value);
|
values.add(value);
|
||||||
@@ -195,26 +245,40 @@ function getAllFieldOptions(group: ProductGroup, field: ParamFieldKey) {
|
|||||||
return sortParamValues([...values]);
|
return sortParamValues([...values]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function groupCatalogSetting(group: ProductGroup) {
|
||||||
|
return catalogSettingsByType.value[group.typeLabel] ?? {
|
||||||
|
...DEFAULT_CATALOG_PRODUCT_TYPE_SETTING,
|
||||||
|
productType: group.typeLabel,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function visibleFields(group: ProductGroup) {
|
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) {
|
return getAllFieldOptions(group, field.key).length > 1;
|
||||||
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 };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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) {
|
function requiredKeys(group: ProductGroup) {
|
||||||
return visibleFields(group).map((field) => field.key);
|
return visibleFields(group).map((field) => field.key);
|
||||||
}
|
}
|
||||||
@@ -228,7 +292,8 @@ function createGroupState(group: ProductGroup): GroupState {
|
|||||||
thicknessMicron: firstProduct?.thicknessMicron ?? null,
|
thicknessMicron: firstProduct?.thicknessMicron ?? null,
|
||||||
sleeveBrand: firstProduct?.sleeveBrand ?? null,
|
sleeveBrand: firstProduct?.sleeveBrand ?? null,
|
||||||
quantityPerBox: firstProduct?.quantityPerBoxOptions[0] ?? 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]));
|
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];
|
return product[key] === state[key];
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -274,7 +347,7 @@ function selectedProduct(group: ProductGroup) {
|
|||||||
const state = getGroupState(group);
|
const state = getGroupState(group);
|
||||||
|
|
||||||
if (keys.length === 0) {
|
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)) {
|
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));
|
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) {
|
function formatOptionLabel(field: ParamFieldKey, value: ParamValue) {
|
||||||
@@ -303,6 +376,14 @@ function productHasOption(product: ParsedProduct, field: ParamFieldKey, option:
|
|||||||
return product.quantityPerBoxOptions.includes(String(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;
|
return product[field] === option;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -359,6 +440,8 @@ function applyProductToState(state: GroupState, product: ParsedProduct, preferre
|
|||||||
state.lengthM = product.lengthM ?? null;
|
state.lengthM = product.lengthM ?? null;
|
||||||
state.thicknessMicron = product.thicknessMicron ?? null;
|
state.thicknessMicron = product.thicknessMicron ?? null;
|
||||||
state.sleeveBrand = product.sleeveBrand ?? 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))) {
|
if (preferredBoxOption !== null && product.quantityPerBoxOptions.includes(String(preferredBoxOption))) {
|
||||||
state.quantityPerBox = String(preferredBoxOption);
|
state.quantityPerBox = String(preferredBoxOption);
|
||||||
@@ -389,8 +472,112 @@ function articleLabel(group: ProductGroup) {
|
|||||||
return selectedProduct(group)?.sku ?? '—';
|
return selectedProduct(group)?.sku ?? '—';
|
||||||
}
|
}
|
||||||
|
|
||||||
function toggleExpanded(group: ProductGroup) {
|
function formatLengthRange(setting: CatalogProductTypeSettingNode) {
|
||||||
getGroupState(group).isExpanded = !getGroupState(group).isExpanded;
|
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) {
|
function incrementProduct(product: ProductNode) {
|
||||||
@@ -429,222 +616,259 @@ function decrementSelected(group: ProductGroup) {
|
|||||||
decrementProduct(product.id);
|
decrementProduct(product.id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function productDetailPath(group: ProductGroup) {
|
||||||
|
return `/products/${group.key}`;
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<section class="space-y-5">
|
<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-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="error" class="alert alert-error">{{ error.message }}</div>
|
||||||
|
<div v-else-if="selectedGroup" class="relative pb-10">
|
||||||
<div v-else-if="productGroups.length" class="space-y-4">
|
<NuxtLink
|
||||||
<article
|
v-if="previousGroup"
|
||||||
v-for="group in productGroups"
|
:to="productDetailPath(previousGroup)"
|
||||||
:key="group.key"
|
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"
|
||||||
class="surface-card rounded-3xl p-4 md:p-5"
|
|
||||||
>
|
>
|
||||||
<div class="grid gap-4 xl:grid-cols-6 xl:items-start">
|
|
||||||
<div class="p-3 xl:col-span-1">
|
|
||||||
<img
|
<img
|
||||||
:src="createProductCover(group.typeLabel, group.key)"
|
:src="createProductCover(previousGroup.typeLabel, previousGroup.key)"
|
||||||
:alt="`Превью группы ${group.typeLabel}`"
|
:alt="`Перейти к товару ${previousGroup.typeLabel}`"
|
||||||
class="aspect-square w-full rounded-[24px] object-cover"
|
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"
|
loading="lazy"
|
||||||
>
|
>
|
||||||
</div>
|
</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
|
<div
|
||||||
v-for="field in visibleFieldsByColumn(group).leftColumn"
|
v-if="customizationDetails(selectedGroup).length"
|
||||||
:key="`${group.key}-${field.key}`"
|
class="rounded-[28px] border border-[#dce9e1] bg-[#f7fbf8] p-4"
|
||||||
class="border-b border-base-200 pb-4 last:border-b-0 last:pb-0"
|
|
||||||
>
|
>
|
||||||
<p class="text-sm font-semibold text-[#163624]">{{ field.label }}</p>
|
<div class="space-y-2">
|
||||||
|
<p
|
||||||
<div class="mt-3 flex flex-wrap gap-2">
|
v-for="note in customizationDetails(selectedGroup)"
|
||||||
<label
|
:key="`${selectedGroup.key}-${note}`"
|
||||||
v-for="option in getAllFieldOptions(group, field.key)"
|
class="text-sm leading-6 text-[#456555]"
|
||||||
:key="`${group.key}-${field.key}-${option}`"
|
|
||||||
class="btn btn-sm rounded-full text-sm normal-case shadow-none transition"
|
|
||||||
:class="[
|
|
||||||
getGroupState(group)[field.key] === option
|
|
||||||
? 'bg-neutral text-neutral-content'
|
|
||||||
: isOptionAvailable(group, field.key, option)
|
|
||||||
? 'bg-base-100 text-base-content hover:bg-base-200'
|
|
||||||
: 'bg-[#eef1f4] text-[#7b8591] hover:bg-[#e3e7eb]',
|
|
||||||
'cursor-pointer',
|
|
||||||
]"
|
|
||||||
>
|
>
|
||||||
<input
|
{{ note }}
|
||||||
type="radio"
|
</p>
|
||||||
class="sr-only"
|
|
||||||
:name="`${group.key}-${field.key}`"
|
|
||||||
:checked="getGroupState(group)[field.key] === option"
|
|
||||||
@change="updateField(group, field.key, option)"
|
|
||||||
>
|
|
||||||
<span>{{ formatOptionLabel(field.key, option) }}</span>
|
|
||||||
</label>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="space-y-4">
|
<div class="space-y-4">
|
||||||
<div
|
<article
|
||||||
v-for="field in visibleFieldsByColumn(group).rightColumn"
|
v-for="field in visibleFields(selectedGroup)"
|
||||||
:key="`${group.key}-${field.key}`"
|
:key="`${selectedGroup.key}-${field.key}`"
|
||||||
class="border-b border-base-200 pb-4 last:border-b-0 last:pb-0"
|
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="flex flex-wrap gap-2">
|
||||||
|
|
||||||
<div class="mt-3 flex flex-wrap gap-2">
|
|
||||||
<label
|
<label
|
||||||
v-for="option in getAllFieldOptions(group, field.key)"
|
v-for="option in getAllFieldOptions(selectedGroup, field.key)"
|
||||||
:key="`${group.key}-${field.key}-${option}`"
|
:key="`${selectedGroup.key}-${field.key}-${option}`"
|
||||||
class="btn btn-sm rounded-full text-sm normal-case shadow-none transition"
|
class="cursor-pointer rounded-2xl border px-4 py-2 text-sm font-medium transition"
|
||||||
:class="[
|
:class="[
|
||||||
getGroupState(group)[field.key] === option
|
getGroupState(selectedGroup)[field.key] === option
|
||||||
? 'bg-neutral text-neutral-content'
|
? 'border-[#163624] bg-[#163624] text-white'
|
||||||
: isOptionAvailable(group, field.key, option)
|
: isOptionAvailable(selectedGroup, field.key, option)
|
||||||
? 'bg-base-100 text-base-content hover:bg-base-200'
|
? 'border-[#dce9e1] bg-white text-[#163624] hover:border-[#163624]'
|
||||||
: 'bg-[#eef1f4] text-[#7b8591] hover:bg-[#e3e7eb]',
|
: 'border-[#e6eaee] bg-[#f3f5f7] text-[#8a949d]',
|
||||||
'cursor-pointer',
|
|
||||||
]"
|
]"
|
||||||
>
|
>
|
||||||
<input
|
<input
|
||||||
type="radio"
|
type="radio"
|
||||||
class="sr-only"
|
class="sr-only"
|
||||||
:name="`${group.key}-${field.key}`"
|
:name="`${selectedGroup.key}-${field.key}`"
|
||||||
:checked="getGroupState(group)[field.key] === option"
|
:checked="getGroupState(selectedGroup)[field.key] === option"
|
||||||
@change="updateField(group, field.key, option)"
|
@change="updateField(selectedGroup, field.key, option)"
|
||||||
>
|
>
|
||||||
<span>{{ formatOptionLabel(field.key, option) }}</span>
|
<span>{{ formatOptionLabel(field.key, option) }}</span>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
</div>
|
<details class="mt-3 rounded-[20px] bg-[#f7fbf8] px-4 py-3 text-sm text-[#587064]">
|
||||||
</div>
|
<summary class="cursor-pointer font-medium text-[#355947]">Подробнее</summary>
|
||||||
|
<p class="mt-2 leading-6">{{ fieldHelperText(selectedGroup, field.key) }}</p>
|
||||||
|
</details>
|
||||||
|
</article>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<aside class="p-4 md:p-5 xl:col-span-1">
|
<aside class="self-start xl:sticky xl:top-24">
|
||||||
<div class="flex h-full flex-col justify-between gap-4">
|
<div class="rounded-[30px] border border-[#e6efe9] bg-white p-5 shadow-[0_24px_48px_rgba(18,56,36,0.08)]">
|
||||||
<div />
|
<p class="mt-1 text-lg font-medium leading-tight text-[#163624]">{{ articleLabel(selectedGroup) }}</p>
|
||||||
|
|
||||||
<div class="space-y-3">
|
|
||||||
<button
|
<button
|
||||||
v-if="selectedQty(group) === 0"
|
v-if="selectedQty(selectedGroup) === 0"
|
||||||
class="btn h-11 w-full rounded-full border-0 bg-[#139957] text-sm font-semibold text-white hover:bg-[#0d854a]"
|
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(group)"
|
:disabled="!selectedProduct(selectedGroup)"
|
||||||
@click="incrementSelected(group)"
|
@click="incrementSelected(selectedGroup)"
|
||||||
>
|
>
|
||||||
В корзину
|
В корзину
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<div v-else class="rounded-[22px] border border-base-300 bg-base-100 px-2 py-1">
|
<div
|
||||||
<div class="flex items-center justify-between gap-2">
|
v-else
|
||||||
|
class="mt-4 flex items-center justify-between rounded-[24px] border border-[#dce9e1] bg-[#f8fbf9] px-2 py-2"
|
||||||
|
>
|
||||||
<button
|
<button
|
||||||
class="btn btn-square btn-sm"
|
class="btn btn-square border-0 bg-white text-[#163624] shadow-none hover:bg-white"
|
||||||
:disabled="selectedQty(group) === 0"
|
:disabled="selectedQty(selectedGroup) === 0"
|
||||||
@click="decrementSelected(group)"
|
@click="decrementSelected(selectedGroup)"
|
||||||
>
|
>
|
||||||
-
|
-
|
||||||
</button>
|
</button>
|
||||||
<div class="min-w-10 text-center text-lg font-semibold text-[#163624]">{{ selectedQty(group) }}</div>
|
<div class="min-w-12 text-center text-lg font-semibold text-[#163624]">{{ selectedQty(selectedGroup) }}</div>
|
||||||
<button class="btn btn-square btn-sm" :disabled="!selectedProduct(group)" @click="incrementSelected(group)">
|
<button
|
||||||
|
class="btn btn-square border-0 bg-white text-[#163624] shadow-none hover:bg-white"
|
||||||
|
:disabled="!selectedProduct(selectedGroup)"
|
||||||
|
@click="incrementSelected(selectedGroup)"
|
||||||
|
>
|
||||||
+
|
+
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p class="text-center text-sm font-medium text-base-content/55">{{ articleLabel(group) }}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</aside>
|
</aside>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div class="mt-8">
|
||||||
v-if="getGroupState(group).isExpanded"
|
<p class="mb-4 text-base font-semibold text-[#163624]">Доступные варианты</p>
|
||||||
class="mt-4 overflow-x-auto rounded-[28px] bg-white"
|
|
||||||
>
|
<div class="overflow-x-auto rounded-[24px] border border-[#edf4ef] 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">
|
<table class="table bg-white">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr class="text-[#587064]">
|
||||||
<th class="border-b border-base-300">Артикул</th>
|
<th>SKU</th>
|
||||||
<th class="border-b border-base-300">Ширина</th>
|
<th>Ширина</th>
|
||||||
<th class="border-b border-base-300">Длина</th>
|
<th>Длина</th>
|
||||||
<th class="border-b border-base-300">Толщина</th>
|
<th>Толщина</th>
|
||||||
<th class="border-b border-base-300">Втулка</th>
|
<th>Втулка</th>
|
||||||
<th class="border-b border-base-300">Короб</th>
|
<th>Цвет</th>
|
||||||
<th class="border-b border-base-300 text-right">Действие</th>
|
<th>Надпись</th>
|
||||||
|
<th>Остаток</th>
|
||||||
|
<th class="text-right">Действие</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr v-for="product in group.products" :key="`${group.key}-${product.id}`">
|
<tr v-for="product in selectedGroup.products" :key="`${selectedGroup.key}-${product.id}`" class="align-middle">
|
||||||
<td class="border-b border-base-200">{{ product.sku }}</td>
|
<td class="font-semibold text-[#163624]">{{ product.sku }}</td>
|
||||||
<td class="border-b border-base-200">{{ product.widthMm ?? '—' }}</td>
|
<td>{{ product.widthMm ?? '—' }}</td>
|
||||||
<td class="border-b border-base-200">{{ product.lengthM ?? '—' }}</td>
|
<td>{{ product.lengthM ?? '—' }}</td>
|
||||||
<td class="border-b border-base-200">{{ product.thicknessMicron ?? '—' }}</td>
|
<td>{{ product.thicknessMicron ?? '—' }}</td>
|
||||||
<td class="border-b border-base-200">{{ product.sleeveBrand ?? '—' }}</td>
|
<td>{{ product.sleeveBrand ?? '—' }}</td>
|
||||||
<td class="border-b border-base-200">{{ product.quantityPerBox ?? '—' }}</td>
|
<td>{{ product.colorTags.join(', ') || '—' }}</td>
|
||||||
<td class="border-b border-base-200 text-right">
|
<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
|
<button
|
||||||
v-if="getQuantity(product.id) === 0"
|
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)"
|
@click="incrementProduct(product)"
|
||||||
>
|
>
|
||||||
В корзину
|
В корзину
|
||||||
</button>
|
</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
|
<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"
|
:disabled="getQuantity(product.id) === 0"
|
||||||
@click="decrementProduct(product.id)"
|
@click="decrementProduct(product.id)"
|
||||||
>
|
>
|
||||||
-
|
-
|
||||||
</button>
|
</button>
|
||||||
<span class="min-w-8 text-center text-sm font-semibold">{{ getQuantity(product.id) }}</span>
|
<span class="min-w-8 text-center text-sm font-semibold text-[#163624]">{{ getQuantity(product.id) }}</span>
|
||||||
<button class="btn btn-xs btn-square" @click="incrementProduct(product)">+</button>
|
<button
|
||||||
|
class="btn btn-xs btn-square border-0 bg-[#f3f7f4] text-[#163624] shadow-none hover:bg-[#eaf2ec]"
|
||||||
|
@click="incrementProduct(product)"
|
||||||
|
>
|
||||||
|
+
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button
|
|
||||||
class="btn btn-ghost mt-3 w-full justify-center gap-2"
|
|
||||||
@click="toggleExpanded(group)"
|
|
||||||
>
|
|
||||||
<svg
|
|
||||||
class="h-4 w-4 transition-transform"
|
|
||||||
:class="{ 'rotate-180': getGroupState(group).isExpanded }"
|
|
||||||
viewBox="0 0 20 20"
|
|
||||||
fill="none"
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
d="M5 7.5L10 12.5L15 7.5"
|
|
||||||
stroke="currentColor"
|
|
||||||
stroke-width="1.8"
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
<span>{{ getGroupState(group).isExpanded ? 'Свернуть все варианты' : 'Развернуть все варианты' }}</span>
|
|
||||||
</button>
|
|
||||||
</article>
|
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
<div v-else class="alert surface-card border-0">По текущему запросу товары не найдены.</div>
|
<div v-else class="alert surface-card border-0">Такой тип товара не найден.</div>
|
||||||
</section>
|
</section>
|
||||||
</template>
|
</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>
|
||||||
@@ -47,6 +47,14 @@ export type AuthSession = {
|
|||||||
user: User;
|
user: User;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type BonusProgramLink = {
|
||||||
|
__typename?: 'BonusProgramLink';
|
||||||
|
expiresAt: Scalars['DateTime']['output'];
|
||||||
|
token: Scalars['String']['output'];
|
||||||
|
url: Scalars['String']['output'];
|
||||||
|
userId: Scalars['ID']['output'];
|
||||||
|
};
|
||||||
|
|
||||||
export type BonusTransaction = {
|
export type BonusTransaction = {
|
||||||
__typename?: 'BonusTransaction';
|
__typename?: 'BonusTransaction';
|
||||||
amount: Scalars['Float']['output'];
|
amount: Scalars['Float']['output'];
|
||||||
@@ -81,6 +89,24 @@ export type CartItem = {
|
|||||||
updatedAt: Scalars['DateTime']['output'];
|
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 = {
|
export type Company = {
|
||||||
__typename?: 'Company';
|
__typename?: 'Company';
|
||||||
id: Scalars['ID']['output'];
|
id: Scalars['ID']['output'];
|
||||||
@@ -300,6 +326,7 @@ export type Mutation = {
|
|||||||
clientReviewOrder: Order;
|
clientReviewOrder: Order;
|
||||||
connectMessenger: MessengerConnection;
|
connectMessenger: MessengerConnection;
|
||||||
consumeLoginToken: AuthSession;
|
consumeLoginToken: AuthSession;
|
||||||
|
createBonusProgramLink: BonusProgramLink;
|
||||||
createInvitation: Invitation;
|
createInvitation: Invitation;
|
||||||
createMyDeliveryAddress: DeliveryAddress;
|
createMyDeliveryAddress: DeliveryAddress;
|
||||||
createReferral: ReferralLink;
|
createReferral: ReferralLink;
|
||||||
@@ -319,6 +346,7 @@ export type Mutation = {
|
|||||||
submitCalculationOrder: Order;
|
submitCalculationOrder: Order;
|
||||||
submitReadyOrder: Order;
|
submitReadyOrder: Order;
|
||||||
updateCartItemQuantity: Cart;
|
updateCartItemQuantity: Cart;
|
||||||
|
upsertCatalogProductTypeSetting: CatalogProductTypeSetting;
|
||||||
upsertMyCounterpartyProfile: CounterpartyProfile;
|
upsertMyCounterpartyProfile: CounterpartyProfile;
|
||||||
verifyLoginCode: AuthSession;
|
verifyLoginCode: AuthSession;
|
||||||
};
|
};
|
||||||
@@ -355,6 +383,11 @@ export type MutationConsumeLoginTokenArgs = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
|
export type MutationCreateBonusProgramLinkArgs = {
|
||||||
|
userId: Scalars['ID']['input'];
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
export type MutationCreateInvitationArgs = {
|
export type MutationCreateInvitationArgs = {
|
||||||
input: CreateInvitationInput;
|
input: CreateInvitationInput;
|
||||||
};
|
};
|
||||||
@@ -453,6 +486,11 @@ export type MutationUpdateCartItemQuantityArgs = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
|
export type MutationUpsertCatalogProductTypeSettingArgs = {
|
||||||
|
input: UpsertCatalogProductTypeSettingInput;
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
export type MutationUpsertMyCounterpartyProfileArgs = {
|
export type MutationUpsertMyCounterpartyProfileArgs = {
|
||||||
input: UpsertMyCounterpartyProfileInput;
|
input: UpsertMyCounterpartyProfileInput;
|
||||||
};
|
};
|
||||||
@@ -565,6 +603,7 @@ export type Product = {
|
|||||||
quantityPerBox?: Maybe<Scalars['String']['output']>;
|
quantityPerBox?: Maybe<Scalars['String']['output']>;
|
||||||
sku: Scalars['String']['output'];
|
sku: Scalars['String']['output'];
|
||||||
sleeveBrand?: Maybe<Scalars['String']['output']>;
|
sleeveBrand?: Maybe<Scalars['String']['output']>;
|
||||||
|
tags: Array<Scalars['String']['output']>;
|
||||||
thicknessMicron?: Maybe<Scalars['Int']['output']>;
|
thicknessMicron?: Maybe<Scalars['Int']['output']>;
|
||||||
widthMm?: Maybe<Scalars['Int']['output']>;
|
widthMm?: Maybe<Scalars['Int']['output']>;
|
||||||
};
|
};
|
||||||
@@ -577,6 +616,7 @@ export type ProductWarehouseBalance = {
|
|||||||
|
|
||||||
export type Query = {
|
export type Query = {
|
||||||
__typename?: 'Query';
|
__typename?: 'Query';
|
||||||
|
catalogProductTypeSettings: Array<CatalogProductTypeSetting>;
|
||||||
clientProducts: Array<Product>;
|
clientProducts: Array<Product>;
|
||||||
healthcheck: Scalars['String']['output'];
|
healthcheck: Scalars['String']['output'];
|
||||||
integrationSyncDashboard: IntegrationSyncDashboard;
|
integrationSyncDashboard: IntegrationSyncDashboard;
|
||||||
@@ -748,6 +788,23 @@ export type UpdateCartItemQuantityInput = {
|
|||||||
quantity: Scalars['Float']['input'];
|
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 = {
|
export type UpsertMyCounterpartyProfileInput = {
|
||||||
bankName: Scalars['String']['input'];
|
bankName: Scalars['String']['input'];
|
||||||
bik: Scalars['String']['input'];
|
bik: Scalars['String']['input'];
|
||||||
@@ -830,6 +887,13 @@ export type VerifyLoginCodeMutationVariables = Exact<{
|
|||||||
|
|
||||||
export type VerifyLoginCodeMutation = { __typename?: 'Mutation', verifyLoginCode: { __typename?: 'AuthSession', accessToken: string, expiresAt: any, user: { __typename?: 'User', id: string, email: string, fullName: string, role: UserRole, company?: { __typename?: 'Company', id: string } | null } } };
|
export type VerifyLoginCodeMutation = { __typename?: 'Mutation', verifyLoginCode: { __typename?: 'AuthSession', accessToken: string, expiresAt: any, user: { __typename?: 'User', id: string, email: string, fullName: string, role: UserRole, company?: { __typename?: 'Company', id: string } | null } } };
|
||||||
|
|
||||||
|
export type CreateBonusProgramLinkMutationVariables = Exact<{
|
||||||
|
userId: Scalars['ID']['input'];
|
||||||
|
}>;
|
||||||
|
|
||||||
|
|
||||||
|
export type CreateBonusProgramLinkMutation = { __typename?: 'Mutation', createBonusProgramLink: { __typename?: 'BonusProgramLink', userId: string, token: string, url: string, expiresAt: any } };
|
||||||
|
|
||||||
export type RequestRewardWithdrawalMutationVariables = Exact<{
|
export type RequestRewardWithdrawalMutationVariables = Exact<{
|
||||||
input: RequestRewardWithdrawalInput;
|
input: RequestRewardWithdrawalInput;
|
||||||
}>;
|
}>;
|
||||||
@@ -878,7 +942,7 @@ export type UpdateCartItemQuantityMutation = { __typename?: 'Mutation', updateCa
|
|||||||
export type ClientProductsQueryVariables = Exact<{ [key: string]: never; }>;
|
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<{
|
export type AddBonusTransactionMutationVariables = Exact<{
|
||||||
input: AddBonusTransactionInput;
|
input: AddBonusTransactionInput;
|
||||||
@@ -1103,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 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 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 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`
|
export const ConsumeLoginTokenDocument = gql`
|
||||||
mutation ConsumeLoginToken($token: String!) {
|
mutation ConsumeLoginToken($token: String!) {
|
||||||
@@ -1283,6 +1359,38 @@ export function useVerifyLoginCodeMutation(options: VueApolloComposable.UseMutat
|
|||||||
return VueApolloComposable.useMutation<VerifyLoginCodeMutation, VerifyLoginCodeMutationVariables>(VerifyLoginCodeDocument, options);
|
return VueApolloComposable.useMutation<VerifyLoginCodeMutation, VerifyLoginCodeMutationVariables>(VerifyLoginCodeDocument, options);
|
||||||
}
|
}
|
||||||
export type VerifyLoginCodeMutationCompositionFunctionResult = VueApolloComposable.UseMutationReturn<VerifyLoginCodeMutation, VerifyLoginCodeMutationVariables>;
|
export type VerifyLoginCodeMutationCompositionFunctionResult = VueApolloComposable.UseMutationReturn<VerifyLoginCodeMutation, VerifyLoginCodeMutationVariables>;
|
||||||
|
export const CreateBonusProgramLinkDocument = gql`
|
||||||
|
mutation CreateBonusProgramLink($userId: ID!) {
|
||||||
|
createBonusProgramLink(userId: $userId) {
|
||||||
|
userId
|
||||||
|
token
|
||||||
|
url
|
||||||
|
expiresAt
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* __useCreateBonusProgramLinkMutation__
|
||||||
|
*
|
||||||
|
* To run a mutation, you first call `useCreateBonusProgramLinkMutation` within a Vue component and pass it any options that fit your needs.
|
||||||
|
* When your component renders, `useCreateBonusProgramLinkMutation` 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 } = useCreateBonusProgramLinkMutation({
|
||||||
|
* variables: {
|
||||||
|
* userId: // value for 'userId'
|
||||||
|
* },
|
||||||
|
* });
|
||||||
|
*/
|
||||||
|
export function useCreateBonusProgramLinkMutation(options: VueApolloComposable.UseMutationOptions<CreateBonusProgramLinkMutation, CreateBonusProgramLinkMutationVariables> | ReactiveFunction<VueApolloComposable.UseMutationOptions<CreateBonusProgramLinkMutation, CreateBonusProgramLinkMutationVariables>> = {}) {
|
||||||
|
return VueApolloComposable.useMutation<CreateBonusProgramLinkMutation, CreateBonusProgramLinkMutationVariables>(CreateBonusProgramLinkDocument, options);
|
||||||
|
}
|
||||||
|
export type CreateBonusProgramLinkMutationCompositionFunctionResult = VueApolloComposable.UseMutationReturn<CreateBonusProgramLinkMutation, CreateBonusProgramLinkMutationVariables>;
|
||||||
export const RequestRewardWithdrawalDocument = gql`
|
export const RequestRewardWithdrawalDocument = gql`
|
||||||
mutation RequestRewardWithdrawal($input: RequestRewardWithdrawalInput!) {
|
mutation RequestRewardWithdrawal($input: RequestRewardWithdrawalInput!) {
|
||||||
requestRewardWithdrawal(input: $input) {
|
requestRewardWithdrawal(input: $input) {
|
||||||
@@ -1576,6 +1684,7 @@ export const ClientProductsDocument = gql`
|
|||||||
thicknessMicron
|
thicknessMicron
|
||||||
sleeveBrand
|
sleeveBrand
|
||||||
quantityPerBox
|
quantityPerBox
|
||||||
|
tags
|
||||||
isCustomizable
|
isCustomizable
|
||||||
availableInWarehouses {
|
availableInWarehouses {
|
||||||
availableQty
|
availableQty
|
||||||
@@ -2872,6 +2981,46 @@ export function useUpsertMyCounterpartyProfileMutation(options: VueApolloComposa
|
|||||||
return VueApolloComposable.useMutation<UpsertMyCounterpartyProfileMutation, UpsertMyCounterpartyProfileMutationVariables>(UpsertMyCounterpartyProfileDocument, options);
|
return VueApolloComposable.useMutation<UpsertMyCounterpartyProfileMutation, UpsertMyCounterpartyProfileMutationVariables>(UpsertMyCounterpartyProfileDocument, options);
|
||||||
}
|
}
|
||||||
export type UpsertMyCounterpartyProfileMutationCompositionFunctionResult = VueApolloComposable.UseMutationReturn<UpsertMyCounterpartyProfileMutation, UpsertMyCounterpartyProfileMutationVariables>;
|
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`
|
export const IntegrationSyncDashboardDocument = gql`
|
||||||
query IntegrationSyncDashboard {
|
query IntegrationSyncDashboard {
|
||||||
integrationSyncDashboard {
|
integrationSyncDashboard {
|
||||||
@@ -2913,3 +3062,45 @@ export function useIntegrationSyncDashboardLazyQuery(options: VueApolloComposabl
|
|||||||
return VueApolloComposable.useLazyQuery<IntegrationSyncDashboardQuery, IntegrationSyncDashboardQueryVariables>(IntegrationSyncDashboardDocument, {}, options);
|
return VueApolloComposable.useLazyQuery<IntegrationSyncDashboardQuery, IntegrationSyncDashboardQueryVariables>(IntegrationSyncDashboardDocument, {}, options);
|
||||||
}
|
}
|
||||||
export type IntegrationSyncDashboardQueryCompositionFunctionResult = VueApolloComposable.UseQueryReturn<IntegrationSyncDashboardQuery, IntegrationSyncDashboardQueryVariables>;
|
export type IntegrationSyncDashboardQueryCompositionFunctionResult = VueApolloComposable.UseQueryReturn<IntegrationSyncDashboardQuery, IntegrationSyncDashboardQueryVariables>;
|
||||||
|
export const UpsertCatalogProductTypeSettingDocument = gql`
|
||||||
|
mutation UpsertCatalogProductTypeSetting($input: UpsertCatalogProductTypeSettingInput!) {
|
||||||
|
upsertCatalogProductTypeSetting(input: $input) {
|
||||||
|
productType
|
||||||
|
showQuantityPerBox
|
||||||
|
allowCustomLength
|
||||||
|
customLengthMinM
|
||||||
|
customLengthMaxM
|
||||||
|
customLengthStepM
|
||||||
|
allowCustomSleeveBrand
|
||||||
|
allowCustomLabel
|
||||||
|
widthOptionsMm
|
||||||
|
lengthOptionsM
|
||||||
|
thicknessOptionsMicron
|
||||||
|
sleeveOptions
|
||||||
|
colorOptions
|
||||||
|
labelOptions
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* __useUpsertCatalogProductTypeSettingMutation__
|
||||||
|
*
|
||||||
|
* To run a mutation, you first call `useUpsertCatalogProductTypeSettingMutation` within a Vue component and pass it any options that fit your needs.
|
||||||
|
* When your component renders, `useUpsertCatalogProductTypeSettingMutation` returns an object that includes:
|
||||||
|
* - A mutate function that you can call at any time to execute the mutation
|
||||||
|
* - Several other properties: https://v4.apollo.vuejs.org/api/use-mutation.html#return
|
||||||
|
*
|
||||||
|
* @param options that will be passed into the mutation, supported options are listed on: https://v4.apollo.vuejs.org/guide-composable/mutation.html#options;
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* const { mutate, loading, error, onDone } = useUpsertCatalogProductTypeSettingMutation({
|
||||||
|
* variables: {
|
||||||
|
* input: // value for 'input'
|
||||||
|
* },
|
||||||
|
* });
|
||||||
|
*/
|
||||||
|
export function useUpsertCatalogProductTypeSettingMutation(options: VueApolloComposable.UseMutationOptions<UpsertCatalogProductTypeSettingMutation, UpsertCatalogProductTypeSettingMutationVariables> | ReactiveFunction<VueApolloComposable.UseMutationOptions<UpsertCatalogProductTypeSettingMutation, UpsertCatalogProductTypeSettingMutationVariables>> = {}) {
|
||||||
|
return VueApolloComposable.useMutation<UpsertCatalogProductTypeSettingMutation, UpsertCatalogProductTypeSettingMutationVariables>(UpsertCatalogProductTypeSettingDocument, options);
|
||||||
|
}
|
||||||
|
export type UpsertCatalogProductTypeSettingMutationCompositionFunctionResult = VueApolloComposable.UseMutationReturn<UpsertCatalogProductTypeSettingMutation, UpsertCatalogProductTypeSettingMutationVariables>;
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { useQuery } from '@vue/apollo-composable';
|
import { useMutation, useQuery } from '@vue/apollo-composable';
|
||||||
import {
|
import {
|
||||||
|
CreateBonusProgramLinkDocument,
|
||||||
ManagerBonusAccountDocument,
|
ManagerBonusAccountDocument,
|
||||||
type ManagerBonusAccountQuery,
|
type ManagerBonusAccountQuery,
|
||||||
} from '~/composables/graphql/generated';
|
} from '~/composables/graphql/generated';
|
||||||
@@ -16,6 +17,10 @@ type PendingWithdrawalItem = ManagerBonusAccountQuery['managerBonusAccount']['pe
|
|||||||
|
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
const userId = computed(() => String(route.params.userId || ''));
|
const userId = computed(() => String(route.params.userId || ''));
|
||||||
|
const createBonusProgramLinkMutation = useMutation(CreateBonusProgramLinkDocument, { throws: 'never' });
|
||||||
|
const bonusProgramLink = ref('');
|
||||||
|
const bonusProgramLinkExpiresAt = ref('');
|
||||||
|
const bonusProgramLinkFeedback = ref('');
|
||||||
|
|
||||||
const bonusAccountQuery = useQuery(ManagerBonusAccountDocument, () => ({
|
const bonusAccountQuery = useQuery(ManagerBonusAccountDocument, () => ({
|
||||||
userId: userId.value,
|
userId: userId.value,
|
||||||
@@ -55,6 +60,33 @@ function withdrawalStatusClass(status: string) {
|
|||||||
}
|
}
|
||||||
return 'bg-[#fff3d8] text-[#9a6100]';
|
return 'bg-[#fff3d8] text-[#9a6100]';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function generateBonusProgramLink() {
|
||||||
|
bonusProgramLinkFeedback.value = '';
|
||||||
|
|
||||||
|
const response = await createBonusProgramLinkMutation.mutate({
|
||||||
|
userId: userId.value,
|
||||||
|
});
|
||||||
|
|
||||||
|
const payload = response?.data?.createBonusProgramLink;
|
||||||
|
if (!payload?.url) {
|
||||||
|
bonusProgramLinkFeedback.value = createBonusProgramLinkMutation.error.value?.message || 'Не удалось сгенерировать ссылку.';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
bonusProgramLink.value = payload.url;
|
||||||
|
bonusProgramLinkExpiresAt.value = payload.expiresAt;
|
||||||
|
bonusProgramLinkFeedback.value = 'Ссылка готова. Её можно переслать клиенту.';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function copyBonusProgramLink() {
|
||||||
|
if (!bonusProgramLink.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await navigator.clipboard.writeText(bonusProgramLink.value);
|
||||||
|
bonusProgramLinkFeedback.value = 'Ссылка скопирована.';
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -71,18 +103,73 @@ function withdrawalStatusClass(status: string) {
|
|||||||
<div class="space-y-4">
|
<div class="space-y-4">
|
||||||
<UiBackHeader
|
<UiBackHeader
|
||||||
to="/admin/bonuses/balances"
|
to="/admin/bonuses/balances"
|
||||||
back-label="Назад к бонусам"
|
back-label="Назад к бонусным счетам"
|
||||||
:title="`Бонусный счёт ${bonusAccount.fullName}`"
|
:title="`Бонусный счёт ${bonusAccount.fullName}`"
|
||||||
:subtitle="bonusAccount.companyName || bonusAccount.email || undefined"
|
:subtitle="bonusAccount.companyName || bonusAccount.email || undefined"
|
||||||
>
|
>
|
||||||
<template #actions>
|
<template #actions>
|
||||||
|
<div class="flex flex-col gap-3 md:items-end">
|
||||||
<div class="text-left md:text-right">
|
<div class="text-left md:text-right">
|
||||||
<p class="text-[11px] font-semibold uppercase tracking-[0.18em] text-[#6a8a76]">Доступный бонус</p>
|
<p class="text-[11px] font-semibold uppercase tracking-[0.18em] text-[#6a8a76]">Доступный бонус</p>
|
||||||
<p class="mt-2 text-3xl font-black leading-none text-[#123824]">{{ formatAmount(bonusAccount.balance) }}</p>
|
<p class="mt-2 text-3xl font-black leading-none text-[#123824]">{{ formatAmount(bonusAccount.balance) }}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
class="btn rounded-full border-0 bg-[#123824] px-5 text-white hover:bg-[#0f2f20]"
|
||||||
|
:disabled="createBonusProgramLinkMutation.loading.value"
|
||||||
|
@click="generateBonusProgramLink"
|
||||||
|
>
|
||||||
|
{{ createBonusProgramLinkMutation.loading.value ? 'Генерируем...' : 'Сгенерировать ссылку' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</UiBackHeader>
|
</UiBackHeader>
|
||||||
|
|
||||||
|
<article v-if="bonusProgramLink" class="surface-card rounded-[28px] px-5 py-4">
|
||||||
|
<div class="flex flex-col gap-4 md:flex-row md:items-end md:justify-between">
|
||||||
|
<div class="min-w-0 flex-1 space-y-2">
|
||||||
|
<p class="text-[11px] font-semibold uppercase tracking-[0.18em] text-[#6a8a76]">Ссылка в бонусный кабинет</p>
|
||||||
|
<p class="text-sm text-[#355947]">
|
||||||
|
Эту ссылку менеджер может отправить клиенту. Дальше клиент авторизуется через отдельный Telegram-бот бонусной программы.
|
||||||
|
</p>
|
||||||
|
<div class="rounded-[20px] bg-[#f8fbf9] px-4 py-3 text-sm font-semibold text-[#123824] break-all">
|
||||||
|
{{ bonusProgramLink }}
|
||||||
|
</div>
|
||||||
|
<p v-if="bonusProgramLinkExpiresAt" class="text-xs text-[#5c7b69]">
|
||||||
|
Действует до {{ formatDateTime(bonusProgramLinkExpiresAt) }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex 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>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p v-if="bonusProgramLinkFeedback" class="mt-4 text-sm font-semibold text-[#0d854a]">
|
||||||
|
{{ bonusProgramLinkFeedback }}
|
||||||
|
</p>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<p
|
||||||
|
v-else-if="bonusProgramLinkFeedback"
|
||||||
|
class="text-sm font-semibold text-[#0d854a]"
|
||||||
|
>
|
||||||
|
{{ bonusProgramLinkFeedback }}
|
||||||
|
</p>
|
||||||
|
|
||||||
<div v-if="pendingWithdrawals.length" class="space-y-3">
|
<div v-if="pendingWithdrawals.length" class="space-y-3">
|
||||||
<div class="space-y-1">
|
<div class="space-y-1">
|
||||||
<p class="text-lg font-bold text-[#123824]">Заявки на выплату</p>
|
<p class="text-lg font-bold text-[#123824]">Заявки на выплату</p>
|
||||||
|
|||||||
@@ -2,11 +2,9 @@
|
|||||||
import { useQuery } from '@vue/apollo-composable';
|
import { useQuery } from '@vue/apollo-composable';
|
||||||
import {
|
import {
|
||||||
ManagerBonusBalancesDocument,
|
ManagerBonusBalancesDocument,
|
||||||
ManagerReferralLinksDocument,
|
|
||||||
ManagerUsersDocument,
|
ManagerUsersDocument,
|
||||||
ManagerWithdrawalRequestsDocument,
|
ManagerWithdrawalRequestsDocument,
|
||||||
type ManagerBonusBalancesQuery,
|
type ManagerBonusBalancesQuery,
|
||||||
type ManagerReferralLinksQuery,
|
|
||||||
type ManagerUsersQuery,
|
type ManagerUsersQuery,
|
||||||
type ManagerWithdrawalRequestsQuery,
|
type ManagerWithdrawalRequestsQuery,
|
||||||
} from '~/composables/graphql/generated';
|
} from '~/composables/graphql/generated';
|
||||||
@@ -19,7 +17,6 @@ definePageMeta({
|
|||||||
});
|
});
|
||||||
|
|
||||||
type BalanceItem = ManagerBonusBalancesQuery['managerBonusBalances'][number];
|
type BalanceItem = ManagerBonusBalancesQuery['managerBonusBalances'][number];
|
||||||
type ReferralLinkItem = ManagerReferralLinksQuery['managerReferralLinks'][number];
|
|
||||||
type ManagerUserItem = ManagerUsersQuery['managerUsers'][number];
|
type ManagerUserItem = ManagerUsersQuery['managerUsers'][number];
|
||||||
type WithdrawalItem = ManagerWithdrawalRequestsQuery['managerWithdrawalRequests'][number];
|
type WithdrawalItem = ManagerWithdrawalRequestsQuery['managerWithdrawalRequests'][number];
|
||||||
type ProductCard = {
|
type ProductCard = {
|
||||||
@@ -33,7 +30,6 @@ type ProductCard = {
|
|||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
const search = ref('');
|
const search = ref('');
|
||||||
const balancesQuery = useQuery(ManagerBonusBalancesDocument);
|
const balancesQuery = useQuery(ManagerBonusBalancesDocument);
|
||||||
const referralLinksQuery = useQuery(ManagerReferralLinksDocument);
|
|
||||||
const usersQuery = useQuery(ManagerUsersDocument);
|
const usersQuery = useQuery(ManagerUsersDocument);
|
||||||
const withdrawalsQuery = useQuery(ManagerWithdrawalRequestsDocument, {
|
const withdrawalsQuery = useQuery(ManagerWithdrawalRequestsDocument, {
|
||||||
status: 'PENDING',
|
status: 'PENDING',
|
||||||
@@ -95,34 +91,15 @@ const productCards: ProductCard[] = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
const balances = computed<BalanceItem[]>(() => balancesQuery.result.value?.managerBonusBalances ?? []);
|
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 users = computed<ManagerUserItem[]>(() => usersQuery.result.value?.managerUsers ?? []);
|
||||||
const withdrawals = computed<WithdrawalItem[]>(() => withdrawalsQuery.result.value?.managerWithdrawalRequests ?? []);
|
const withdrawals = computed<WithdrawalItem[]>(() => withdrawalsQuery.result.value?.managerWithdrawalRequests ?? []);
|
||||||
const usersById = computed(() => new Map(users.value.map((user) => [user.id, user])));
|
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 filteredBalances = computed(() => {
|
||||||
const query = search.value.trim().toLowerCase();
|
const query = search.value.trim().toLowerCase();
|
||||||
|
|
||||||
return balances.value
|
return balances.value
|
||||||
.filter((item) => {
|
.filter((item) => {
|
||||||
const links = referralLinksByReferrer.value.get(item.userId);
|
|
||||||
|
|
||||||
if (!links?.length) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!query) {
|
if (!query) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -132,22 +109,11 @@ const filteredBalances = computed(() => {
|
|||||||
item.email,
|
item.email,
|
||||||
item.companyName || '',
|
item.companyName || '',
|
||||||
String(item.balance),
|
String(item.balance),
|
||||||
...links.flatMap((link) => [
|
String(item.transactionsCount),
|
||||||
link.refereeName,
|
|
||||||
link.refereeEmail,
|
|
||||||
link.refereeCompanyName || '',
|
|
||||||
String(link.bonusPercent),
|
|
||||||
]),
|
|
||||||
]
|
]
|
||||||
.join(' ')
|
.join(' ')
|
||||||
.toLowerCase()
|
.toLowerCase()
|
||||||
.includes(query);
|
.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'
|
: 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'">
|
<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>
|
||||||
<div v-else-if="filteredBalances.length === 0" class="manager-empty-state">
|
<div v-else-if="filteredBalances.length === 0" class="manager-empty-state">
|
||||||
Бонусных связок пока нет.
|
Бонусных счетов пока нет.
|
||||||
</div>
|
</div>
|
||||||
<div v-else class="space-y-4">
|
<div v-else class="space-y-4">
|
||||||
<div class="grid gap-4 sm:grid-cols-2 xl:grid-cols-4 2xl:grid-cols-6">
|
<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">
|
<script setup lang="ts">
|
||||||
import { useMutation, useQuery } from '@vue/apollo-composable';
|
import { useMutation, useQuery } from '@vue/apollo-composable';
|
||||||
import {
|
import {
|
||||||
CreateReferralDocument,
|
CreateBonusProgramLinkDocument,
|
||||||
ManagerReferralLinksDocument,
|
|
||||||
ManagerUsersDocument,
|
ManagerUsersDocument,
|
||||||
type ManagerReferralLinksQuery,
|
type CreateBonusProgramLinkMutation,
|
||||||
type ManagerUsersQuery,
|
type ManagerUsersQuery,
|
||||||
} from '~/composables/graphql/generated';
|
} from '~/composables/graphql/generated';
|
||||||
|
|
||||||
definePageMeta({
|
definePageMeta({
|
||||||
middleware: ['manager-only'],
|
middleware: ['manager-only'],
|
||||||
path: '/admin/bonuses/referrals/new',
|
path: '/admin/bonuses/links/new',
|
||||||
alias: ['/bonus-system/referrals/new'],
|
alias: ['/bonus-system/links/new', '/bonus-system/referrals/new', '/admin/bonuses/referrals/new'],
|
||||||
});
|
});
|
||||||
|
|
||||||
type ManagerUserItem = ManagerUsersQuery['managerUsers'][number];
|
type ManagerUserItem = ManagerUsersQuery['managerUsers'][number];
|
||||||
type ManagerReferralLinkItem = ManagerReferralLinksQuery['managerReferralLinks'][number];
|
const userId = ref('');
|
||||||
|
|
||||||
const referrerUserId = ref('');
|
|
||||||
const refereeUserId = ref('');
|
|
||||||
const bonusPercent = ref(5);
|
|
||||||
const createdReferralId = ref('');
|
|
||||||
const errorMessage = ref('');
|
const errorMessage = ref('');
|
||||||
|
const bonusProgramLink = ref('');
|
||||||
|
const bonusProgramLinkExpiresAt = ref('');
|
||||||
const usersQuery = useQuery(ManagerUsersDocument);
|
const usersQuery = useQuery(ManagerUsersDocument);
|
||||||
const linksQuery = useQuery(ManagerReferralLinksDocument);
|
const createBonusProgramLinkMutation = useMutation(CreateBonusProgramLinkDocument, { throws: 'never' });
|
||||||
const createReferralMutation = useMutation(CreateReferralDocument);
|
|
||||||
|
|
||||||
const clientOptions = computed<ManagerUserItem[]>(() => (
|
const clientOptions = computed<ManagerUserItem[]>(() => (
|
||||||
(usersQuery.result.value?.managerUsers ?? [])
|
(usersQuery.result.value?.managerUsers ?? [])
|
||||||
.filter((user) => user.role === 'CLIENT')
|
.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) {
|
function userOptionLabel(user: ManagerUserItem) {
|
||||||
return [user.fullName, user.companyName || user.email]
|
return [user.fullName, user.companyName || user.email]
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.join(' • ');
|
.join(' • ');
|
||||||
}
|
}
|
||||||
|
|
||||||
async function createReferral() {
|
async function createBonusAccountLink() {
|
||||||
createdReferralId.value = '';
|
|
||||||
errorMessage.value = '';
|
errorMessage.value = '';
|
||||||
|
bonusProgramLink.value = '';
|
||||||
|
bonusProgramLinkExpiresAt.value = '';
|
||||||
|
|
||||||
if (!referrerUserId.value || !refereeUserId.value) {
|
if (!userId.value) {
|
||||||
errorMessage.value = 'Выберите обоих клиентов для связки.';
|
errorMessage.value = 'Выберите клиента.';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (referrerUserId.value === refereeUserId.value) {
|
const bonusLinkResponse = await createBonusProgramLinkMutation.mutate({
|
||||||
errorMessage.value = 'Нельзя связать клиента с самим собой.';
|
userId: userId.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,
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
createdReferralId.value = response?.data?.createReferral.id ?? '';
|
const bonusLinkPayload: CreateBonusProgramLinkMutation['createBonusProgramLink'] | undefined = bonusLinkResponse?.data?.createBonusProgramLink;
|
||||||
refereeUserId.value = '';
|
if (!bonusLinkPayload?.url) {
|
||||||
await linksQuery.refetch();
|
errorMessage.value = createBonusProgramLinkMutation.error.value?.message || 'Не удалось сгенерировать ссылку.';
|
||||||
} catch (error: unknown) {
|
return;
|
||||||
errorMessage.value = error instanceof Error
|
|
||||||
? error.message
|
|
||||||
: 'Не удалось создать бонусную связку.';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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>
|
</script>
|
||||||
|
|
||||||
@@ -105,52 +73,29 @@ async function createReferral() {
|
|||||||
<section class="space-y-6 max-w-3xl">
|
<section class="space-y-6 max-w-3xl">
|
||||||
<UiBackHeader
|
<UiBackHeader
|
||||||
to="/admin/bonuses/balances"
|
to="/admin/bonuses/balances"
|
||||||
back-label="Назад к бонусам"
|
back-label="Назад к бонусным счетам"
|
||||||
title="Создать бонусную связку клиентов"
|
title="Создать бонусный счет"
|
||||||
subtitle="Первый клиент получает процент бонуса, когда заказ второго клиента переходит в статус доставленного."
|
subtitle="Менеджер выбирает клиента и сразу получает ссылку, которую можно переслать ему."
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div class="surface-card rounded-3xl p-5 space-y-4">
|
<div class="surface-card rounded-3xl p-5 space-y-4">
|
||||||
<label class="form-control">
|
<label class="form-control">
|
||||||
<span class="label-text">Клиент, который получает бонус</span>
|
<span class="label-text">Клиент</span>
|
||||||
<select v-model="referrerUserId" class="select manager-field w-full">
|
<select v-model="userId" class="select manager-field w-full">
|
||||||
<option value="">Выберите клиента</option>
|
<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) }}
|
{{ userOptionLabel(user) }}
|
||||||
</option>
|
</option>
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</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">
|
<div class="mt-4">
|
||||||
<button
|
<button
|
||||||
class="btn btn-primary border-0"
|
class="btn btn-primary border-0"
|
||||||
:disabled="createReferralMutation.loading.value || usersQuery.loading.value"
|
:disabled="createBonusProgramLinkMutation.loading.value || usersQuery.loading.value"
|
||||||
@click="createReferral"
|
@click="createBonusAccountLink"
|
||||||
>
|
>
|
||||||
{{ createReferralMutation.loading.value ? 'Сохраняем...' : 'Создать связь' }}
|
{{ createBonusProgramLinkMutation.loading.value ? 'Генерируем...' : 'Создать ссылку' }}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -159,41 +104,36 @@ async function createReferral() {
|
|||||||
{{ errorMessage }}
|
{{ errorMessage }}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="createdReferralId" class="surface-card rounded-3xl p-5 text-sm text-[#123824]">
|
<article v-if="bonusProgramLink" class="surface-card rounded-3xl p-5 space-y-4">
|
||||||
Создана связь: <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"
|
|
||||||
>
|
|
||||||
<div class="space-y-2">
|
<div class="space-y-2">
|
||||||
<p class="text-sm font-semibold text-[#123824]">
|
<p class="text-sm font-semibold text-[#123824]">Ссылка в бонусный кабинет</p>
|
||||||
{{ link.referrerName }} получает {{ link.bonusPercent }}% с заказов {{ link.refereeName }}
|
|
||||||
</p>
|
|
||||||
<p class="text-sm text-[#466653]">
|
<p class="text-sm text-[#466653]">
|
||||||
{{ link.referrerCompanyName || link.referrerEmail }} → {{ link.refereeCompanyName || link.refereeEmail }}
|
Эту ссылку менеджер может сразу отправить клиенту.
|
||||||
</p>
|
</p>
|
||||||
<p class="text-xs text-[#5c7b69]">
|
<div class="rounded-[20px] bg-[#f8fbf9] px-4 py-3 text-sm font-semibold text-[#123824] break-all">
|
||||||
Создано {{ new Date(link.createdAt).toLocaleString() }}
|
{{ bonusProgramLink }}
|
||||||
|
</div>
|
||||||
|
<p v-if="bonusProgramLinkExpiresAt" class="text-xs text-[#5c7b69]">
|
||||||
|
Действует до {{ formatDateTime(bonusProgramLinkExpiresAt) }}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</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>
|
</article>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ async function addBonus() {
|
|||||||
<section class="space-y-6 max-w-3xl">
|
<section class="space-y-6 max-w-3xl">
|
||||||
<UiBackHeader
|
<UiBackHeader
|
||||||
to="/admin/bonuses/balances"
|
to="/admin/bonuses/balances"
|
||||||
back-label="Назад к бонусам"
|
back-label="Назад к бонусным счетам"
|
||||||
title="Добавить бонусную транзакцию"
|
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">
|
<script setup lang="ts">
|
||||||
import CatalogConfigurator from '~/components/catalog/CatalogConfigurator.vue';
|
import CatalogProductTypeList from '~/components/catalog/CatalogProductTypeList.vue';
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<CatalogConfigurator />
|
<CatalogProductTypeList />
|
||||||
</template>
|
</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 dashboard = computed(() => syncDashboardQuery.result.value?.integrationSyncDashboard ?? null);
|
||||||
const syncItems = computed<SyncItem[]>(() => dashboard.value?.items ?? []);
|
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) {
|
function formatDateTime(value?: string | null) {
|
||||||
if (!value) {
|
if (!value) {
|
||||||
return 'Пока нет';
|
return 'Пока нет';
|
||||||
@@ -33,124 +49,69 @@ function formatDateTime(value?: string | null) {
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<section class="space-y-6">
|
<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>
|
<h1 class="text-3xl font-extrabold text-[#0f2f20]">1С</h1>
|
||||||
<p class="max-w-3xl text-sm leading-6 text-[#557562]">
|
<p class="text-sm leading-6 text-[#557562]">
|
||||||
Витрина контроля будущей интеграции с 1С. Здесь видно, какие контуры уже есть в данных,
|
Статус синхронизации по ключевым событиям.
|
||||||
когда в них была последняя активность и под какие workers мы потом соберём реальную синхронизацию.
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="syncDashboardQuery.loading.value" class="manager-empty-state">
|
<div v-if="syncDashboardQuery.loading.value" class="manager-empty-state">
|
||||||
Загружаем контур синхронизации...
|
Загружаем статусы...
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<template v-else-if="dashboard">
|
<template v-else-if="dashboard">
|
||||||
<section class="grid gap-4 md:grid-cols-3">
|
<section class="space-y-4">
|
||||||
<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">
|
|
||||||
<article
|
<article
|
||||||
v-for="item in syncItems"
|
v-for="item in syncItems"
|
||||||
:key="item.id"
|
: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="flex flex-col gap-4 md:flex-row md:items-start md:justify-between">
|
||||||
<div class="min-w-0">
|
<div class="min-w-0 space-y-3">
|
||||||
<div class="flex flex-wrap items-center gap-2">
|
<div class="flex flex-wrap items-center gap-3">
|
||||||
<span class="rounded-full bg-[#eef5f0] px-3 py-1 text-xs font-bold uppercase tracking-[0.12em] text-[#4e7060]">
|
<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 }}
|
{{ item.source }}
|
||||||
</span>
|
</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>
|
||||||
|
|
||||||
<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 class="text-xs font-semibold uppercase tracking-[0.12em] text-[#688676]">
|
||||||
Синхронизировано
|
Обновлений
|
||||||
</p>
|
</p>
|
||||||
<p class="mt-2 text-2xl font-black tracking-[-0.04em] text-[#123824]">
|
<p class="mt-2 text-2xl font-black tracking-[-0.04em] text-[#123824]">
|
||||||
{{ item.syncedCount }}
|
{{ item.syncedCount }}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
</article>
|
||||||
</section>
|
</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>
|
</template>
|
||||||
</section>
|
</section>
|
||||||
</template>
|
</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
@@ -0,0 +1,8 @@
|
|||||||
|
mutation CreateBonusProgramLink($userId: ID!) {
|
||||||
|
createBonusProgramLink(userId: $userId) {
|
||||||
|
userId
|
||||||
|
token
|
||||||
|
url
|
||||||
|
expiresAt
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,6 +10,7 @@ query ClientProducts {
|
|||||||
thicknessMicron
|
thicknessMicron
|
||||||
sleeveBrand
|
sleeveBrand
|
||||||
quantityPerBox
|
quantityPerBox
|
||||||
|
tags
|
||||||
isCustomizable
|
isCustomizable
|
||||||
availableInWarehouses {
|
availableInWarehouses {
|
||||||
availableQty
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -213,6 +213,13 @@ type IntegrationSyncDashboard {
|
|||||||
items: [IntegrationSyncItem!]!
|
items: [IntegrationSyncItem!]!
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type BonusProgramLink {
|
||||||
|
userId: ID!
|
||||||
|
token: String!
|
||||||
|
url: String!
|
||||||
|
expiresAt: DateTime!
|
||||||
|
}
|
||||||
|
|
||||||
type Warehouse {
|
type Warehouse {
|
||||||
id: ID!
|
id: ID!
|
||||||
code: String!
|
code: String!
|
||||||
@@ -235,11 +242,29 @@ type Product {
|
|||||||
thicknessMicron: Int
|
thicknessMicron: Int
|
||||||
sleeveBrand: String
|
sleeveBrand: String
|
||||||
quantityPerBox: String
|
quantityPerBox: String
|
||||||
|
tags: [String!]!
|
||||||
isCustomizable: Boolean!
|
isCustomizable: Boolean!
|
||||||
isActive: Boolean!
|
isActive: Boolean!
|
||||||
availableInWarehouses: [ProductWarehouseBalance!]!
|
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 {
|
type CartItem {
|
||||||
id: ID!
|
id: ID!
|
||||||
productId: ID!
|
productId: ID!
|
||||||
@@ -403,6 +428,7 @@ type Query {
|
|||||||
integrationSyncDashboard: IntegrationSyncDashboard!
|
integrationSyncDashboard: IntegrationSyncDashboard!
|
||||||
managerNotificationHistory(userId: ID!, channel: MessengerType!, limit: Int = 50): [NotificationHistoryItem!]!
|
managerNotificationHistory(userId: ID!, channel: MessengerType!, limit: Int = 50): [NotificationHistoryItem!]!
|
||||||
clientProducts: [Product!]!
|
clientProducts: [Product!]!
|
||||||
|
catalogProductTypeSettings: [CatalogProductTypeSetting!]!
|
||||||
order(id: ID!): Order
|
order(id: ID!): Order
|
||||||
myOrders: [Order!]!
|
myOrders: [Order!]!
|
||||||
myCurrentOrders: [Order!]!
|
myCurrentOrders: [Order!]!
|
||||||
@@ -483,6 +509,23 @@ input UpdateCartItemQuantityInput {
|
|||||||
quantity: Float!
|
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 {
|
input ReadyOrderItemInput {
|
||||||
productId: ID!
|
productId: ID!
|
||||||
quantity: Float!
|
quantity: Float!
|
||||||
@@ -546,6 +589,7 @@ type Mutation {
|
|||||||
connectMessenger(input: ConnectMessengerInput!): MessengerConnection!
|
connectMessenger(input: ConnectMessengerInput!): MessengerConnection!
|
||||||
deleteMyMessengerConnection(connectionId: ID!): Boolean!
|
deleteMyMessengerConnection(connectionId: ID!): Boolean!
|
||||||
upsertMyCounterpartyProfile(input: UpsertMyCounterpartyProfileInput!): CounterpartyProfile!
|
upsertMyCounterpartyProfile(input: UpsertMyCounterpartyProfileInput!): CounterpartyProfile!
|
||||||
|
upsertCatalogProductTypeSetting(input: UpsertCatalogProductTypeSettingInput!): CatalogProductTypeSetting!
|
||||||
addProductToCart(productId: ID!): Cart!
|
addProductToCart(productId: ID!): Cart!
|
||||||
updateCartItemQuantity(input: UpdateCartItemQuantityInput!): Cart!
|
updateCartItemQuantity(input: UpdateCartItemQuantityInput!): Cart!
|
||||||
removeCartItem(productId: ID!): Cart!
|
removeCartItem(productId: ID!): Cart!
|
||||||
@@ -563,6 +607,7 @@ type Mutation {
|
|||||||
clientReviewOrder(orderId: ID!, decision: Decision!): Order!
|
clientReviewOrder(orderId: ID!, decision: Decision!): Order!
|
||||||
|
|
||||||
createReferral(input: CreateReferralInput!): ReferralLink!
|
createReferral(input: CreateReferralInput!): ReferralLink!
|
||||||
|
createBonusProgramLink(userId: ID!): BonusProgramLink!
|
||||||
addBonusTransaction(input: AddBonusTransactionInput!): BonusTransaction!
|
addBonusTransaction(input: AddBonusTransactionInput!): BonusTransaction!
|
||||||
requestRewardWithdrawal(input: RequestRewardWithdrawalInput!): RewardWithdrawalRequest!
|
requestRewardWithdrawal(input: RequestRewardWithdrawalInput!): RewardWithdrawalRequest!
|
||||||
reviewRewardWithdrawal(input: ReviewRewardWithdrawalInput!): RewardWithdrawalRequest!
|
reviewRewardWithdrawal(input: ReviewRewardWithdrawalInput!): RewardWithdrawalRequest!
|
||||||
|
|||||||
10
package.json
@@ -10,7 +10,10 @@
|
|||||||
"postinstall": "nuxt prepare",
|
"postinstall": "nuxt prepare",
|
||||||
"codegen": "graphql-codegen --config codegen.ts",
|
"codegen": "graphql-codegen --config codegen.ts",
|
||||||
"storybook": "storybook dev -p 6006",
|
"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": {
|
"dependencies": {
|
||||||
"@apollo/client": "^3.14.1",
|
"@apollo/client": "^3.14.1",
|
||||||
@@ -24,6 +27,7 @@
|
|||||||
"@vue/apollo-composable": "^4.2.2",
|
"@vue/apollo-composable": "^4.2.2",
|
||||||
"daisyui": "^5.5.19",
|
"daisyui": "^5.5.19",
|
||||||
"graphql": "^16.13.2",
|
"graphql": "^16.13.2",
|
||||||
|
"mermaid": "^11.14.0",
|
||||||
"nuxt": "^4.4.2",
|
"nuxt": "^4.4.2",
|
||||||
"vue": "^3.5.30",
|
"vue": "^3.5.30",
|
||||||
"vue-router": "^5.0.4"
|
"vue-router": "^5.0.4"
|
||||||
@@ -34,9 +38,11 @@
|
|||||||
"@graphql-codegen/typescript": "^5.0.9",
|
"@graphql-codegen/typescript": "^5.0.9",
|
||||||
"@graphql-codegen/typescript-operations": "^5.0.9",
|
"@graphql-codegen/typescript-operations": "^5.0.9",
|
||||||
"@graphql-codegen/typescript-vue-apollo": "^5.0.0",
|
"@graphql-codegen/typescript-vue-apollo": "^5.0.0",
|
||||||
|
"@mermaid-js/mermaid-cli": "^11.14.0",
|
||||||
"@storybook/addon-essentials": "8.6.14",
|
"@storybook/addon-essentials": "8.6.14",
|
||||||
"@storybook/vue3-vite": "^8.6.14",
|
"@storybook/vue3-vite": "^8.6.14",
|
||||||
"storybook": "^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
|
||||||