Compare commits

89 Commits

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

2
.gitignore vendored
View File

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

View File

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

View File

@@ -17,86 +17,134 @@ const managerPageTabs = computed(() => {
}
if (
route.path === '/client-orders'
|| route.path.startsWith('/client-orders/')
|| route.path === '/clients'
|| route.path.startsWith('/clients/')
route.path === '/admin/orders'
|| route.path.startsWith('/admin/orders/')
) {
return [
{
key: 'orders',
label: 'Заказы',
active: route.path === '/client-orders' || route.path.startsWith('/client-orders/'),
active: route.path === '/admin/orders'
|| (
/^\/admin\/orders\/[^/]+$/.test(route.path)
&& !route.path.startsWith('/admin/orders/clients')
&& !route.path.startsWith('/admin/orders/requests')
),
to: {
path: '/client-orders',
query: route.path === '/client-orders' ? route.query : {},
path: '/admin/orders',
},
},
{
key: 'clients',
label: 'Клиенты',
active: route.path === '/clients' || route.path.startsWith('/clients/'),
active: route.path === '/admin/orders/clients'
|| route.path.startsWith('/admin/orders/clients/')
|| route.path.startsWith('/admin/orders/requests/'),
to: {
path: '/clients',
query: route.path === '/clients' ? route.query : {},
path: '/admin/orders/clients',
},
},
];
}
if (route.path === '/bonus-system') {
if (route.path.startsWith('/admin/bonuses')) {
return [
{
key: 'balances',
label: алансы',
active: route.query.tab !== 'withdrawals' && route.query.tab !== 'products' && route.query.tab !== 'manager',
label: онусные счета',
active: route.path === '/admin/bonuses'
|| route.path === '/admin/bonuses/balances'
|| route.path.startsWith('/admin/bonuses/balances/')
|| route.path.startsWith('/admin/bonuses/referrals/')
|| route.path.startsWith('/admin/bonuses/transactions/'),
to: {
path: '/bonus-system',
query: {
...route.query,
tab: 'balances',
},
path: '/admin/bonuses/balances',
},
},
{
key: 'withdrawals',
label: 'Заявки на выплату',
active: route.query.tab === 'withdrawals',
active: route.path === '/admin/bonuses/requests'
|| route.path.startsWith('/admin/bonuses/requests/'),
to: {
path: '/bonus-system',
query: {
...route.query,
tab: 'withdrawals',
},
path: '/admin/bonuses/requests',
},
},
{
key: 'products',
label: 'Товары',
active: route.query.tab === 'products' || route.query.tab === 'manager',
key: 'rewards',
label: 'Вознаграждения',
active: route.path === '/admin/bonuses/rewards',
to: {
path: '/bonus-system',
query: {
...route.query,
tab: 'products',
path: '/admin/bonuses/rewards',
},
},
];
}
if (route.path.startsWith('/admin/settings')) {
return [
{
key: 'catalog',
label: 'Каталог',
active: route.path === '/admin/settings/catalog',
to: {
path: '/admin/settings/catalog',
},
},
{
key: 'messages',
label: 'Сообщения',
active: route.path === '/admin/settings/messages',
to: {
path: '/admin/settings/messages',
},
},
{
key: 'sync',
label: '1С',
active: route.path === '/admin/settings/sync',
to: {
path: '/admin/settings/sync',
},
},
];
}
return [];
});
const mainClass = computed(() => {
if (isBonusProgramPage.value) {
return 'bonus-program-main';
}
if (isLoginPage.value) {
return 'mx-auto flex min-h-screen w-full max-w-[1440px] items-center justify-center p-4 md:p-6 lg:p-8';
}
return [
'mx-auto w-full max-w-[1440px] p-4 pt-[104px] md:p-6 md:pt-[112px] lg:p-8 lg:pt-[118px]',
hasManagerDock.value ? 'pb-[116px] md:pb-[128px]' : '',
];
});
const pageFrameClass = computed(() => {
if (isBonusProgramPage.value) {
return 'bonus-program-stage';
}
if (isLoginPage.value) {
return '';
}
return ['lk-content-canvas', { 'lk-content-canvas--with-tabs': managerPageTabs.value.length }];
});
</script>
<template>
<div :class="isBonusProgramPage ? 'bonus-program-shell' : 'lk-shell'" data-theme="aqua">
<UiAppHeader v-if="!isLoginPage && !isBonusProgramPage" />
<main
:class="isBonusProgramPage
? 'bonus-program-main'
: ['mx-auto w-full max-w-[1440px] p-4 pt-[104px] md:p-6 md:pt-[112px] lg:p-8 lg:pt-[118px]', hasManagerDock ? 'pb-[116px] md:pb-[128px]' : '']"
>
<main :class="mainClass">
<div v-if="managerPageTabs.length && !isBonusProgramPage" class="lk-page-tabs-shell">
<nav class="manager-page-tabs" aria-label="Разделы страницы">
<NuxtLink
@@ -111,11 +159,7 @@ const managerPageTabs = computed(() => {
</nav>
</div>
<div
:class="isBonusProgramPage
? 'bonus-program-stage'
: ['lk-content-canvas', { 'lk-content-canvas--with-tabs': managerPageTabs.length }]"
>
<div :class="pageFrameClass">
<NuxtPage />
</div>
</main>

View File

@@ -508,9 +508,9 @@ body {
.manager-dock {
pointer-events: auto;
display: grid;
width: min(100%, 18.5rem);
grid-template-columns: repeat(2, minmax(0, 1fr));
display: flex;
width: fit-content;
max-width: min(100%, calc(100vw - 2rem));
gap: 0.5rem;
border-radius: 1.75rem;
background: rgba(255, 255, 255, 0.92);
@@ -524,7 +524,7 @@ body {
.manager-dock__item {
display: flex;
min-width: 0;
min-width: 5.5rem;
flex-direction: column;
align-items: center;
justify-content: center;

View File

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

View File

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

View File

@@ -1,30 +1,26 @@
<script setup lang="ts">
import {
getOrderStatusBadgePresentation,
type OrderStatusTone,
} from '~/composables/useOrderStatusPresentation';
const props = defineProps<{
status: string;
}>();
const statusLabel = computed(() => {
if (props.status === 'NEW' || props.status === 'MANAGER_PROCESSING') return 'Заявка';
if (props.status === 'WAITING_DOUBLE_CONFIRM' || props.status === 'CONFIRMED') return 'Предложение';
if (props.status === 'IN_PROGRESS') return 'В работе';
if (props.status === 'COMPLETED') return 'Завершен';
if (props.status === 'CLIENT_REJECTED' || props.status === 'MANAGER_REJECTED') return 'Отклонен';
if (props.status === 'MANAGER_BLOCKED') return 'Пауза';
return props.status;
});
const badgePresentation = computed(() => getOrderStatusBadgePresentation(props.status));
const className = computed(() => {
if (props.status === 'COMPLETED') return 'bg-[#139957]';
if (props.status === 'CLIENT_REJECTED' || props.status === 'MANAGER_REJECTED') return 'bg-[#d94b55]';
if (props.status === 'MANAGER_BLOCKED') return 'bg-[#f1a43a]';
if (props.status === 'NEW' || props.status === 'MANAGER_PROCESSING') return 'bg-[#f1a43a]';
function dotClass(tone: OrderStatusTone) {
if (tone === 'success') return 'bg-[#139957]';
if (tone === 'danger') return 'bg-[#d94b55]';
if (tone === 'warning') return 'bg-[#f1a43a]';
return 'bg-[#2e8de4]';
});
}
</script>
<template>
<span class="inline-flex items-center gap-2 text-sm font-semibold text-[#123824]">
<span class="h-2.5 w-2.5 rounded-full" :class="className" />
<span>{{ statusLabel }}</span>
<span class="inline-flex items-center gap-2 text-sm font-semibold leading-tight text-[#123824]">
<span class="h-2.5 w-2.5 rounded-full" :class="dotClass(badgePresentation.tone)" />
<span>{{ badgePresentation.label }}</span>
</span>
</template>

View File

@@ -1,6 +1,10 @@
<script setup lang="ts">
import OrderStatusBadge from '~/components/orders/OrderStatusBadge.vue';
import { getOrderStatusPresentation } from '~/composables/useOrderStatusPresentation';
import {
getOrderStatusBadgePresentation,
getOrderStatusPresentation,
type OrderStatusTone,
} from '~/composables/useOrderStatusPresentation';
const props = defineProps<{
status: string;
@@ -11,10 +15,53 @@ const props = defineProps<{
const isExpanded = ref(false);
const presentation = computed(() => getOrderStatusPresentation(props.status, props.createdAt, props.audience ?? 'client'));
const currentBadge = computed(() => getOrderStatusBadgePresentation(props.status));
function currentToneClass(tone: OrderStatusTone) {
if (tone === 'success') {
return {
marker: 'bg-[#139957] ring-4 ring-[#dff4e8]',
panel: 'bg-[#eef8f2]',
title: 'text-[#123824]',
note: 'text-[#355947]',
date: 'text-[#139957]',
connector: 'bg-[#bfe0cb]',
};
}
if (tone === 'danger') {
return {
marker: 'bg-[#d94b55] ring-4 ring-[#fbe5e7]',
panel: 'bg-[#fff1f2]',
title: 'text-[#7e2130]',
note: 'text-[#9b4150]',
date: 'text-[#d94b55]',
connector: 'bg-[#f1c6cb]',
};
}
if (tone === 'warning') {
return {
marker: 'bg-[#f1a43a] ring-4 ring-[#fff0d9]',
panel: 'bg-[#fff7eb]',
title: 'text-[#6c4303]',
note: 'text-[#8f6420]',
date: 'text-[#c67d11]',
connector: 'bg-[#efd1a2]',
};
}
return {
marker: 'bg-[#2e8de4] ring-4 ring-[#e3effb]',
panel: 'bg-[#eef5fc]',
title: 'text-[#174b7e]',
note: 'text-[#436b92]',
date: 'text-[#2e8de4]',
connector: 'bg-[#c7dbef]',
};
}
function markerClass(state: 'done' | 'current' | 'upcoming') {
if (state === 'current') {
return 'bg-[#139957] ring-4 ring-[#dff4e8]';
return currentToneClass(currentBadge.value.tone).marker;
}
if (state === 'done') {
return 'bg-[#9dcfb0]';
@@ -24,7 +71,9 @@ function markerClass(state: 'done' | 'current' | 'upcoming') {
function connectorClass(state: 'done' | 'current' | 'upcoming') {
if (state === 'done' || state === 'current') {
return 'bg-[#cfe5d7]';
return state === 'current'
? currentToneClass(currentBadge.value.tone).connector
: 'bg-[#cfe5d7]';
}
return 'bg-[#e4ece7]';
@@ -32,7 +81,7 @@ function connectorClass(state: 'done' | 'current' | 'upcoming') {
function titleClass(state: 'done' | 'current' | 'upcoming') {
if (state === 'current') {
return 'text-[#123824]';
return currentToneClass(currentBadge.value.tone).title;
}
if (state === 'done') {
return 'text-[#355947]';
@@ -43,7 +92,7 @@ function titleClass(state: 'done' | 'current' | 'upcoming') {
function noteClass(state: 'done' | 'current' | 'upcoming') {
if (state === 'current') {
return 'text-[#355947]';
return currentToneClass(currentBadge.value.tone).note;
}
if (state === 'done') {
return 'text-[#557562]';
@@ -51,6 +100,28 @@ function noteClass(state: 'done' | 'current' | 'upcoming') {
return 'text-[#7d9688]';
}
function stagePanelClass(state: 'done' | 'current' | 'upcoming') {
if (state === 'current') {
return currentToneClass(currentBadge.value.tone).panel;
}
if (state === 'done') {
return 'bg-[#f3f7f4]';
}
return 'bg-[#f7faf8]';
}
function dateClass(state: 'done' | 'current' | 'upcoming') {
if (state === 'current') {
return currentToneClass(currentBadge.value.tone).date;
}
if (state === 'done') {
return 'text-[#5c7b69]';
}
return 'text-[#86a091]';
}
</script>
<template>
@@ -60,17 +131,19 @@ function noteClass(state: 'done' | 'current' | 'upcoming') {
class="flex w-full items-start justify-between gap-4 text-left"
@click="isExpanded = !isExpanded"
>
<div class="space-y-2">
<div class="space-y-3">
<div class="flex flex-wrap items-center gap-3">
<h2 class="text-2xl font-black leading-tight text-[#123824]">
{{ presentation.title }}
</h2>
<OrderStatusBadge :status="status" />
</div>
<p class="max-w-2xl text-sm leading-6 text-[#355947]">
{{ presentation.summary }}
</p>
</div>
<div class="flex items-center gap-3 pt-1">
<OrderStatusBadge :status="status" />
<span
class="flex h-10 w-10 items-center justify-center rounded-full bg-[#f2f5f3] text-[#123824] transition-transform"
:class="{ 'rotate-180': isExpanded }"
@@ -97,15 +170,16 @@ function noteClass(state: 'done' | 'current' | 'upcoming') {
/>
</div>
<div class="min-w-0 flex-1 pb-5">
<div
class="min-w-0 flex-1 pb-5"
:class="index < presentation.stages.length - 1 ? 'border-b border-[#e1ebe4]' : ''"
class="rounded-[22px] px-4 py-4 transition-colors"
:class="stagePanelClass(stage.state)"
>
<div class="flex flex-wrap items-center justify-between gap-2">
<p class="text-sm font-semibold" :class="titleClass(stage.state)">
{{ stage.label }}
</p>
<p class="text-xs font-semibold uppercase tracking-[0.12em] text-[#5c7b69]">
<p class="text-xs font-semibold uppercase tracking-[0.12em]" :class="dateClass(stage.state)">
{{ stage.dateLabel }}
</p>
</div>
@@ -117,4 +191,5 @@ function noteClass(state: 'done' | 'current' | 'upcoming') {
</div>
</div>
</div>
</div>
</template>

View File

@@ -2,25 +2,26 @@
type DockItem = {
to: string;
label: string;
icon: 'orders' | 'bonus';
icon: 'orders' | 'bonus' | 'settings';
};
const route = useRoute();
const dockItems: DockItem[] = [
{ to: '/client-orders', label: 'Заказы', icon: 'orders' },
{ to: '/bonus-system', label: 'Бонусы', icon: 'bonus' },
{ to: '/admin/orders', label: 'Заказы', icon: 'orders' },
{ to: '/admin/bonuses/balances', label: 'Бонусы', icon: 'bonus' },
{ to: '/admin/settings/messages', label: 'Настройки', icon: 'settings' },
];
function isActive(path: string) {
if (path === '/client-orders') {
return route.path === '/client-orders'
|| route.path.startsWith('/client-orders/')
|| route.path === '/clients'
|| route.path.startsWith('/clients/');
if (path === '/admin/orders') {
return route.path === '/admin/orders' || route.path.startsWith('/admin/orders/');
}
if (path === '/bonus-system') {
return route.path === '/bonus-system' || route.path.startsWith('/bonus-system/');
if (path === '/admin/bonuses/balances') {
return route.path.startsWith('/admin/bonuses');
}
if (path === '/admin/settings/messages') {
return route.path.startsWith('/admin/settings');
}
return route.path === path;
}
@@ -43,9 +44,13 @@ function isActive(path: string) {
<path d="M7 17.25H13" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" />
<rect x="4" y="4" width="16" height="16" rx="4" stroke="currentColor" stroke-width="1.8" />
</svg>
<svg v-else viewBox="0 0 24 24" fill="none" class="h-5 w-5">
<svg v-else-if="item.icon === 'bonus'" viewBox="0 0 24 24" fill="none" class="h-5 w-5">
<path d="M12 4.75L14.2401 9.28984L19.25 10.0172L15.625 13.5504L16.4802 18.5398L12 16.1848L7.51983 18.5398L8.375 13.5504L4.75 10.0172L9.75987 9.28984L12 4.75Z" stroke="currentColor" stroke-width="1.8" stroke-linejoin="round" />
</svg>
<svg v-else viewBox="0 0 24 24" fill="none" class="h-5 w-5">
<path d="M12 8.25C9.92893 8.25 8.25 9.92893 8.25 12C8.25 14.0711 9.92893 15.75 12 15.75C14.0711 15.75 15.75 14.0711 15.75 12C15.75 9.92893 14.0711 8.25 12 8.25Z" stroke="currentColor" stroke-width="1.8" />
<path d="M19.25 13.25V10.75L17.4539 10.2018C17.3255 9.82617 17.1745 9.46304 17.0023 9.11134L17.875 7.45833L16.0417 5.625L14.3887 6.4977C14.037 6.32552 13.6738 6.17449 13.2982 6.04607L12.75 4.25H10.25L9.70183 6.04607C9.32617 6.17449 8.96304 6.32552 8.61134 6.4977L6.95833 5.625L5.125 7.45833L5.9977 9.11134C5.82552 9.46304 5.67449 9.82617 5.54607 10.2018L3.75 10.75V13.25L5.54607 13.7982C5.67449 14.1738 5.82552 14.537 5.9977 14.8887L5.125 16.5417L6.95833 18.375L8.61134 17.5023C8.96304 17.6745 9.32617 17.8255 9.70183 17.9539L10.25 19.75H12.75L13.2982 17.9539C13.6738 17.8255 14.037 17.6745 14.3887 17.5023L16.0417 18.375L17.875 16.5417L17.0023 14.8887C17.1745 14.537 17.3255 14.1738 17.4539 13.7982L19.25 13.25Z" stroke="currentColor" stroke-width="1.8" stroke-linejoin="round" />
</svg>
</span>
<span class="manager-dock__label">{{ item.label }}</span>
</NuxtLink>

View File

@@ -0,0 +1,38 @@
<script setup lang="ts">
defineProps<{
to: string;
backLabel: string;
title: string;
subtitle?: string;
}>();
</script>
<template>
<div class="flex flex-col gap-4 md:flex-row md:items-start md:justify-between">
<div class="min-w-0 space-y-3">
<div class="flex flex-wrap items-center gap-3 px-1">
<NuxtLink
:to="to"
:aria-label="backLabel"
class="flex h-11 w-11 shrink-0 items-center justify-center rounded-full bg-white/70 text-[#0d854a] transition hover:bg-white"
>
<svg class="h-5 w-5" viewBox="0 0 20 20" fill="none">
<path d="M11.5 4.5L6 10L11.5 15.5" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" />
</svg>
</NuxtLink>
<h1 class="min-w-0 text-2xl font-black tracking-[-0.03em] text-[#123824] md:text-3xl">
{{ title }}
</h1>
</div>
<p v-if="subtitle" class="max-w-3xl pl-[3.5rem] text-sm leading-6 text-[#466653]">
{{ subtitle }}
</p>
</div>
<div v-if="$slots.actions" class="flex shrink-0 flex-wrap gap-2 md:justify-end">
<slot name="actions" />
</div>
</div>
</template>

View File

@@ -35,10 +35,10 @@ withDefaults(defineProps<{
<h2 class="mt-8 text-lg font-bold leading-tight text-[#123824]">{{ fullName }}</h2>
<div
v-if="metaLabel && metaValue"
v-if="metaValue"
class="mt-4 inline-flex flex-wrap items-center justify-center gap-2 rounded-full border border-[#e1c15a] bg-[#fff8dc] px-4 py-2 text-sm text-[#7a5b00]"
>
<span class="font-semibold">{{ metaLabel }}</span>
<span v-if="metaLabel" class="font-semibold">{{ metaLabel }}</span>
<span class="font-bold text-[#123824]">{{ metaValue }}</span>
</div>
</NuxtLink>

View File

@@ -47,6 +47,14 @@ export type AuthSession = {
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 = {
__typename?: 'BonusTransaction';
amount: Scalars['Float']['output'];
@@ -81,6 +89,24 @@ export type CartItem = {
updatedAt: Scalars['DateTime']['output'];
};
export type CatalogProductTypeSetting = {
__typename?: 'CatalogProductTypeSetting';
allowCustomLabel: Scalars['Boolean']['output'];
allowCustomLength: Scalars['Boolean']['output'];
allowCustomSleeveBrand: Scalars['Boolean']['output'];
colorOptions: Array<Scalars['String']['output']>;
customLengthMaxM?: Maybe<Scalars['Int']['output']>;
customLengthMinM?: Maybe<Scalars['Int']['output']>;
customLengthStepM?: Maybe<Scalars['Int']['output']>;
labelOptions: Array<Scalars['String']['output']>;
lengthOptionsM: Array<Scalars['Int']['output']>;
productType: Scalars['String']['output'];
showQuantityPerBox: Scalars['Boolean']['output'];
sleeveOptions: Array<Scalars['String']['output']>;
thicknessOptionsMicron: Array<Scalars['Int']['output']>;
widthOptionsMm: Array<Scalars['Int']['output']>;
};
export type Company = {
__typename?: 'Company';
id: Scalars['ID']['output'];
@@ -152,6 +178,28 @@ export type DeliveryAddress = {
userId: Scalars['ID']['output'];
};
export type IntegrationSyncDashboard = {
__typename?: 'IntegrationSyncDashboard';
generatedAt: Scalars['DateTime']['output'];
items: Array<IntegrationSyncItem>;
lastActivityAt?: Maybe<Scalars['DateTime']['output']>;
totalClients: Scalars['Int']['output'];
totalOrders: Scalars['Int']['output'];
totalProducts: Scalars['Int']['output'];
};
export type IntegrationSyncItem = {
__typename?: 'IntegrationSyncItem';
description: Scalars['String']['output'];
id: Scalars['ID']['output'];
lastSyncedAt?: Maybe<Scalars['DateTime']['output']>;
note: Scalars['String']['output'];
source: Scalars['String']['output'];
status: Scalars['String']['output'];
syncedCount: Scalars['Int']['output'];
title: Scalars['String']['output'];
};
export type Invitation = {
__typename?: 'Invitation';
acceptedAt?: Maybe<Scalars['DateTime']['output']>;
@@ -278,10 +326,12 @@ export type Mutation = {
clientReviewOrder: Order;
connectMessenger: MessengerConnection;
consumeLoginToken: AuthSession;
createBonusProgramLink: BonusProgramLink;
createInvitation: Invitation;
createMyDeliveryAddress: DeliveryAddress;
createReferral: ReferralLink;
deleteMyDeliveryAddress: Scalars['Boolean']['output'];
deleteMyMessengerConnection: Scalars['Boolean']['output'];
managerSetOrderOffer: Order;
managerSetOrderStatus: Order;
registerSelf: RegistrationRequest;
@@ -296,6 +346,7 @@ export type Mutation = {
submitCalculationOrder: Order;
submitReadyOrder: Order;
updateCartItemQuantity: Cart;
upsertCatalogProductTypeSetting: CatalogProductTypeSetting;
upsertMyCounterpartyProfile: CounterpartyProfile;
verifyLoginCode: AuthSession;
};
@@ -332,6 +383,11 @@ export type MutationConsumeLoginTokenArgs = {
};
export type MutationCreateBonusProgramLinkArgs = {
userId: Scalars['ID']['input'];
};
export type MutationCreateInvitationArgs = {
input: CreateInvitationInput;
};
@@ -352,6 +408,11 @@ export type MutationDeleteMyDeliveryAddressArgs = {
};
export type MutationDeleteMyMessengerConnectionArgs = {
connectionId: Scalars['ID']['input'];
};
export type MutationManagerSetOrderOfferArgs = {
input: SetOrderOfferInput;
};
@@ -425,6 +486,11 @@ export type MutationUpdateCartItemQuantityArgs = {
};
export type MutationUpsertCatalogProductTypeSettingArgs = {
input: UpsertCatalogProductTypeSettingInput;
};
export type MutationUpsertMyCounterpartyProfileArgs = {
input: UpsertMyCounterpartyProfileInput;
};
@@ -537,6 +603,7 @@ export type Product = {
quantityPerBox?: Maybe<Scalars['String']['output']>;
sku: Scalars['String']['output'];
sleeveBrand?: Maybe<Scalars['String']['output']>;
tags: Array<Scalars['String']['output']>;
thicknessMicron?: Maybe<Scalars['Int']['output']>;
widthMm?: Maybe<Scalars['Int']['output']>;
};
@@ -549,8 +616,10 @@ export type ProductWarehouseBalance = {
export type Query = {
__typename?: 'Query';
catalogProductTypeSettings: Array<CatalogProductTypeSetting>;
clientProducts: Array<Product>;
healthcheck: Scalars['String']['output'];
integrationSyncDashboard: IntegrationSyncDashboard;
managerBonusAccount: ManagerBonusAccount;
managerBonusBalances: Array<ManagerBonusBalance>;
managerNotificationHistory: Array<NotificationHistoryItem>;
@@ -719,6 +788,23 @@ export type UpdateCartItemQuantityInput = {
quantity: Scalars['Float']['input'];
};
export type UpsertCatalogProductTypeSettingInput = {
allowCustomLabel: Scalars['Boolean']['input'];
allowCustomLength: Scalars['Boolean']['input'];
allowCustomSleeveBrand: Scalars['Boolean']['input'];
colorOptions: Array<Scalars['String']['input']>;
customLengthMaxM?: InputMaybe<Scalars['Int']['input']>;
customLengthMinM?: InputMaybe<Scalars['Int']['input']>;
customLengthStepM?: InputMaybe<Scalars['Int']['input']>;
labelOptions: Array<Scalars['String']['input']>;
lengthOptionsM: Array<Scalars['Int']['input']>;
productType: Scalars['String']['input'];
showQuantityPerBox: Scalars['Boolean']['input'];
sleeveOptions: Array<Scalars['String']['input']>;
thicknessOptionsMicron: Array<Scalars['Int']['input']>;
widthOptionsMm: Array<Scalars['Int']['input']>;
};
export type UpsertMyCounterpartyProfileInput = {
bankName: Scalars['String']['input'];
bik: Scalars['String']['input'];
@@ -801,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 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<{
input: RequestRewardWithdrawalInput;
}>;
@@ -849,7 +942,7 @@ export type UpdateCartItemQuantityMutation = { __typename?: 'Mutation', updateCa
export type ClientProductsQueryVariables = Exact<{ [key: string]: never; }>;
export type ClientProductsQuery = { __typename?: 'Query', clientProducts: Array<{ __typename?: 'Product', id: string, sku: string, name: string, description?: string | null, productType?: string | null, widthMm?: number | null, lengthM?: number | null, thicknessMicron?: number | null, sleeveBrand?: string | null, quantityPerBox?: string | null, isCustomizable: boolean, availableInWarehouses: Array<{ __typename?: 'ProductWarehouseBalance', availableQty: number, warehouse: { __typename?: 'Warehouse', id: string, code: string, name: string } }> }> };
export type ClientProductsQuery = { __typename?: 'Query', clientProducts: Array<{ __typename?: 'Product', id: string, sku: string, name: string, description?: string | null, productType?: string | null, widthMm?: number | null, lengthM?: number | null, thicknessMicron?: number | null, sleeveBrand?: string | null, quantityPerBox?: string | null, tags: Array<string>, isCustomizable: boolean, availableInWarehouses: Array<{ __typename?: 'ProductWarehouseBalance', availableQty: number, warehouse: { __typename?: 'Warehouse', id: string, code: string, name: string } }> }> };
export type AddBonusTransactionMutationVariables = Exact<{
input: AddBonusTransactionInput;
@@ -1043,6 +1136,13 @@ export type DeleteMyDeliveryAddressMutationVariables = Exact<{
export type DeleteMyDeliveryAddressMutation = { __typename?: 'Mutation', deleteMyDeliveryAddress: boolean };
export type DeleteMyMessengerConnectionMutationVariables = Exact<{
connectionId: Scalars['ID']['input'];
}>;
export type DeleteMyMessengerConnectionMutation = { __typename?: 'Mutation', deleteMyMessengerConnection: boolean };
export type MyCounterpartyProfileQueryVariables = Exact<{ [key: string]: never; }>;
@@ -1067,6 +1167,23 @@ export type UpsertMyCounterpartyProfileMutationVariables = Exact<{
export type UpsertMyCounterpartyProfileMutation = { __typename?: 'Mutation', upsertMyCounterpartyProfile: { __typename?: 'CounterpartyProfile', id: string, companyName: string, companyFullName: string, inn: string, kpp?: string | null, ogrn?: string | null, legalAddress: string, bankName: string, bik: string, correspondentAccount: string, checkingAccount: string, signerFullName: string, signerPosition: string, signerBasis: string, isComplete: boolean, updatedAt: any } };
export type CatalogProductTypeSettingsQueryVariables = Exact<{ [key: string]: never; }>;
export type CatalogProductTypeSettingsQuery = { __typename?: 'Query', catalogProductTypeSettings: Array<{ __typename?: 'CatalogProductTypeSetting', productType: string, showQuantityPerBox: boolean, allowCustomLength: boolean, customLengthMinM?: number | null, customLengthMaxM?: number | null, customLengthStepM?: number | null, allowCustomSleeveBrand: boolean, allowCustomLabel: boolean, widthOptionsMm: Array<number>, lengthOptionsM: Array<number>, thicknessOptionsMicron: Array<number>, sleeveOptions: Array<string>, colorOptions: Array<string>, labelOptions: Array<string> }> };
export type IntegrationSyncDashboardQueryVariables = Exact<{ [key: string]: never; }>;
export type IntegrationSyncDashboardQuery = { __typename?: 'Query', integrationSyncDashboard: { __typename?: 'IntegrationSyncDashboard', generatedAt: any, lastActivityAt?: any | null, totalOrders: number, totalProducts: number, totalClients: number, items: Array<{ __typename?: 'IntegrationSyncItem', id: string, title: string, description: string, source: string, syncedCount: number, lastSyncedAt?: any | null, status: string, note: string }> } };
export type UpsertCatalogProductTypeSettingMutationVariables = Exact<{
input: UpsertCatalogProductTypeSettingInput;
}>;
export type UpsertCatalogProductTypeSettingMutation = { __typename?: 'Mutation', upsertCatalogProductTypeSetting: { __typename?: 'CatalogProductTypeSetting', productType: string, showQuantityPerBox: boolean, allowCustomLength: boolean, customLengthMinM?: number | null, customLengthMaxM?: number | null, customLengthStepM?: number | null, allowCustomSleeveBrand: boolean, allowCustomLabel: boolean, widthOptionsMm: Array<number>, lengthOptionsM: Array<number>, thicknessOptionsMicron: Array<number>, sleeveOptions: Array<string>, colorOptions: Array<string>, labelOptions: Array<string> } };
export const ConsumeLoginTokenDocument = gql`
mutation ConsumeLoginToken($token: String!) {
@@ -1242,6 +1359,38 @@ export function useVerifyLoginCodeMutation(options: VueApolloComposable.UseMutat
return VueApolloComposable.useMutation<VerifyLoginCodeMutation, VerifyLoginCodeMutationVariables>(VerifyLoginCodeDocument, options);
}
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`
mutation RequestRewardWithdrawal($input: RequestRewardWithdrawalInput!) {
requestRewardWithdrawal(input: $input) {
@@ -1535,6 +1684,7 @@ export const ClientProductsDocument = gql`
thicknessMicron
sleeveBrand
quantityPerBox
tags
isCustomizable
availableInWarehouses {
availableQty
@@ -2650,6 +2800,33 @@ export function useDeleteMyDeliveryAddressMutation(options: VueApolloComposable.
return VueApolloComposable.useMutation<DeleteMyDeliveryAddressMutation, DeleteMyDeliveryAddressMutationVariables>(DeleteMyDeliveryAddressDocument, options);
}
export type DeleteMyDeliveryAddressMutationCompositionFunctionResult = VueApolloComposable.UseMutationReturn<DeleteMyDeliveryAddressMutation, DeleteMyDeliveryAddressMutationVariables>;
export const DeleteMyMessengerConnectionDocument = gql`
mutation DeleteMyMessengerConnection($connectionId: ID!) {
deleteMyMessengerConnection(connectionId: $connectionId)
}
`;
/**
* __useDeleteMyMessengerConnectionMutation__
*
* To run a mutation, you first call `useDeleteMyMessengerConnectionMutation` within a Vue component and pass it any options that fit your needs.
* When your component renders, `useDeleteMyMessengerConnectionMutation` 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 } = useDeleteMyMessengerConnectionMutation({
* variables: {
* connectionId: // value for 'connectionId'
* },
* });
*/
export function useDeleteMyMessengerConnectionMutation(options: VueApolloComposable.UseMutationOptions<DeleteMyMessengerConnectionMutation, DeleteMyMessengerConnectionMutationVariables> | ReactiveFunction<VueApolloComposable.UseMutationOptions<DeleteMyMessengerConnectionMutation, DeleteMyMessengerConnectionMutationVariables>> = {}) {
return VueApolloComposable.useMutation<DeleteMyMessengerConnectionMutation, DeleteMyMessengerConnectionMutationVariables>(DeleteMyMessengerConnectionDocument, options);
}
export type DeleteMyMessengerConnectionMutationCompositionFunctionResult = VueApolloComposable.UseMutationReturn<DeleteMyMessengerConnectionMutation, DeleteMyMessengerConnectionMutationVariables>;
export const MyCounterpartyProfileDocument = gql`
query MyCounterpartyProfile {
myCounterpartyProfile {
@@ -2804,3 +2981,126 @@ export function useUpsertMyCounterpartyProfileMutation(options: VueApolloComposa
return VueApolloComposable.useMutation<UpsertMyCounterpartyProfileMutation, UpsertMyCounterpartyProfileMutationVariables>(UpsertMyCounterpartyProfileDocument, options);
}
export type UpsertMyCounterpartyProfileMutationCompositionFunctionResult = VueApolloComposable.UseMutationReturn<UpsertMyCounterpartyProfileMutation, UpsertMyCounterpartyProfileMutationVariables>;
export const CatalogProductTypeSettingsDocument = gql`
query CatalogProductTypeSettings {
catalogProductTypeSettings {
productType
showQuantityPerBox
allowCustomLength
customLengthMinM
customLengthMaxM
customLengthStepM
allowCustomSleeveBrand
allowCustomLabel
widthOptionsMm
lengthOptionsM
thicknessOptionsMicron
sleeveOptions
colorOptions
labelOptions
}
}
`;
/**
* __useCatalogProductTypeSettingsQuery__
*
* To run a query within a Vue component, call `useCatalogProductTypeSettingsQuery` and pass it any options that fit your needs.
* When your component renders, `useCatalogProductTypeSettingsQuery` returns an object from Apollo Client that contains result, loading and error properties
* you can use to render your UI.
*
* @param options that will be passed into the query, supported options are listed on: https://v4.apollo.vuejs.org/guide-composable/query.html#options;
*
* @example
* const { result, loading, error } = useCatalogProductTypeSettingsQuery();
*/
export function useCatalogProductTypeSettingsQuery(options: VueApolloComposable.UseQueryOptions<CatalogProductTypeSettingsQuery, CatalogProductTypeSettingsQueryVariables> | VueCompositionApi.Ref<VueApolloComposable.UseQueryOptions<CatalogProductTypeSettingsQuery, CatalogProductTypeSettingsQueryVariables>> | ReactiveFunction<VueApolloComposable.UseQueryOptions<CatalogProductTypeSettingsQuery, CatalogProductTypeSettingsQueryVariables>> = {}) {
return VueApolloComposable.useQuery<CatalogProductTypeSettingsQuery, CatalogProductTypeSettingsQueryVariables>(CatalogProductTypeSettingsDocument, {}, options);
}
export function useCatalogProductTypeSettingsLazyQuery(options: VueApolloComposable.UseQueryOptions<CatalogProductTypeSettingsQuery, CatalogProductTypeSettingsQueryVariables> | VueCompositionApi.Ref<VueApolloComposable.UseQueryOptions<CatalogProductTypeSettingsQuery, CatalogProductTypeSettingsQueryVariables>> | ReactiveFunction<VueApolloComposable.UseQueryOptions<CatalogProductTypeSettingsQuery, CatalogProductTypeSettingsQueryVariables>> = {}) {
return VueApolloComposable.useLazyQuery<CatalogProductTypeSettingsQuery, CatalogProductTypeSettingsQueryVariables>(CatalogProductTypeSettingsDocument, {}, options);
}
export type CatalogProductTypeSettingsQueryCompositionFunctionResult = VueApolloComposable.UseQueryReturn<CatalogProductTypeSettingsQuery, CatalogProductTypeSettingsQueryVariables>;
export const IntegrationSyncDashboardDocument = gql`
query IntegrationSyncDashboard {
integrationSyncDashboard {
generatedAt
lastActivityAt
totalOrders
totalProducts
totalClients
items {
id
title
description
source
syncedCount
lastSyncedAt
status
note
}
}
}
`;
/**
* __useIntegrationSyncDashboardQuery__
*
* To run a query within a Vue component, call `useIntegrationSyncDashboardQuery` and pass it any options that fit your needs.
* When your component renders, `useIntegrationSyncDashboardQuery` 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 } = useIntegrationSyncDashboardQuery();
*/
export function useIntegrationSyncDashboardQuery(options: VueApolloComposable.UseQueryOptions<IntegrationSyncDashboardQuery, IntegrationSyncDashboardQueryVariables> | VueCompositionApi.Ref<VueApolloComposable.UseQueryOptions<IntegrationSyncDashboardQuery, IntegrationSyncDashboardQueryVariables>> | ReactiveFunction<VueApolloComposable.UseQueryOptions<IntegrationSyncDashboardQuery, IntegrationSyncDashboardQueryVariables>> = {}) {
return VueApolloComposable.useQuery<IntegrationSyncDashboardQuery, IntegrationSyncDashboardQueryVariables>(IntegrationSyncDashboardDocument, {}, options);
}
export function useIntegrationSyncDashboardLazyQuery(options: VueApolloComposable.UseQueryOptions<IntegrationSyncDashboardQuery, IntegrationSyncDashboardQueryVariables> | VueCompositionApi.Ref<VueApolloComposable.UseQueryOptions<IntegrationSyncDashboardQuery, IntegrationSyncDashboardQueryVariables>> | ReactiveFunction<VueApolloComposable.UseQueryOptions<IntegrationSyncDashboardQuery, IntegrationSyncDashboardQueryVariables>> = {}) {
return VueApolloComposable.useLazyQuery<IntegrationSyncDashboardQuery, IntegrationSyncDashboardQueryVariables>(IntegrationSyncDashboardDocument, {}, options);
}
export type IntegrationSyncDashboardQueryCompositionFunctionResult = VueApolloComposable.UseQueryReturn<IntegrationSyncDashboardQuery, IntegrationSyncDashboardQueryVariables>;
export const UpsertCatalogProductTypeSettingDocument = gql`
mutation UpsertCatalogProductTypeSetting($input: UpsertCatalogProductTypeSettingInput!) {
upsertCatalogProductTypeSetting(input: $input) {
productType
showQuantityPerBox
allowCustomLength
customLengthMinM
customLengthMaxM
customLengthStepM
allowCustomSleeveBrand
allowCustomLabel
widthOptionsMm
lengthOptionsM
thicknessOptionsMicron
sleeveOptions
colorOptions
labelOptions
}
}
`;
/**
* __useUpsertCatalogProductTypeSettingMutation__
*
* To run a mutation, you first call `useUpsertCatalogProductTypeSettingMutation` within a Vue component and pass it any options that fit your needs.
* When your component renders, `useUpsertCatalogProductTypeSettingMutation` returns an object that includes:
* - A mutate function that you can call at any time to execute the mutation
* - Several other properties: https://v4.apollo.vuejs.org/api/use-mutation.html#return
*
* @param options that will be passed into the mutation, supported options are listed on: https://v4.apollo.vuejs.org/guide-composable/mutation.html#options;
*
* @example
* const { mutate, loading, error, onDone } = useUpsertCatalogProductTypeSettingMutation({
* variables: {
* input: // value for 'input'
* },
* });
*/
export function useUpsertCatalogProductTypeSettingMutation(options: VueApolloComposable.UseMutationOptions<UpsertCatalogProductTypeSettingMutation, UpsertCatalogProductTypeSettingMutationVariables> | ReactiveFunction<VueApolloComposable.UseMutationOptions<UpsertCatalogProductTypeSettingMutation, UpsertCatalogProductTypeSettingMutationVariables>> = {}) {
return VueApolloComposable.useMutation<UpsertCatalogProductTypeSettingMutation, UpsertCatalogProductTypeSettingMutationVariables>(UpsertCatalogProductTypeSettingDocument, options);
}
export type UpsertCatalogProductTypeSettingMutationCompositionFunctionResult = VueApolloComposable.UseMutationReturn<UpsertCatalogProductTypeSettingMutation, UpsertCatalogProductTypeSettingMutationVariables>;

View File

@@ -18,6 +18,7 @@ type MessengerStartInput = {
export function useMessengerStart() {
const pendingChannel = ref<MessengerChannel | null>(null);
const maxMiniApp = useMaxMiniApp();
async function openMessengerBot({ channel, baseUrl, email, redirectPath }: MessengerStartInput) {
pendingChannel.value = channel;
@@ -38,8 +39,18 @@ export function useMessengerStart() {
const startUrl = buildMessengerBotStartUrl(baseUrl, payload.startToken);
if (import.meta.client) {
if (
channel === 'MAX'
&& maxMiniApp.isAvailable.value
&& startUrl.startsWith('https://max.ru/')
&& typeof maxMiniApp.webApp.value?.openMaxLink === 'function'
) {
maxMiniApp.webApp.value.openMaxLink(startUrl);
}
else {
window.open(startUrl, '_blank', 'noopener,noreferrer');
}
}
return payload;
}

View File

@@ -9,6 +9,8 @@ type OrderStatusCode =
| 'IN_PROGRESS'
| 'COMPLETED';
export type OrderStatusTone = 'warning' | 'info' | 'success' | 'danger';
type TimelineStage = {
code: string;
label: string;
@@ -17,6 +19,11 @@ type TimelineStage = {
state: 'done' | 'current' | 'upcoming';
};
type StatusBadgePresentation = {
label: string;
tone: OrderStatusTone;
};
type StatusPresentation = {
title: string;
summary: string;
@@ -37,6 +44,18 @@ const DAY_FORMATTER = new Intl.DateTimeFormat('ru-RU', {
month: 'long',
});
const STATUS_BADGE_MAP: Record<string, StatusBadgePresentation> = {
NEW: { label: 'Заявка', tone: 'warning' },
MANAGER_PROCESSING: { label: 'Готовим предложение', tone: 'warning' },
WAITING_DOUBLE_CONFIRM: { label: 'Предложение', tone: 'info' },
CLIENT_REJECTED: { label: 'Отклонен', tone: 'danger' },
MANAGER_REJECTED: { label: 'Отклонен', tone: 'danger' },
MANAGER_BLOCKED: { label: 'Пауза', tone: 'warning' },
CONFIRMED: { label: 'Производство', tone: 'info' },
IN_PROGRESS: { label: 'Отгрузка', tone: 'success' },
COMPLETED: { label: 'Доставка', tone: 'success' },
};
function addDays(date: Date, days: number) {
const next = new Date(date);
next.setDate(next.getDate() + days);
@@ -149,14 +168,14 @@ function buildClientStages(status: string, createdAt: string | Date): StatusPres
stages: [
{
code: 'NEW',
label: 'Заказ создан',
label: 'Заявка',
note: 'Заказ принят в обработку.',
dateLabel: formatDay(dates.created),
state: 'done',
},
{
code: status,
label: 'Заказ остановлен',
label: 'Отклонен',
note: 'Дальнейшее исполнение не планируется.',
dateLabel: formatDay(dates.approval),
state: 'current',
@@ -172,14 +191,14 @@ function buildClientStages(status: string, createdAt: string | Date): StatusPres
stages: [
{
code: 'NEW',
label: 'Заказ создан',
label: 'Заявка',
note: 'Заказ принят в обработку.',
dateLabel: formatDay(dates.created),
state: 'done',
},
{
code: status,
label: 'Уточняем детали',
label: 'Пауза',
note: 'После уточнения покажем плановые даты по исполнению.',
dateLabel: formatDay(dates.approval),
state: 'current',
@@ -193,31 +212,45 @@ function buildClientStages(status: string, createdAt: string | Date): StatusPres
const stages: TimelineStage[] = [
{
code: 'NEW',
label: 'Заказ создан',
label: 'Заявка',
note: 'Приняли заказ и начали обработку.',
dateLabel: formatDay(dates.created),
state: currentIndex > 0 ? 'done' : 'current',
state: currentIndex > 0 ? 'done' : currentIndex === 0 ? 'current' : 'upcoming',
},
{
code: 'MANAGER_PROCESSING',
label: 'Готовим предложение',
note: 'Собираем стоимость и плановые сроки по заказу.',
dateLabel: formatDay(dates.offer),
state: currentIndex > 1 ? 'done' : currentIndex === 1 ? 'current' : 'upcoming',
},
{
code: 'WAITING_DOUBLE_CONFIRM',
label: 'Предложение',
note: 'Цена и условия готовы, ждём подтверждения.',
dateLabel: formatDay(dates.approval),
state: currentIndex > 2 ? 'done' : currentIndex === 2 ? 'current' : 'upcoming',
},
{
code: 'CONFIRMED',
label: 'Производство',
note: 'Плановая дата запуска или выхода из производства.',
dateLabel: formatDay(dates.production),
state: currentIndex > 3 ? 'done' : currentIndex >= 3 ? 'current' : 'upcoming',
state: currentIndex > 3 ? 'done' : currentIndex === 3 ? 'current' : 'upcoming',
},
{
code: 'IN_PROGRESS',
label: 'Отгрузка',
note: 'Плановая дата передачи в логистику.',
dateLabel: formatDay(dates.shipment),
state: currentIndex > 4 ? 'done' : currentIndex >= 4 ? 'current' : 'upcoming',
state: currentIndex > 4 ? 'done' : currentIndex === 4 ? 'current' : 'upcoming',
},
{
code: 'COMPLETED',
label: 'Доставка',
note: 'Плановая дата получения заказа.',
dateLabel: formatDay(dates.delivered),
state: currentIndex >= 5 ? 'current' : 'upcoming',
state: currentIndex === 5 ? 'current' : 'upcoming',
},
];
@@ -247,7 +280,7 @@ function buildClientStages(status: string, createdAt: string | Date): StatusPres
if (status === 'CONFIRMED') {
return {
title: ланируем производство',
title: 'Производство запланировано',
summary: `Ориентируемся на производство ${formatDay(dates.production)}, затем отгрузку и доставку по плану.`,
stages,
};
@@ -255,7 +288,7 @@ function buildClientStages(status: string, createdAt: string | Date): StatusPres
if (status === 'IN_PROGRESS') {
return {
title: 'Заказ в работе',
title: 'Готовим отгрузку',
summary: `Производство идёт. Следующая плановая дата: отгрузка около ${formatDay(dates.shipment)}.`,
stages,
};
@@ -268,6 +301,13 @@ function buildClientStages(status: string, createdAt: string | Date): StatusPres
};
}
export function getOrderStatusBadgePresentation(status: string): StatusBadgePresentation {
return STATUS_BADGE_MAP[status] ?? {
label: status,
tone: 'info',
};
}
export function getOrderStatusPresentation(
status: string,
createdAt: string | Date,

View File

@@ -24,6 +24,12 @@ const availableBalance = computed(() => bonusAccount.value?.availableBalance ??
const canWithdraw = computed(() => availableBalance.value >= 100);
const selectedEntry = computed(() => String(route.query.entry || '').trim());
const rewardCards = [
{ id: 'ozon-3000', store: 'Ozon', title: 'Подарочная карта Ozon', amount: 3000 },
{ id: 'wildberries-4000', store: 'Wildberries', title: 'Подарочная карта Wildberries', amount: 4000 },
{ id: 'mvideo-5000', store: 'М.Видео', title: 'Подарочная карта М.Видео', amount: 5000 },
];
const entryTitle = computed(() => {
if (selectedEntry.value.includes('withdrawal')) {
return 'Вы открыли бонусную программу из уведомления о выводе.';
@@ -115,14 +121,11 @@ async function submitWithdrawal() {
</h1>
<p class="bonus-program-copy">
{{ entryTitle }}
Здесь отдельно живут баланс, начисления, выводы и переходы из бонусных уведомлений.
Здесь отдельно живут история начислений, магазин вознаграждений и выводы.
</p>
</div>
<div class="flex flex-wrap gap-3">
<NuxtLink to="/messages" class="bonus-program-ghost-button">
Message board
</NuxtLink>
<NuxtLink to="/notifications" class="bonus-program-ghost-button">
История уведомлений
</NuxtLink>
@@ -293,6 +296,36 @@ async function submitWithdrawal() {
</div>
</article>
</section>
<article class="bonus-program-panel">
<div class="flex items-center justify-between gap-3">
<div>
<p class="bonus-program-caption">Магазин</p>
<h2 class="mt-2 text-2xl font-black tracking-[-0.04em] text-white">Вознаграждения</h2>
</div>
<span class="rounded-full border border-white/10 bg-white/5 px-3 py-1 text-xs font-semibold uppercase tracking-[0.12em] text-white/55">
{{ rewardCards.length }}
</span>
</div>
<div class="mt-5 grid gap-3 md:grid-cols-3">
<article
v-for="reward in rewardCards"
:key="reward.id"
class="rounded-[24px] border border-white/10 bg-white/[0.04] p-4"
>
<p class="text-xs font-semibold uppercase tracking-[0.14em] text-white/45">
{{ reward.store }}
</p>
<h3 class="mt-3 text-lg font-bold text-white">
{{ reward.title }}
</h3>
<p class="mt-4 text-sm font-semibold text-white/70">
{{ formatMoney(reward.amount) }} бонусов
</p>
</article>
</div>
</article>
</template>
</section>
</template>

View File

@@ -1,12 +1,15 @@
<script setup lang="ts">
import { useQuery } from '@vue/apollo-composable';
import { useMutation, useQuery } from '@vue/apollo-composable';
import {
CreateBonusProgramLinkDocument,
ManagerBonusAccountDocument,
type ManagerBonusAccountQuery,
} from '~/composables/graphql/generated';
definePageMeta({
middleware: ['manager-only'],
path: '/admin/bonuses/balances/:userId',
alias: ['/bonus-system/:userId'],
});
type TransactionItem = ManagerBonusAccountQuery['managerBonusAccount']['transactions'][number];
@@ -14,6 +17,10 @@ type PendingWithdrawalItem = ManagerBonusAccountQuery['managerBonusAccount']['pe
const route = useRoute();
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, () => ({
userId: userId.value,
@@ -33,12 +40,57 @@ function formatAmount(value: number) {
function formatDateTime(value: string) {
return new Date(value).toLocaleString('ru-RU');
}
function withdrawalStatusLabel(status: string) {
if (status === 'APPROVED') {
return 'Проведена';
}
if (status === 'REJECTED') {
return 'Отклонена';
}
return 'На проверке';
}
function withdrawalStatusClass(status: string) {
if (status === 'APPROVED') {
return 'bg-[#def7e8] text-[#0d854a]';
}
if (status === 'REJECTED') {
return 'bg-[#fde8ea] text-[#b73742]';
}
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>
<template>
<section class="space-y-6">
<NuxtLink to="/bonus-system" class="text-sm font-semibold text-[#0d854a]"> Назад к бонусам</NuxtLink>
<div v-if="bonusAccountQuery.loading.value" class="manager-empty-state">
Загружаем бонусный счёт...
</div>
@@ -49,74 +101,151 @@ function formatDateTime(value: string) {
<template v-else>
<div class="space-y-4">
<div class="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div class="space-y-1">
<h1 class="text-3xl font-extrabold text-[#123824]">{{ bonusAccount.fullName }}</h1>
<p v-if="bonusAccount.companyName || bonusAccount.email" class="text-sm text-[#5c7b69]">
{{ bonusAccount.companyName || bonusAccount.email }}
</p>
</div>
<div class="text-left sm:text-right">
<UiBackHeader
to="/admin/bonuses/balances"
back-label="Назад к бонусным счетам"
:title="`Бонусный счёт ${bonusAccount.fullName}`"
:subtitle="bonusAccount.companyName || bonusAccount.email || undefined"
>
<template #actions>
<div class="flex flex-col gap-3 md:items-end">
<div class="text-left md:text-right">
<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>
</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>
</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 v-if="pendingWithdrawals.length" class="surface-card rounded-[32px] p-6">
<p class="text-lg font-bold text-[#123824]">Выводы</p>
<div class="mt-4 space-y-3">
<article
v-for="withdrawal in pendingWithdrawals"
:key="withdrawal.id"
class="rounded-[24px] bg-[#f6fbf8] px-4 py-4"
<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]"
>
<div class="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
Открыть
</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 class="space-y-1">
<p class="text-sm font-semibold text-[#123824]">{{ formatAmount(withdrawal.amount) }}</p>
<p class="text-sm text-[#355947]">Создано {{ formatDateTime(withdrawal.createdAt) }}</p>
<p v-if="withdrawal.reviewComment" class="text-sm text-[#355947]">{{ withdrawal.reviewComment }}</p>
<p class="text-lg font-bold text-[#123824]">Заявки на выплату</p>
<p class="text-sm text-[#5c7b69]">Все активные выплаты по этому бонусному счёту.</p>
</div>
<NuxtLink
:to="`/bonus-system/withdrawals/${withdrawal.id}`"
class="text-sm font-semibold text-[#0d854a]"
v-for="withdrawal in pendingWithdrawals"
:key="withdrawal.id"
:to="`/admin/bonuses/requests/${withdrawal.id}`"
class="surface-card surface-card-interactive block rounded-[28px] px-5 py-4"
>
Проверить выплату
</NuxtLink>
</div>
</article>
</div>
<div class="grid gap-4 md:grid-cols-[180px_minmax(0,1fr)_170px_140px] md:items-center md:gap-6">
<div class="space-y-1">
<p class="text-xs font-bold uppercase tracking-[0.16em] text-[#6a8a76]">Заявка</p>
<p class="text-base font-bold text-[#123824]">WD-{{ withdrawal.id.slice(-6).toUpperCase() }}</p>
</div>
<div class="surface-card rounded-[32px] p-6">
<div class="min-w-0 space-y-1">
<p class="text-sm font-semibold text-[#123824]">Создано {{ formatDateTime(withdrawal.createdAt) }}</p>
<p v-if="withdrawal.reviewComment" class="truncate text-sm text-[#5c7b69]">{{ withdrawal.reviewComment }}</p>
</div>
<div>
<span class="rounded-full px-3 py-1 text-sm font-semibold" :class="withdrawalStatusClass(withdrawal.status)">
{{ withdrawalStatusLabel(withdrawal.status) }}
</span>
</div>
<div class="text-left md:text-right">
<p class="text-base font-bold text-[#123824]">{{ formatAmount(withdrawal.amount) }} </p>
</div>
</div>
</NuxtLink>
</div>
<div class="space-y-3">
<div class="space-y-1">
<p class="text-lg font-bold text-[#123824]">Транзакции</p>
<p class="text-sm text-[#5c7b69]">История начислений и операций по бонусному счёту.</p>
</div>
<div v-if="transactions.length === 0" class="manager-empty-state mt-4">
Начислений пока нет.
</div>
<div v-else class="mt-4 space-y-3">
<div v-else class="space-y-3">
<article
v-for="transaction in transactions"
:key="transaction.id"
class="rounded-[24px] bg-[#f6fbf8] px-4 py-4"
class="surface-card rounded-[28px] px-5 py-4"
>
<div class="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div class="space-y-1">
<p class="text-base font-semibold text-[#123824]">+{{ formatAmount(transaction.amount) }}</p>
<p class="text-sm text-[#355947]">{{ transaction.reason }}</p>
<p class="text-xs text-[#5c7b69]">{{ formatDateTime(transaction.createdAt) }}</p>
<div class="grid gap-4 md:grid-cols-[140px_minmax(0,1fr)_180px_140px] md:items-center md:gap-6">
<div>
<p class="text-base font-bold text-[#123824]">+{{ formatAmount(transaction.amount) }} </p>
</div>
<div class="min-w-0 space-y-1">
<p class="text-sm font-semibold text-[#123824]">Начисление</p>
<p class="text-sm text-[#355947]">{{ transaction.reason }}</p>
</div>
<div class="text-sm text-[#5c7b69]">
{{ formatDateTime(transaction.createdAt) }}
</div>
<div class="text-left md:text-right">
<NuxtLink
v-if="transaction.orderId"
:to="`/client-orders/${transaction.orderId}`"
:to="`/admin/orders/${transaction.orderId}`"
class="text-sm font-semibold text-[#0d854a]"
>
Открыть заказ
</NuxtLink>
</div>
</div>
</article>
</div>
</div>

View File

@@ -2,11 +2,9 @@
import { useQuery } from '@vue/apollo-composable';
import {
ManagerBonusBalancesDocument,
ManagerReferralLinksDocument,
ManagerUsersDocument,
ManagerWithdrawalRequestsDocument,
type ManagerBonusBalancesQuery,
type ManagerReferralLinksQuery,
type ManagerUsersQuery,
type ManagerWithdrawalRequestsQuery,
} from '~/composables/graphql/generated';
@@ -14,10 +12,11 @@ import { messengerConnectionAvatarSrc } from '~/composables/useMessengerConnecti
definePageMeta({
middleware: ['manager-only'],
path: '/admin/bonuses/:section(balances|requests|rewards)?',
alias: ['/bonus-system'],
});
type BalanceItem = ManagerBonusBalancesQuery['managerBonusBalances'][number];
type ReferralLinkItem = ManagerReferralLinksQuery['managerReferralLinks'][number];
type ManagerUserItem = ManagerUsersQuery['managerUsers'][number];
type WithdrawalItem = ManagerWithdrawalRequestsQuery['managerWithdrawalRequests'][number];
type ProductCard = {
@@ -25,26 +24,23 @@ type ProductCard = {
store: string;
title: string;
amount: number;
subtitle: string;
gradient: string;
tags: string[];
};
const route = useRoute();
const search = ref('');
const balancesQuery = useQuery(ManagerBonusBalancesDocument);
const referralLinksQuery = useQuery(ManagerReferralLinksDocument);
const usersQuery = useQuery(ManagerUsersDocument);
const withdrawalsQuery = useQuery(ManagerWithdrawalRequestsDocument, {
status: 'PENDING',
});
const activeTab = computed<'balances' | 'withdrawals' | 'products'>(() => {
if (route.query.tab === 'withdrawals') {
const activeTab = computed<'balances' | 'withdrawals' | 'rewards'>(() => {
if (route.path === '/admin/bonuses/requests') {
return 'withdrawals';
}
if (route.query.tab === 'products' || route.query.tab === 'manager') {
return 'products';
if (route.path === '/admin/bonuses/rewards') {
return 'rewards';
}
return 'balances';
});
@@ -55,86 +51,55 @@ const productCards: ProductCard[] = [
store: 'Ozon',
title: 'Подарочная карта Ozon',
amount: 3000,
subtitle: 'Универсальная карта для маркетплейса: техника, дом и повседневные покупки.',
gradient: 'linear-gradient(135deg, #38b6ff 0%, #1369ff 55%, #0b2f72 100%)',
tags: ['Маркетплейс', 'Электронная карта', '3 000 ₽'],
},
{
id: 'ozon-5000',
store: 'Ozon',
title: 'Подарочная карта Ozon',
amount: 5000,
subtitle: 'Крупный номинал для заметных подарков и сезонных закупок.',
gradient: 'linear-gradient(135deg, #65d0ff 0%, #247bff 52%, #12315e 100%)',
tags: ['Маркетплейс', 'Топ-номинал', '5 000 ₽'],
},
{
id: 'wildberries-3000',
store: 'Wildberries',
title: 'Подарочная карта Wildberries',
amount: 3000,
subtitle: 'Подходит для одежды, дома и повседневных мелочей в одном каталоге.',
gradient: 'linear-gradient(135deg, #d84dff 0%, #8b27ff 52%, #39006a 100%)',
tags: ['Fashion', 'Маркетплейс', '3 000 ₽'],
},
{
id: 'wildberries-4000',
store: 'Wildberries',
title: 'Подарочная карта Wildberries',
amount: 4000,
subtitle: 'Средний номинал для fashion-покупок и товаров для дома.',
gradient: 'linear-gradient(135deg, #ef7cff 0%, #a12dff 50%, #4c0b7d 100%)',
tags: ['Одежда', 'Дом', '4 000 ₽'],
},
{
id: 'mvideo-4000',
store: 'М.Видео',
title: 'Подарочная карта М.Видео',
amount: 4000,
subtitle: 'Для техники, аксессуаров и бытовой электроники.',
gradient: 'linear-gradient(135deg, #ff9461 0%, #ff5630 48%, #821414 100%)',
tags: ['Техника', 'Электроника', '4 000 ₽'],
},
{
id: 'mvideo-5000',
store: 'М.Видео',
title: 'Подарочная карта М.Видео',
amount: 5000,
subtitle: 'Максимальный номинал для заметных подарков и апгрейдов рабочего места.',
gradient: 'linear-gradient(135deg, #ffb17e 0%, #ff6842 50%, #8f1818 100%)',
tags: ['Электроника', 'Подарок', '5 000 ₽'],
},
];
const balances = computed<BalanceItem[]>(() => balancesQuery.result.value?.managerBonusBalances ?? []);
const referralLinks = computed<ReferralLinkItem[]>(() => referralLinksQuery.result.value?.managerReferralLinks ?? []);
const users = computed<ManagerUserItem[]>(() => usersQuery.result.value?.managerUsers ?? []);
const withdrawals = computed<WithdrawalItem[]>(() => withdrawalsQuery.result.value?.managerWithdrawalRequests ?? []);
const usersById = computed(() => new Map(users.value.map((user) => [user.id, user])));
const referralLinksByReferrer = computed(() => {
const grouped = new Map<string, ReferralLinkItem[]>();
for (const link of referralLinks.value) {
const existing = grouped.get(link.referrerId) ?? [];
existing.push(link);
grouped.set(link.referrerId, existing);
}
return grouped;
});
const filteredBalances = computed(() => {
const query = search.value.trim().toLowerCase();
return balances.value
.filter((item) => {
const links = referralLinksByReferrer.value.get(item.userId);
if (!links?.length) {
return false;
}
if (!query) {
return true;
}
@@ -144,22 +109,11 @@ const filteredBalances = computed(() => {
item.email,
item.companyName || '',
String(item.balance),
...links.flatMap((link) => [
link.refereeName,
link.refereeEmail,
link.refereeCompanyName || '',
String(link.bonusPercent),
]),
String(item.transactionsCount),
]
.join(' ')
.toLowerCase()
.includes(query);
})
.slice()
.sort((left, right) => {
const leftLatest = referralLinksByReferrer.value.get(left.userId)?.[0]?.createdAt ?? '';
const rightLatest = referralLinksByReferrer.value.get(right.userId)?.[0]?.createdAt ?? '';
return rightLatest.localeCompare(leftLatest);
});
});
@@ -196,9 +150,7 @@ const filteredProducts = computed(() => {
return [
item.store,
item.title,
item.subtitle,
String(item.amount),
...item.tags,
]
.join(' ')
.toLowerCase()
@@ -230,6 +182,13 @@ const {
resetKeys: [search, activeTab],
});
const WITHDRAWAL_DATE_FORMATTER = new Intl.DateTimeFormat('ru-RU', {
day: '2-digit',
month: 'short',
hour: '2-digit',
minute: '2-digit',
});
function userInitials(fullName: string) {
const parts = fullName
.trim()
@@ -250,6 +209,59 @@ function formatAmount(value: number) {
maximumFractionDigits: 2,
}).format(value);
}
function formatWithdrawalCode(id: string) {
return `WD-${id.slice(-6).toUpperCase()}`;
}
function formatDateTime(value: string) {
return WITHDRAWAL_DATE_FORMATTER.format(new Date(value));
}
function withdrawalStatusLabel(status: string) {
if (status === 'APPROVED') {
return 'Подтверждена';
}
if (status === 'REJECTED') {
return 'Отклонена';
}
return 'На проверке';
}
function withdrawalStatusClass(status: string) {
if (status === 'APPROVED') {
return 'bg-[#def7e8] text-[#0d854a]';
}
if (status === 'REJECTED') {
return 'bg-[#fde8ea] text-[#b73742]';
}
return 'bg-[#fff3d8] text-[#9a6100]';
}
function requesterMeta(withdrawal: WithdrawalItem) {
const requester = usersById.value.get(withdrawal.requesterId);
const fullName = withdrawal.requesterFullName;
return {
avatarSrc: messengerConnectionAvatarSrc(requester?.telegramConnection),
initials: userInitials(fullName),
companyName: withdrawal.companyName || requester?.companyName || '',
};
}
function productVisualLabel(product: ProductCard) {
return product.store
.replace(/[^A-Za-zА-Яа-яЁё0-9]+/g, ' ')
.trim()
.split(/\s+/)
.slice(0, 2)
.map((part) => part.charAt(0).toUpperCase())
.join('');
}
function compactProductTitle(product: ProductCard) {
return `Подарочная карта ${product.store}`;
}
</script>
<template>
@@ -258,30 +270,39 @@ function formatAmount(value: number) {
v-model="search"
title="Бонусы"
:search-placeholder="activeTab === 'balances'
? 'Клиент, связанный клиент, email или процент'
? 'Клиент, связанный клиент или email'
: activeTab === 'withdrawals'
? 'Пользователь, сумма или статус'
: 'Магазин, номинал или тип карты'"
/>
? 'Номер выплаты, клиент или сумма'
: 'Название или номинал'"
>
<template #controls>
<NuxtLink
v-if="activeTab === 'balances'"
to="/admin/bonuses/links/new"
class="btn btn-primary border-0"
>
Добавить
</NuxtLink>
</template>
</UiSectionSearchHero>
<template v-if="activeTab === 'balances'">
<div v-if="balancesQuery.loading.value || referralLinksQuery.loading.value || usersQuery.loading.value" class="manager-empty-state">
Загружаем балансы...
<div v-if="balancesQuery.loading.value || usersQuery.loading.value" class="manager-empty-state">
Загружаем бонусные счета...
</div>
<div v-else-if="filteredBalances.length === 0" class="manager-empty-state">
Бонусных связок пока нет.
Бонусных счетов пока нет.
</div>
<div v-else class="space-y-4">
<div class="grid gap-4 sm:grid-cols-2 xl:grid-cols-4 2xl:grid-cols-6">
<UsersGridCard
v-for="item in visibleBalances"
:key="item.userId"
:to="`/bonus-system/${item.userId}`"
:to="`/admin/bonuses/balances/${item.userId}`"
:full-name="item.fullName"
:avatar-src="messengerConnectionAvatarSrc(usersById.get(item.userId)?.telegramConnection)"
:initials="userInitials(item.fullName)"
meta-label="Доступный бонус"
:meta-value="formatAmount(item.balance)"
:meta-value="`${formatAmount(item.balance)} `"
/>
</div>
@@ -297,25 +318,7 @@ function formatAmount(value: number) {
</div>
</template>
<template v-else-if="activeTab === 'products'">
<div class="surface-card rounded-[32px] p-6">
<div class="flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between">
<div class="space-y-2">
<p class="text-[11px] font-semibold uppercase tracking-[0.18em] text-[#6a8a76]">Витрина магазина</p>
<h2 class="text-2xl font-black tracking-[-0.03em] text-[#123824]">Подарочные карты для бонусного каталога</h2>
<p class="max-w-3xl text-sm leading-6 text-[#557562]">
Вынес товары в отдельную вкладку без лишнего вложения. Пока это стартовый сет из популярных магазинов с номиналами от 3 000 до 5 000 рублей.
</p>
</div>
<div class="flex flex-wrap gap-2 text-sm text-[#355947]">
<span class="rounded-full bg-[#eef7f1] px-3 py-2 font-semibold">3 магазина</span>
<span class="rounded-full bg-[#eef7f1] px-3 py-2 font-semibold">6 карточек</span>
<span class="rounded-full bg-[#eef7f1] px-3 py-2 font-semibold">3 000-5 000 </span>
</div>
</div>
</div>
<template v-else-if="activeTab === 'rewards'">
<div v-if="filteredProducts.length === 0" class="manager-empty-state">
По текущему запросу товары не найдены.
</div>
@@ -323,36 +326,25 @@ function formatAmount(value: number) {
<article
v-for="product in filteredProducts"
:key="product.id"
class="surface-card overflow-hidden rounded-[32px]"
class="surface-card rounded-[28px] p-5"
>
<div class="p-5 text-white" :style="{ background: product.gradient }">
<div class="flex items-start justify-between gap-4">
<div>
<p class="text-[11px] font-semibold uppercase tracking-[0.18em] text-white/72">{{ product.store }}</p>
<h3 class="mt-3 text-2xl font-black tracking-[-0.03em]">{{ product.title }}</h3>
</div>
<span class="rounded-full bg-white/14 px-3 py-1 text-sm font-semibold backdrop-blur-sm">Витрина</span>
</div>
<div class="mt-10">
<p class="text-sm text-white/72">Номинал</p>
<p class="mt-2 text-4xl font-black leading-none">{{ formatAmount(product.amount) }} </p>
</div>
</div>
<div class="space-y-4 p-5">
<p class="text-sm leading-6 text-[#557562]">
{{ product.subtitle }}
</p>
<div class="flex flex-wrap gap-2">
<span
v-for="tag in product.tags"
:key="tag"
class="rounded-full bg-[#eef7f1] px-3 py-1.5 text-xs font-semibold text-[#0d854a]"
<div class="flex items-center gap-4">
<div
class="flex h-16 w-16 shrink-0 items-center justify-center rounded-[20px] text-lg font-black text-white"
:style="{ background: product.gradient }"
>
{{ tag }}
{{ productVisualLabel(product) }}
</div>
<div class="min-w-0 flex-1">
<div class="flex flex-wrap items-center gap-2">
<span class="rounded-full bg-[#eef5f0] px-3 py-1 text-xs font-bold uppercase tracking-[0.12em] text-[#4e7060]">
{{ product.store }}
</span>
<span class="rounded-full bg-[#fff8dc] px-3 py-1 text-xs font-bold text-[#7a5b00]">
{{ formatAmount(product.amount) }}
</span>
</div>
<h2 class="mt-3 text-lg font-bold leading-tight text-[#123824]">{{ compactProductTitle(product) }}</h2>
</div>
</div>
</article>
@@ -366,25 +358,55 @@ function formatAmount(value: number) {
<div v-else-if="filteredWithdrawals.length === 0" class="manager-empty-state">
Активных заявок на выплату сейчас нет.
</div>
<div v-else class="space-y-4">
<article
<div v-else class="space-y-3">
<NuxtLink
v-for="withdrawal in visibleWithdrawals"
:key="withdrawal.id"
class="surface-card rounded-3xl px-5 py-5"
:to="`/admin/bonuses/requests/${withdrawal.id}`"
class="surface-card surface-card-interactive block rounded-[30px] bg-white px-4 py-4 md:px-5"
>
<div class="flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
<div class="space-y-1">
<p class="text-sm font-semibold text-[#123824]">{{ withdrawal.requesterFullName }}</p>
<p class="text-sm text-[#355947]">{{ withdrawal.requesterEmail }}</p>
<p v-if="withdrawal.companyName" class="text-sm text-[#355947]">{{ withdrawal.companyName }}</p>
<p class="text-sm text-[#355947]">Сумма: {{ withdrawal.amount }}</p>
<p class="text-xs text-[#5c7b69]">{{ new Date(withdrawal.createdAt).toLocaleString() }}</p>
<div class="grid gap-4 md:grid-cols-[minmax(0,1fr)_minmax(0,1.4fr)_180px_140px] md:items-center md:gap-6">
<div class="min-w-0">
<h2 class="truncate text-lg font-bold text-[#123824]">{{ formatWithdrawalCode(withdrawal.id) }}</h2>
<p class="mt-1 text-sm text-[#688676]">{{ formatDateTime(withdrawal.createdAt) }}</p>
</div>
<div class="flex min-w-0 items-center gap-3">
<img
v-if="requesterMeta(withdrawal).avatarSrc"
:src="requesterMeta(withdrawal).avatarSrc"
:alt="withdrawal.requesterFullName"
class="h-12 w-12 rounded-[16px] object-cover"
>
<div
v-else
class="flex h-12 w-12 shrink-0 items-center justify-center rounded-[16px] bg-[linear-gradient(135deg,#dff7e9_0%,#c2ead3_100%)] text-sm font-black text-[#123824]"
>
{{ requesterMeta(withdrawal).initials }}
</div>
<div class="min-w-0">
<p class="truncate text-sm font-semibold text-[#123824]">{{ withdrawal.requesterFullName }}</p>
<p class="truncate text-sm text-[#557562]">
{{ requesterMeta(withdrawal).companyName || withdrawal.requesterEmail }}
</p>
</div>
</div>
<div class="flex items-center justify-start">
<span
class="rounded-full px-3 py-1 text-sm font-semibold"
:class="withdrawalStatusClass(withdrawal.status)"
>
{{ withdrawalStatusLabel(withdrawal.status) }}
</span>
</div>
<div class="text-left md:text-right">
<p class="text-base font-bold text-[#123824]">{{ formatAmount(withdrawal.amount) }} </p>
</div>
</div>
<NuxtLink :to="`/bonus-system/withdrawals/${withdrawal.id}`" class="btn btn-accent btn-sm border-0">
Проверить выплату
</NuxtLink>
</div>
</article>
<div
v-if="canLoadMoreWithdrawals"

View File

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

View File

@@ -4,6 +4,8 @@ import { AddBonusTransactionDocument } from '~/composables/graphql/generated';
definePageMeta({
middleware: ['manager-only'],
path: '/admin/bonuses/transactions/new',
alias: ['/bonus-system/transactions/new'],
});
const userId = ref('');
@@ -29,12 +31,11 @@ async function addBonus() {
<template>
<section class="space-y-6 max-w-3xl">
<NuxtLink to="/bonus-system" class="text-sm font-semibold text-[#0d854a]"> Назад к бонусам</NuxtLink>
<div class="manager-hero">
<p class="manager-eyebrow">Бонусы</p>
<h1 class="manager-title">Добавить бонусную транзакцию</h1>
</div>
<UiBackHeader
to="/admin/bonuses/balances"
back-label="Назад к бонусным счетам"
title="Добавить бонусную транзакцию"
/>
<div class="surface-card rounded-3xl p-5 space-y-3">
<label class="form-control">

View File

@@ -8,6 +8,8 @@ import {
definePageMeta({
middleware: ['manager-only'],
path: '/admin/bonuses/requests/:id',
alias: ['/bonus-system/withdrawals/:id'],
});
const route = useRoute();
@@ -19,14 +21,22 @@ const withdrawalsQuery = useQuery(ManagerWithdrawalRequestsDocument, {
});
const reviewMutation = useMutation(ReviewRewardWithdrawalDocument);
const decision = ref<'APPROVE' | 'REJECT'>('APPROVE');
const reviewComment = ref('');
const reviewResult = ref('');
const isProcessed = ref(true);
const savePending = computed(() => reviewMutation.loading.value);
const currentWithdrawal = computed(() =>
(withdrawalsQuery.result.value?.managerWithdrawalRequests ?? []).find((item: WithdrawalItem) => item.id === withdrawalId.value),
);
watch(currentWithdrawal, (withdrawal) => {
if (!withdrawal) {
return;
}
isProcessed.value = withdrawal.status !== 'REJECTED';
}, { immediate: true });
async function reviewWithdrawal() {
if (!currentWithdrawal.value) {
return;
@@ -35,8 +45,7 @@ async function reviewWithdrawal() {
const response = await reviewMutation.mutate({
input: {
withdrawalId: currentWithdrawal.value.id,
decision: decision.value,
reviewComment: reviewComment.value || undefined,
decision: isProcessed.value ? 'APPROVE' : 'REJECT',
},
});
@@ -46,9 +55,7 @@ async function reviewWithdrawal() {
</script>
<template>
<section class="space-y-6 max-w-3xl">
<NuxtLink to="/bonus-system" class="text-sm font-semibold text-[#0d854a]"> Назад к бонусам</NuxtLink>
<section class="space-y-6">
<div v-if="withdrawalsQuery.loading.value" class="manager-empty-state">
Загружаем заявку на вывод...
</div>
@@ -58,30 +65,42 @@ async function reviewWithdrawal() {
</div>
<template v-else>
<div class="manager-hero">
<p class="manager-eyebrow">Вывод</p>
<h1 class="manager-title">Проверка заявки на вывод</h1>
<p class="manager-copy">
{{ currentWithdrawal.requesterFullName }} · {{ currentWithdrawal.requesterEmail }} · Сумма: {{ currentWithdrawal.amount }}
</p>
</div>
<UiBackHeader
to="/admin/bonuses/requests"
back-label="Назад к бонусам"
title="Проверка заявки на вывод"
:subtitle="`${currentWithdrawal.requesterFullName} · ${currentWithdrawal.requesterEmail} · Сумма: ${currentWithdrawal.amount}`"
/>
<div class="surface-card rounded-3xl p-5 space-y-3">
<label class="form-control">
<span class="label-text">Решение</span>
<select v-model="decision" class="select manager-field w-full">
<option value="APPROVE">Одобрить</option>
<option value="REJECT">Отклонить</option>
</select>
<div class="surface-card rounded-3xl p-5 md:p-6">
<div class="space-y-5">
<label class="flex items-start gap-4 rounded-[24px] bg-[#f5faf7] px-4 py-4">
<input
v-model="isProcessed"
type="checkbox"
class="checkbox mt-1 border-[#b9d7c5] bg-white [--chkbg:#123824] [--chkfg:#ffffff]"
>
<span class="space-y-1">
<span class="block text-base font-bold text-[#123824]">Проведено</span>
<span class="block text-sm leading-6 text-[#5c7b69]">
Отметьте выплату как проведённую. Если галочка снята, заявка будет отклонена.
</span>
</span>
</label>
<label class="form-control">
<span class="label-text">Комментарий</span>
<textarea v-model="reviewComment" class="textarea manager-field min-h-28 w-full" placeholder="Комментарий для заявки" />
</label>
<div>
<button class="btn btn-primary border-0" @click="reviewWithdrawal">Сохранить решение</button>
<button
class="btn h-12 w-full rounded-full border-0 bg-[#123824] text-white shadow-[0_16px_32px_rgba(18,56,36,0.18)] hover:bg-[#0f2f20] disabled:border-0 disabled:bg-[#cfd8d2] disabled:text-[#6f8579]"
:disabled="savePending"
@click="reviewWithdrawal"
>
{{
savePending
? 'Сохраняем...'
: isProcessed
? 'Провести выплату'
: 'Отклонить заявку'
}}
</button>
</div>
</div>

View File

@@ -0,0 +1,394 @@
<script setup lang="ts">
import { useMutation, useQuery } from '@vue/apollo-composable';
import {
CatalogProductTypeSettingsDocument,
UpsertCatalogProductTypeSettingDocument,
type CatalogProductTypeSettingsQuery,
} from '~/composables/graphql/generated';
definePageMeta({
middleware: ['manager-only'],
path: '/admin/settings/catalog',
});
type CatalogSettingItem = CatalogProductTypeSettingsQuery['catalogProductTypeSettings'][number];
type OptionKey =
| 'widthOptionsMm'
| 'lengthOptionsM'
| 'thicknessOptionsMicron'
| 'sleeveOptions'
| 'colorOptions'
| 'labelOptions';
type OptionKind = 'number' | 'text';
type CatalogSettingForm = {
productType: string;
allowCustomLength: boolean;
customLengthMinM: string;
customLengthMaxM: string;
customLengthStepM: string;
allowCustomSleeveBrand: boolean;
allowCustomLabel: boolean;
widthOptionsMm: string[];
lengthOptionsM: string[];
thicknessOptionsMicron: string[];
sleeveOptions: string[];
colorOptions: string[];
labelOptions: string[];
};
type OptionGroupDefinition = {
key: OptionKey;
label: string;
placeholder: string;
kind: OptionKind;
suffix?: string;
};
const OPTION_GROUPS: OptionGroupDefinition[] = [
{ key: 'widthOptionsMm', label: 'Ширина', placeholder: 'Добавить ширину', kind: 'number', suffix: 'мм' },
{ key: 'lengthOptionsM', label: 'Длина', placeholder: 'Добавить длину', kind: 'number', suffix: 'м' },
{ key: 'thicknessOptionsMicron', label: 'Толщина', placeholder: 'Добавить толщину', kind: 'number', suffix: 'мкм' },
{ key: 'sleeveOptions', label: 'Втулка', placeholder: 'Добавить втулку', kind: 'text' },
{ key: 'colorOptions', label: 'Цвет', placeholder: 'Добавить цвет', kind: 'text' },
{ key: 'labelOptions', label: 'Надпись', placeholder: 'Добавить надпись', kind: 'text' },
];
const settingsQuery = useQuery(CatalogProductTypeSettingsDocument);
const saveSettingMutation = useMutation(UpsertCatalogProductTypeSettingDocument, { throws: 'never' });
const forms = reactive<Record<string, CatalogSettingForm>>({});
const isSavingAll = ref(false);
const saveSuccess = ref('');
const saveError = ref('');
const settings = computed<CatalogSettingItem[]>(() => settingsQuery.result.value?.catalogProductTypeSettings ?? []);
const isLoading = computed(() => settingsQuery.loading.value);
function normalizeText(value: string | null | undefined) {
return String(value ?? '').replaceAll(/\s+/g, ' ').trim();
}
function toInputValue(value: number | null | undefined) {
return value == null ? '' : String(value);
}
function parseOptionalInteger(value: string) {
const normalized = value.trim();
if (!normalized) {
return null;
}
const parsed = Number(normalized);
if (!Number.isInteger(parsed) || parsed <= 0) {
return null;
}
return parsed;
}
function normalizeOptionEntry(value: string, kind: OptionKind) {
if (kind === 'number') {
const parsed = parseOptionalInteger(value);
return parsed == null ? '' : String(parsed);
}
return normalizeText(value);
}
function normalizeOptionList(values: Array<string | number | null | undefined>, kind: OptionKind) {
const normalizedValues = values
.map((value) => normalizeOptionEntry(String(value ?? ''), kind))
.filter(Boolean);
return [...new Set(normalizedValues)].sort((left, right) => {
if (kind === 'number') {
return Number(left) - Number(right);
}
return left.localeCompare(right, 'ru');
});
}
function createForm(item: CatalogSettingItem): CatalogSettingForm {
return {
productType: item.productType,
allowCustomLength: item.allowCustomLength,
customLengthMinM: toInputValue(item.customLengthMinM),
customLengthMaxM: toInputValue(item.customLengthMaxM),
customLengthStepM: toInputValue(item.customLengthStepM),
allowCustomSleeveBrand: item.allowCustomSleeveBrand,
allowCustomLabel: item.allowCustomLabel,
widthOptionsMm: normalizeOptionList(item.widthOptionsMm, 'number'),
lengthOptionsM: normalizeOptionList(item.lengthOptionsM, 'number'),
thicknessOptionsMicron: normalizeOptionList(item.thicknessOptionsMicron, 'number'),
sleeveOptions: normalizeOptionList(item.sleeveOptions, 'text'),
colorOptions: normalizeOptionList(item.colorOptions, 'text'),
labelOptions: normalizeOptionList(item.labelOptions, 'text'),
};
}
function formFor(item: CatalogSettingItem) {
forms[item.productType] ??= createForm(item);
return forms[item.productType];
}
function addOption(form: CatalogSettingForm, group: OptionGroupDefinition, rawValue: string) {
const value = normalizeOptionEntry(rawValue, group.kind);
if (!value) {
return;
}
form[group.key] = normalizeOptionList([...form[group.key], value], group.kind);
}
function removeOption(form: CatalogSettingForm, groupKey: OptionKey, value: string) {
form[groupKey] = form[groupKey].filter((item) => item !== value);
}
function openAddOptionPrompt(form: CatalogSettingForm, group: OptionGroupDefinition) {
const value = window.prompt(group.placeholder, '');
if (value == null) {
return;
}
addOption(form, group, value);
}
function optionChipLabel(value: string, group: OptionGroupDefinition) {
return group.suffix ? `${value} ${group.suffix}` : value;
}
function parseIntegerOptionList(values: string[]) {
return normalizeOptionList(values, 'number').map((value) => Number(value));
}
function parseTextOptionList(values: string[]) {
return normalizeOptionList(values, 'text');
}
function enabledCustomizationCount(form: CatalogSettingForm) {
return [
form.allowCustomLength,
form.allowCustomSleeveBrand,
form.allowCustomLabel,
].filter(Boolean).length;
}
function filledParameterGroupCount(form: CatalogSettingForm) {
return OPTION_GROUPS.filter((group) => form[group.key].length > 0).length;
}
watch(
settings,
(items) => {
const activeTypes = new Set(items.map((item) => item.productType));
for (const productType of Object.keys(forms)) {
if (!activeTypes.has(productType)) {
Reflect.deleteProperty(forms, productType);
}
}
for (const item of items) {
forms[item.productType] = createForm(item);
}
},
{ immediate: true },
);
async function saveAllSettings() {
saveSuccess.value = '';
saveError.value = '';
isSavingAll.value = true;
for (const item of settings.value) {
const form = formFor(item);
const result = await saveSettingMutation.mutate({
input: {
productType: form.productType,
showQuantityPerBox: false,
allowCustomLength: form.allowCustomLength,
customLengthMinM: form.allowCustomLength ? parseOptionalInteger(form.customLengthMinM) : null,
customLengthMaxM: form.allowCustomLength ? parseOptionalInteger(form.customLengthMaxM) : null,
customLengthStepM: form.allowCustomLength ? parseOptionalInteger(form.customLengthStepM) : null,
allowCustomSleeveBrand: form.allowCustomSleeveBrand,
allowCustomLabel: form.allowCustomLabel,
widthOptionsMm: parseIntegerOptionList(form.widthOptionsMm),
lengthOptionsM: parseIntegerOptionList(form.lengthOptionsM),
thicknessOptionsMicron: parseIntegerOptionList(form.thicknessOptionsMicron),
sleeveOptions: parseTextOptionList(form.sleeveOptions),
colorOptions: parseTextOptionList(form.colorOptions),
labelOptions: parseTextOptionList(form.labelOptions),
},
});
if (!result?.data?.upsertCatalogProductTypeSetting) {
saveError.value = saveSettingMutation.error.value?.message || `Не удалось сохранить настройки для "${form.productType}".`;
isSavingAll.value = false;
return;
}
forms[item.productType] = createForm(result.data.upsertCatalogProductTypeSetting);
}
isSavingAll.value = false;
saveSuccess.value = 'Настройки сохранены.';
}
</script>
<template>
<section class="space-y-6">
<h1 class="text-3xl font-extrabold text-[#0f2f20]">Каталог</h1>
<div v-if="isLoading" class="manager-empty-state">
Загружаем настройки каталога...
</div>
<div v-else-if="settings.length === 0" class="manager-empty-state">
Типы товаров пока не появились в каталоге.
</div>
<div v-else class="space-y-3">
<details
v-for="item in settings"
:key="item.productType"
class="group rounded-[28px] bg-white shadow-[0_18px_38px_rgba(18,56,36,0.08)]"
>
<summary class="flex cursor-pointer list-none items-center justify-between gap-4 p-5">
<div class="min-w-0 space-y-1">
<h2 class="text-xl font-bold text-[#123824]">{{ item.productType }}</h2>
<p class="text-sm text-[#5a7667]">
{{ filledParameterGroupCount(formFor(item)) }} параметров, {{ enabledCustomizationCount(formFor(item)) }} кастомные возможности
</p>
</div>
<div class="flex items-center gap-3">
<span class="hidden text-sm font-semibold text-[#6a8a78] md:inline">Открыть</span>
<span class="inline-flex h-10 w-10 items-center justify-center rounded-full bg-[#eef7f1] text-[#1d5a3c] transition group-open:rotate-180">
<svg viewBox="0 0 20 20" fill="currentColor" class="h-5 w-5">
<path fill-rule="evenodd" d="M5.23 7.21a.75.75 0 0 1 1.06.02L10 11.168l3.71-3.938a.75.75 0 1 1 1.08 1.04l-4.25 4.512a.75.75 0 0 1-1.08 0L5.21 8.27a.75.75 0 0 1 .02-1.06Z" clip-rule="evenodd" />
</svg>
</span>
</div>
</summary>
<div class="space-y-5 border-t border-[#edf4ef] p-5">
<div class="space-y-3">
<label class="surface-card flex items-center gap-3 rounded-[22px] p-4">
<input v-model="formFor(item).allowCustomLength" type="checkbox" class="checkbox checkbox-success">
<span class="text-sm font-semibold text-[#123824]">Любая длина</span>
</label>
<label class="surface-card flex items-center gap-3 rounded-[22px] p-4">
<input v-model="formFor(item).allowCustomSleeveBrand" type="checkbox" class="checkbox checkbox-success">
<span class="text-sm font-semibold text-[#123824]">Логотип на втулке</span>
</label>
<label class="surface-card flex items-center gap-3 rounded-[22px] p-4">
<input v-model="formFor(item).allowCustomLabel" type="checkbox" class="checkbox checkbox-success">
<span class="text-sm font-semibold text-[#123824]">Нанесение надписи</span>
</label>
</div>
<div v-if="formFor(item).allowCustomLength" class="rounded-[24px] bg-[#f7fbf8] p-4">
<div class="mb-4 text-sm font-bold uppercase tracking-[0.12em] text-[#355947]">Диапазон длины</div>
<div class="grid gap-3 md:grid-cols-3">
<label class="space-y-2">
<span class="text-sm font-semibold text-[#123824]">Мин. длина, м</span>
<input
v-model="formFor(item).customLengthMinM"
type="number"
min="1"
step="1"
class="input manager-field w-full"
>
</label>
<label class="space-y-2">
<span class="text-sm font-semibold text-[#123824]">Макс. длина, м</span>
<input
v-model="formFor(item).customLengthMaxM"
type="number"
min="1"
step="1"
class="input manager-field w-full"
>
</label>
<label class="space-y-2">
<span class="text-sm font-semibold text-[#123824]">Шаг, м</span>
<input
v-model="formFor(item).customLengthStepM"
type="number"
min="1"
step="1"
class="input manager-field w-full"
>
</label>
</div>
</div>
<div class="rounded-[24px] bg-[#f7fbf8] p-4">
<div class="mb-4 text-sm font-bold uppercase tracking-[0.12em] text-[#355947]">Параметры</div>
<div class="space-y-4">
<div
v-for="group in OPTION_GROUPS"
:key="`${item.productType}-${group.key}`"
class="group rounded-[20px] bg-white p-4"
>
<div class="space-y-3">
<div class="flex items-center justify-between gap-3">
<div class="text-sm font-semibold text-[#123824]">{{ group.label }}</div>
<button
type="button"
class="inline-flex h-7 w-7 items-center justify-center rounded-full border border-[#d6e7dc] text-sm font-semibold text-[#6a8a78] opacity-0 transition group-hover:opacity-100 hover:border-[#9ccbb0] hover:text-[#155c3a]"
@click="openAddOptionPrompt(formFor(item), group)"
>
+
</button>
</div>
<div class="flex flex-wrap gap-2">
<button
v-for="value in formFor(item)[group.key]"
:key="`${item.productType}-${group.key}-${value}`"
type="button"
class="group/chip inline-flex items-center gap-2 rounded-full bg-[#eef7f1] px-3 py-1 text-xs font-semibold text-[#1d5a3c]"
@click="removeOption(formFor(item), group.key, value)"
>
<span>{{ optionChipLabel(value, group) }}</span>
<span class="text-[11px] leading-none text-[#6a8a78] opacity-0 transition group-hover/chip:opacity-100">×</span>
</button>
<span
v-if="formFor(item)[group.key].length === 0"
class="rounded-full border border-dashed border-[#d6e7dc] px-3 py-1 text-xs font-medium text-[#7b8f84]"
>
Пока пусто
</span>
</div>
</div>
</div>
</div>
</div>
</div>
</details>
</div>
<div v-if="saveSuccess || saveError" class="space-y-2 text-sm">
<p v-if="saveSuccess" class="font-semibold text-[#1c6b45]">{{ saveSuccess }}</p>
<p v-if="saveError" class="font-semibold text-[#c4472d]">{{ saveError }}</p>
</div>
<div v-if="settings.length" class="flex justify-end">
<button
class="btn h-11 rounded-full border-0 bg-[#139957] px-6 text-sm font-semibold text-white hover:bg-[#0d854a]"
:disabled="isSavingAll"
@click="saveAllSettings"
>
{{ isSavingAll ? 'Сохраняем…' : 'Сохранить' }}
</button>
</div>
</section>
</template>

View File

@@ -1,12 +1,15 @@
<script setup lang="ts">
import { useMutation, useQuery } from '@vue/apollo-composable';
import {
ManagerUsersDetailDocument,
ManagerSetOrderOfferDocument,
ManagerSetOrderStatusDocument,
OrderStatus,
OrderDetailDocument,
type OrderDetailQuery,
type ManagerUsersDetailQuery,
} from '~/composables/graphql/generated';
import { messengerConnectionAvatarSrc } from '~/composables/useMessengerConnectionPresentation';
import {
formatPrice,
} from '~/composables/useOrderDetailPresentation';
@@ -14,12 +17,15 @@ import { formatOrderCode } from '~/composables/useOrderCodePresentation';
definePageMeta({
middleware: ['manager-only'],
path: '/admin/orders/:id',
alias: ['/client-orders/:id'],
});
const route = useRoute();
const orderId = computed(() => String(route.params.id || ''));
type ManagerOrderItem = NonNullable<OrderDetailQuery['order']>;
type ManagerCustomerItem = ManagerUsersDetailQuery['managerUsers'][number];
type StatusOption = {
value: OrderStatus;
label: string;
@@ -28,6 +34,7 @@ type StatusOption = {
const orderQuery = useQuery(OrderDetailDocument, () => ({
id: orderId.value,
}));
const managerUsersQuery = useQuery(ManagerUsersDetailDocument);
const setOfferMutation = useMutation(ManagerSetOrderOfferDocument);
const setOrderStatusMutation = useMutation(ManagerSetOrderStatusDocument);
@@ -49,6 +56,28 @@ const currentOrder = computed<ManagerOrderItem | null>(() =>
);
const currentOrderCode = computed(() => formatOrderCode(currentOrder.value?.code));
const currentCustomer = computed<ManagerCustomerItem | null>(() => {
const customerId = currentOrder.value?.customerId;
if (!customerId) {
return null;
}
return (managerUsersQuery.result.value?.managerUsers ?? []).find((item) => item.id === customerId) ?? null;
});
function userInitials(fullName: string) {
const parts = fullName
.trim()
.split(/\s+/)
.filter(Boolean)
.slice(0, 2);
if (!parts.length) {
return 'FR';
}
return parts.map((part) => part[0]?.toUpperCase() ?? '').join('');
}
watch(
currentOrder,
@@ -314,20 +343,40 @@ watch(
</div>
<template v-else>
<div class="surface-card rounded-3xl px-5 py-4">
<div class="flex flex-wrap items-center gap-3">
<NuxtLink to="/client-orders" class="text-sm font-semibold text-[#0d854a]">
Назад к заказам клиентов
<UiBackHeader
to="/admin/orders"
back-label="Назад к заказам клиентов"
:title="`Заказ ${currentOrderCode}`"
>
<template #actions>
<NuxtLink
:to="`/admin/orders/clients/${currentOrder.customerId}`"
class="surface-card surface-card-interactive flex min-w-[220px] items-center gap-3 rounded-[24px] px-4 py-3"
>
<img
v-if="currentCustomer && messengerConnectionAvatarSrc(currentCustomer.telegramConnection)"
:src="messengerConnectionAvatarSrc(currentCustomer.telegramConnection)"
:alt="currentCustomer.fullName"
class="h-12 w-12 rounded-[16px] object-cover"
>
<div
v-else
class="flex h-12 w-12 shrink-0 items-center justify-center rounded-[16px] bg-[linear-gradient(135deg,#dff7e9_0%,#c2ead3_100%)] text-sm font-black text-[#123824]"
>
{{ userInitials(currentCustomer?.fullName || 'Fregat') }}
</div>
<div class="min-w-0 text-left">
<p class="truncate text-sm font-bold text-[#123824]">
{{ currentCustomer?.fullName || 'Клиент' }}
</p>
<p class="truncate text-xs text-[#5c7b69]">
{{ currentCustomer?.companyName || currentCustomer?.email || 'Открыть карточку клиента' }}
</p>
</div>
</NuxtLink>
<span class="hidden h-4 w-px bg-[#d8e4dd] md:block" />
<span class="text-[11px] font-semibold uppercase tracking-[0.18em] text-[#6a8a76]">
Заказ
</span>
<span class="text-lg font-black tracking-[-0.03em] text-[#123824]">
{{ currentOrderCode }}
</span>
</div>
</div>
</template>
</UiBackHeader>
<div class="space-y-4">
<div class="surface-card rounded-3xl p-5">

View File

@@ -15,6 +15,8 @@ import { formatPrice } from '~/composables/useOrderDetailPresentation';
definePageMeta({
middleware: ['manager-only'],
path: '/admin/orders',
alias: ['/client-orders'],
});
type ManagerOrderItem = ManagerOrdersQuery['managerOrders'][number];
@@ -264,7 +266,7 @@ const calendarOptions = computed(() => ({
};
},
eventClick: ({ event }: { event: { id: string } }) => {
void router.push(`/client-orders/${event.id}`);
void router.push(`/admin/orders/${event.id}`);
},
}));
</script>
@@ -333,7 +335,7 @@ const calendarOptions = computed(() => ({
<OrdersOrderSummaryCard
v-for="order in visibleOrders"
:key="order.id"
:to="`/client-orders/${order.id}`"
:to="`/admin/orders/${order.id}`"
:code="order.code"
:status="order.status"
:created-at="order.createdAt"

View File

@@ -13,6 +13,8 @@ import { messengerConnectionAvatarSrc } from '~/composables/useMessengerConnecti
definePageMeta({
middleware: ['manager-only'],
path: '/admin/orders/:mode(clients|requests)/:id',
alias: ['/clients/:id'],
});
type ManagerUserItem = ManagerUsersDetailQuery['managerUsers'][number];
@@ -21,8 +23,9 @@ type RequestItem = RegistrationRequestsQuery['registrationRequests'][number];
const route = useRoute();
const entityId = computed(() => String(route.params.id || ''));
const isRequestMode = computed(() => route.query.tab === 'requests');
const backTarget = computed(() => '/clients');
const entityMode = computed(() => String(route.params.mode || 'clients'));
const isRequestMode = computed(() => entityMode.value === 'requests');
const backTarget = computed(() => '/admin/orders/clients');
const usersQuery = useQuery(ManagerUsersDetailDocument);
const requestsQuery = useQuery(RegistrationRequestsDocument, {
@@ -104,8 +107,6 @@ async function rejectRequest() {
<template>
<section class="space-y-6">
<NuxtLink :to="backTarget" class="text-sm font-semibold text-[#0d854a]"> Назад к пользователям</NuxtLink>
<template v-if="isRequestMode">
<div v-if="requestsQuery.loading.value" class="manager-empty-state">
Загружаем карточку клиента...
@@ -116,18 +117,19 @@ async function rejectRequest() {
</div>
<template v-else>
<div class="flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
<div class="manager-hero">
<p class="manager-eyebrow">Заявка</p>
<h1 class="manager-title">{{ currentRequest.companyName }}</h1>
<p class="manager-copy">Контакт: {{ currentRequest.contactName }} · {{ currentRequest.email }}</p>
</div>
<UiBackHeader
:to="backTarget"
back-label="Назад к пользователям"
:title="`Заявка ${currentRequest.companyName}`"
:subtitle="`Контакт: ${currentRequest.contactName} · ${currentRequest.email}`"
>
<template #actions>
<div v-if="currentRequest.status === 'PENDING'" class="flex flex-wrap gap-2">
<button class="btn btn-success border-0" @click="approveRequest">Одобрить</button>
<button class="btn btn-error border-0" @click="rejectRequest">Отклонить</button>
</div>
</div>
</template>
</UiBackHeader>
<div class="grid gap-4 lg:grid-cols-3">
<div class="manager-stat-card">
@@ -158,6 +160,13 @@ async function rejectRequest() {
</div>
<template v-else>
<UiBackHeader
:to="backTarget"
back-label="Назад к пользователям"
:title="`Клиент ${currentUser.fullName}`"
:subtitle="currentUser.companyName || currentUser.email"
/>
<div class="rounded-[36px] bg-[#edf3ee] p-6 md:p-8">
<div class="flex flex-col gap-6 md:flex-row md:items-start">
<div class="flex shrink-0 justify-center md:block">
@@ -176,11 +185,6 @@ async function rejectRequest() {
</div>
<div class="min-w-0 flex-1 space-y-5">
<div class="space-y-2">
<p class="text-[11px] font-semibold uppercase tracking-[0.18em] text-[#6a8a76]">Клиент</p>
<h1 class="text-3xl font-black tracking-[-0.03em] text-[#123824]">{{ currentUser.fullName }}</h1>
</div>
<div class="grid gap-3 md:grid-cols-2 xl:grid-cols-4">
<div class="rounded-[24px] bg-white/70 px-4 py-3">
<p class="text-[11px] font-semibold uppercase tracking-[0.18em] text-[#6a8a76]">Email</p>
@@ -221,7 +225,7 @@ async function rejectRequest() {
<OrdersOrderSummaryCard
v-for="order in visibleUserOrders"
:key="order.id"
:to="`/client-orders/${order.id}`"
:to="`/admin/orders/${order.id}`"
:code="order.code"
:status="order.status"
:created-at="order.createdAt"

View File

@@ -5,6 +5,8 @@ import { messengerConnectionAvatarSrc } from '~/composables/useMessengerConnecti
definePageMeta({
middleware: ['manager-only'],
path: '/admin/orders/clients',
alias: ['/clients'],
});
const search = ref('');
@@ -64,7 +66,7 @@ function userInitials(fullName: string) {
search-placeholder="Имя, компания или email"
>
<template #controls>
<NuxtLink to="/clients/invite" class="btn btn-primary border-0">
<NuxtLink to="/admin/orders/clients/invite" class="btn btn-primary border-0">
Пригласить
</NuxtLink>
</template>
@@ -81,7 +83,7 @@ function userInitials(fullName: string) {
<UsersGridCard
v-for="user in visibleUsers"
:key="user.id"
:to="`/clients/${user.id}`"
:to="`/admin/orders/clients/${user.id}`"
:full-name="user.fullName"
:avatar-src="messengerConnectionAvatarSrc(user.telegramConnection)"
:initials="userInitials(user.fullName)"

View File

@@ -4,6 +4,8 @@ import { CreateInvitationDocument } from '~/composables/graphql/generated';
definePageMeta({
middleware: ['manager-only'],
path: '/admin/orders/clients/invite',
alias: ['/clients/invite'],
});
const email = ref('');
@@ -37,13 +39,12 @@ async function createInvitation() {
<template>
<section class="space-y-6 max-w-3xl">
<NuxtLink to="/clients" class="text-sm font-semibold text-[#0d854a]"> Назад к пользователям</NuxtLink>
<div class="manager-hero">
<p class="manager-eyebrow">Приглашение</p>
<h1 class="manager-title">Пригласить нового клиента</h1>
<p class="manager-copy">Форма вынесена отдельно, чтобы список клиентов оставался чистым и спокойным.</p>
</div>
<UiBackHeader
to="/admin/orders/clients"
back-label="Назад к пользователям"
title="Пригласить нового клиента"
subtitle="Форма вынесена отдельно, чтобы список клиентов оставался чистым и спокойным."
/>
<div class="surface-card rounded-3xl p-5">
<div class="grid gap-3">

View File

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

View File

@@ -24,8 +24,6 @@ const expiresAt = ref('');
const code = ref('');
const feedback = ref('');
const feedbackTone = ref<'success' | 'error'>('success');
const autoRequestTimer = ref<ReturnType<typeof setTimeout> | null>(null);
const lastRequestedEmail = ref('');
const requestCodeMutation = useMutation(RequestLoginCodeDocument, { throws: 'never' });
const verifyCodeMutation = useMutation(VerifyLoginCodeDocument, { throws: 'never' });
@@ -44,6 +42,9 @@ const maxBotUrl = computed(() => config.public.maxBotUrl || '');
const normalizedEmail = computed(() => email.value.trim().toLowerCase());
const isEmailReady = computed(() => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(normalizedEmail.value));
const canUseTelegramLogin = computed(() => messengerMiniAppChannel.value !== 'TELEGRAM' && Boolean(telegramBotUrl.value));
const canUseMaxLogin = computed(() => messengerMiniAppChannel.value !== 'MAX' && Boolean(maxBotUrl.value));
const hasMessengerButtons = computed(() => canUseTelegramLogin.value || canUseMaxLogin.value);
const nextPath = computed(() =>
typeof route.query.next === 'string' && route.query.next.startsWith('/')
? route.query.next
@@ -69,15 +70,6 @@ async function navigateAfterLogin(user: { company?: { id: string } | null; compa
await navigateTo('/');
}
function clearAutoRequestTimer() {
if (!autoRequestTimer.value) {
return;
}
clearTimeout(autoRequestTimer.value);
autoRequestTimer.value = null;
}
function normalizeApolloErrorMessage(message: string) {
if (message.includes('User for this destination was not found.')) {
return 'Пользователь с таким e-mail не найден. Вход доступен только для созданных аккаунтов.';
@@ -118,7 +110,6 @@ async function requestCode() {
if (requestCodeMutation.loading.value) {
return;
}
clearAutoRequestTimer();
feedback.value = '';
const result = await requestCodeMutation.mutate({
@@ -135,7 +126,6 @@ async function requestCode() {
return;
}
lastRequestedEmail.value = normalizedEmail.value;
challengeToken.value = payload.challengeToken;
maskedEmail.value = payload.destination;
expiresAt.value = new Date(payload.expiresAt).toLocaleString();
@@ -172,6 +162,15 @@ async function verifyCode() {
await navigateAfterLogin(payload.user);
}
function returnToRequestStep() {
step.value = 'request';
code.value = '';
feedback.value = '';
challengeToken.value = '';
maskedEmail.value = '';
expiresAt.value = '';
}
async function consumeLoginToken(loginToken: string) {
feedback.value = '';
const result = await consumeLoginTokenMutation.mutate({
@@ -294,47 +293,6 @@ async function startMessengerLogin(channel: 'TELEGRAM' | 'MAX') {
});
}
function scheduleAutoRequest() {
clearAutoRequestTimer();
if (step.value !== 'request') {
return;
}
if (!isEmailReady.value) {
return;
}
if (normalizedEmail.value === lastRequestedEmail.value) {
return;
}
autoRequestTimer.value = setTimeout(() => {
void requestCode();
}, 450);
}
function onEmailBlur() {
if (step.value !== 'request') {
return;
}
if (!isEmailReady.value) {
return;
}
if (normalizedEmail.value === lastRequestedEmail.value) {
return;
}
void requestCode();
}
watch([normalizedEmail, step], () => {
if (step.value !== 'request') {
clearAutoRequestTimer();
return;
}
scheduleAutoRequest();
});
onMounted(async () => {
const loginToken = typeof route.query.login_token === 'string' ? route.query.login_token : '';
if (loginToken) {
@@ -344,112 +302,133 @@ onMounted(async () => {
await tryMessengerMiniAppLogin();
});
onBeforeUnmount(() => {
clearAutoRequestTimer();
});
</script>
<template>
<section class="mx-auto flex min-h-[calc(100vh-4rem)] w-full max-w-3xl items-center py-8">
<div class="card w-full border border-base-300/60 bg-base-100 shadow-xl">
<div class="card-body p-5 md:p-8">
<div class="mb-4 text-center">
<h1 class="text-3xl font-extrabold">Вход в личный кабинет</h1>
<p
v-if="telegramMiniAppMode === 'checking'"
class="mt-2 text-sm text-base-content/70"
>
{{ `Проверяем аккаунт ${resolveMessengerMiniAppLabel()}` }}
<section class="mx-auto flex w-full max-w-[540px] items-center justify-center py-6 md:py-10">
<div class="surface-card w-full rounded-[32px] border border-white/70 px-6 py-6 shadow-[0_26px_70px_rgba(18,56,36,0.12)] md:px-8 md:py-8">
<div class="space-y-6">
<div class="space-y-3 text-center">
<p class="text-[11px] font-bold uppercase tracking-[0.22em] text-[#6a8a76]">Фрегат</p>
<div class="space-y-2">
<h1 class="text-3xl font-black tracking-[-0.04em] text-[#123824] md:text-4xl">Вход</h1>
<p class="text-sm leading-6 text-[#5c7b69]">
Войдите по рабочему e-mail и коду из письма.
</p>
<p
v-else-if="isMessengerMiniApp"
class="mt-2 text-sm text-base-content/70"
</div>
</div>
<div
v-if="telegramMiniAppMode === 'checking' || isMessengerMiniApp"
class="rounded-[24px] border px-4 py-3 text-sm leading-6"
:class="telegramMiniAppMode === 'checking'
? 'border-[#dce9e1] bg-[#f7fbf9] text-[#355947]'
: 'border-[#cbe9d6] bg-[#f1fbf5] text-[#1c6b45]'"
>
<template v-if="telegramMiniAppMode === 'checking'">
{{ `Проверяем аккаунт ${resolveMessengerMiniAppLabel()}` }}
</template>
<template v-else>
{{
messengerMiniAppDisplayName
? `Вы вошли из ${resolveMessengerMiniAppLabel()} как ${messengerMiniAppDisplayName}.`
: `Вы открыли кабинет внутри ${resolveMessengerMiniAppLabel()}.`
}}
</p>
</template>
</div>
<div v-if="step === 'request'" class="space-y-4">
<fieldset class="fieldset">
<legend class="fieldset-legend text-base font-semibold">E-mail</legend>
<div v-if="step === 'request'" class="space-y-5">
<label class="block space-y-2">
<span class="text-sm font-semibold text-[#355947]">E-mail</span>
<input
v-model="email"
type="email"
class="input input-bordered w-full"
class="input manager-field h-14 w-full px-4 text-base text-[#123824]"
placeholder="name@company.com"
@keydown.enter.prevent="requestCode"
@blur="onEmailBlur"
>
</fieldset>
</label>
<div class="grid gap-2 sm:grid-cols-2">
<button
v-if="messengerMiniAppChannel !== 'TELEGRAM'"
class="btn btn-secondary"
:class="{ 'btn-disabled pointer-events-none': !telegramBotUrl || !isEmailReady }"
:disabled="pendingChannel === 'TELEGRAM' || !telegramBotUrl || !isEmailReady"
class="btn h-12 w-full rounded-full border-0 bg-[#123824] text-white shadow-[0_16px_32px_rgba(18,56,36,0.18)] hover:bg-[#0f2f20] disabled:border-0 disabled:bg-[#cfd8d2] disabled:text-[#6f8579]"
:disabled="requestCodeMutation.loading.value || !isEmailReady"
@click="requestCode"
>
{{ requestCodeMutation.loading.value ? 'Отправляем код…' : 'Получить код' }}
</button>
<div v-if="hasMessengerButtons" class="space-y-3">
<div class="flex items-center gap-3">
<span class="h-px flex-1 bg-[#e2ece6]" />
<span class="text-[11px] font-bold uppercase tracking-[0.18em] text-[#7a9386]">или войти через</span>
<span class="h-px flex-1 bg-[#e2ece6]" />
</div>
<div class="grid gap-3 sm:grid-cols-2">
<button
v-if="canUseTelegramLogin"
class="btn h-12 rounded-full border border-[#d7e6dc] bg-white text-[#123824] hover:border-[#bed6c7] hover:bg-[#f6fbf8] disabled:border-[#e6ede9] disabled:bg-[#f8fbf9] disabled:text-[#91a79b]"
:disabled="pendingChannel === 'TELEGRAM' || !isEmailReady"
@click="startMessengerLogin('TELEGRAM')"
>
{{ pendingChannel === 'TELEGRAM' ? 'Открываем Telegram…' : 'Войти через Telegram' }}
{{ pendingChannel === 'TELEGRAM' ? 'Открываем Telegram…' : 'Telegram' }}
</button>
<button
v-if="messengerMiniAppChannel !== 'MAX'"
class="btn btn-accent"
:class="{ 'btn-disabled pointer-events-none': !maxBotUrl || !isEmailReady }"
:disabled="pendingChannel === 'MAX' || !maxBotUrl || !isEmailReady"
v-if="canUseMaxLogin"
class="btn h-12 rounded-full border border-[#d7e6dc] bg-white text-[#123824] hover:border-[#bed6c7] hover:bg-[#f6fbf8] disabled:border-[#e6ede9] disabled:bg-[#f8fbf9] disabled:text-[#91a79b]"
:disabled="pendingChannel === 'MAX' || !isEmailReady"
@click="startMessengerLogin('MAX')"
>
{{ pendingChannel === 'MAX' ? 'Открываем Max…' : 'Войти через Max' }}
{{ pendingChannel === 'MAX' ? 'Открываем Max…' : 'Max' }}
</button>
</div>
<p v-if="requestCodeMutation.loading.value" class="text-sm text-base-content/70">
Проверяем e-mail и отправляем код...
</p>
</div>
</div>
<div v-else class="space-y-4">
<p class="text-sm text-base-content/70">E-mail: {{ maskedEmail }}</p>
<div v-else class="space-y-5">
<div class="rounded-[24px] bg-[#f5faf7] px-4 py-3 text-sm text-[#355947]">
Код отправлен на <span class="font-semibold text-[#123824]">{{ maskedEmail }}</span>
</div>
<fieldset class="fieldset">
<legend class="fieldset-legend text-base font-semibold">Код</legend>
<label class="block space-y-2">
<span class="text-sm font-semibold text-[#355947]">Код из письма</span>
<input
v-model="code"
type="text"
maxlength="6"
class="input input-bordered w-full"
class="input manager-field h-14 w-full px-4 text-base tracking-[0.22em] text-[#123824]"
placeholder="123456"
@keydown.enter.prevent="verifyCode"
>
</fieldset>
</label>
<div class="space-y-3">
<button
class="btn btn-primary w-full"
:disabled="verifyCodeMutation.loading.value"
class="btn h-12 w-full rounded-full border-0 bg-[#123824] text-white shadow-[0_16px_32px_rgba(18,56,36,0.18)] hover:bg-[#0f2f20] disabled:border-0 disabled:bg-[#cfd8d2] disabled:text-[#6f8579]"
:disabled="verifyCodeMutation.loading.value || !code.trim()"
@click="verifyCode"
>
{{ verifyCodeMutation.loading.value ? 'Проверяем…' : 'Войти' }}
</button>
<button
class="btn btn-ghost w-full"
@click="step = 'request'; code = ''; feedback = ''; challengeToken = ''; maskedEmail = ''"
class="btn h-12 w-full rounded-full border border-[#d7e6dc] bg-white text-[#123824] hover:border-[#bed6c7] hover:bg-[#f6fbf8]"
@click="returnToRequestStep"
>
Изменить e-mail
</button>
</div>
<p class="text-xs text-base-content/60">Код действует до {{ expiresAt }}</p>
<p class="text-xs text-[#7a9386]">Код действует до {{ expiresAt }}</p>
</div>
<div
v-if="feedback"
class="alert mt-2"
:class="feedbackTone === 'success' ? 'alert-success' : 'alert-error'"
class="rounded-[24px] border px-4 py-3 text-sm leading-6"
:class="feedbackTone === 'success'
? 'border-[#cbe9d6] bg-[#f1fbf5] text-[#1c6b45]'
: 'border-[#f1d1c7] bg-[#fff3ef] text-[#9d4426]'"
>
{{ feedback }}
</div>

View File

@@ -7,6 +7,8 @@ import {
definePageMeta({
middleware: ['manager-only'],
path: '/admin/settings/messages',
alias: ['/messages'],
});
type TemplateItem = NotificationTemplatesQuery['notificationTemplates'][number];
@@ -29,13 +31,7 @@ function channelLabel(channel: TemplateChannel['channel']) {
<template>
<section class="space-y-6">
<div class="manager-hero">
<h1 class="manager-title">Реестр шаблонов уведомлений</h1>
<p class="manager-copy">
Экран собирается из backend-кода. Здесь только реальные шаблоны и реальные типы взаимодействия с клиентом,
которые сейчас описаны в системе.
</p>
</div>
<h1 class="text-3xl font-extrabold text-[#0f2f20]">Сообщения</h1>
<div v-if="templatesQuery.loading.value" class="manager-empty-state">
Загружаем шаблоны...
@@ -45,13 +41,12 @@ function channelLabel(channel: TemplateChannel['channel']) {
Шаблонов пока нет.
</div>
<div v-else class="space-y-4">
<article
<div v-else class="space-y-6">
<section
v-for="template in templates"
:key="template.id"
class="surface-card rounded-3xl p-5"
>
<h2 class="text-2xl font-black tracking-[-0.03em] text-[#123824]">
<h2 class="text-xl font-bold text-[#123824]">
{{ template.title }}
</h2>
@@ -59,42 +54,55 @@ function channelLabel(channel: TemplateChannel['channel']) {
<section
v-for="channel in template.channels"
:key="`${template.id}-${channel.channel}`"
class="rounded-[24px] border border-[#deebe4] bg-[#fbfdfb] p-4"
class="surface-card rounded-[24px] bg-white p-4"
>
<h3 class="text-sm font-extrabold uppercase tracking-[0.14em] text-[#355947]">
{{ channelLabel(channel.channel) }}
</h3>
<div class="mt-3 rounded-[20px] bg-white p-4">
<p
<div class="mt-3 rounded-[20px] bg-[#f8fbf9] p-4">
<div
v-if="channel.subject"
class="text-xs font-semibold uppercase tracking-[0.12em] text-[#5c7b69]"
class="mb-4"
>
<p class="text-xs font-semibold uppercase tracking-[0.12em] text-[#5c7b69]">
Тема:
</p>
<p class="mt-2 text-sm font-semibold text-[#123824]">
{{ channel.subject }}
</p>
<div class="mt-3 space-y-3 text-sm leading-6 text-[#123824]">
<div class="mt-4 h-px bg-[#d7e4dc]" />
</div>
<div class="space-y-3 text-sm leading-6 text-[#123824]">
<p
v-for="line in channel.body"
:key="line"
:class="channel.implemented ? '' : 'text-[#6f8577]'"
>
{{ line }}
</p>
</div>
<div v-if="channel.buttonText" class="mt-4 space-y-1 text-sm leading-6 text-[#355947]">
<p class="font-semibold text-[#123824]">
<div v-if="channel.buttonText" class="mt-4">
<a
v-if="channel.buttonUrl"
:href="channel.buttonUrl"
class="btn h-11 rounded-full border-0 bg-[#123824] px-5 text-white hover:bg-[#0f2f20]"
>
{{ channel.buttonText }}
</p>
<p v-if="channel.buttonUrl" class="break-all text-xs text-[#5c7b69]">
{{ channel.buttonUrl }}
</p>
</a>
<span
v-else
class="inline-flex h-11 items-center rounded-full bg-[#123824] px-5 text-sm font-semibold text-white"
>
{{ channel.buttonText }}
</span>
</div>
</div>
</section>
</div>
</article>
</section>
</div>
</section>
</template>

View File

@@ -32,20 +32,11 @@ const currentOrderCode = computed(() => formatOrderCode(currentOrder.value?.code
</div>
<template v-else>
<div class="surface-card rounded-3xl px-5 py-4">
<div class="flex flex-wrap items-center gap-3">
<NuxtLink to="/orders" class="text-sm font-semibold text-[#0d854a]">
Назад к моим заказам
</NuxtLink>
<span class="hidden h-4 w-px bg-[#d8e4dd] md:block" />
<span class="text-[11px] font-semibold uppercase tracking-[0.18em] text-[#6a8a76]">
Заказ
</span>
<span class="text-lg font-black tracking-[-0.03em] text-[#123824]">
{{ currentOrderCode }}
</span>
</div>
</div>
<UiBackHeader
to="/orders"
back-label="Назад к моим заказам"
:title="`Заказ ${currentOrderCode}`"
/>
<div class="space-y-4">
<OrdersOrderStatusTimelineCard

View File

@@ -11,6 +11,8 @@ type OrderItem = MyOrdersQuery['myOrders'][number];
const allOrders = useQuery(MyOrdersDocument);
const search = ref('');
const statusFilter = ref<'ALL' | 'WAITING' | 'ACTIVE' | 'CLOSED'>('ALL');
const dateFrom = ref('');
const dateTo = ref('');
const ACTIVE_STATUSES = new Set(['NEW', 'MANAGER_PROCESSING', 'WAITING_DOUBLE_CONFIRM', 'CONFIRMED', 'IN_PROGRESS']);
const CLOSED_STATUSES = new Set(['COMPLETED', 'CLIENT_REJECTED', 'MANAGER_REJECTED', 'MANAGER_BLOCKED']);
@@ -28,6 +30,29 @@ function matchesFilter(order: OrderItem) {
return CLOSED_STATUSES.has(order.status);
}
function matchesDate(order: OrderItem) {
const orderTimestamp = new Date(order.createdAt).getTime();
if (!Number.isFinite(orderTimestamp)) {
return false;
}
if (dateFrom.value) {
const fromTimestamp = new Date(`${dateFrom.value}T00:00:00`).getTime();
if (Number.isFinite(fromTimestamp) && orderTimestamp < fromTimestamp) {
return false;
}
}
if (dateTo.value) {
const toTimestamp = new Date(`${dateTo.value}T23:59:59.999`).getTime();
if (Number.isFinite(toTimestamp) && orderTimestamp > toTimestamp) {
return false;
}
}
return true;
}
const filteredOrders = computed(() => {
const orders = allOrders.result.value?.myOrders ?? [];
const normalizedSearch = search.value.trim().toLowerCase();
@@ -42,7 +67,7 @@ const filteredOrders = computed(() => {
.toLowerCase();
const matchSearch = !normalizedSearch || text.includes(normalizedSearch);
return matchSearch && matchesFilter(order);
return matchSearch && matchesFilter(order) && matchesDate(order);
});
});
@@ -54,7 +79,7 @@ const {
visibleItems: visibleOrders,
} = useIncrementalList(filteredOrders, {
pageSize: 24,
resetKeys: [search, statusFilter],
resetKeys: [search, statusFilter, dateFrom, dateTo],
});
</script>
@@ -66,15 +91,52 @@ const {
search-placeholder="Номер заказа или товар"
>
<template #controls>
<div class="flex w-full flex-col gap-3 md:w-auto md:flex-row md:flex-wrap md:justify-end">
<label class="flex items-center gap-2 rounded-full border border-[#d7e9de] bg-white px-4 py-3 text-sm font-semibold text-[#123824]">
<svg class="h-4 w-4 text-[#6b8576]" viewBox="0 0 20 20" fill="none">
<path d="M3.33334 5H16.6667" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" />
<path d="M6.66666 10H13.3333" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" />
<path d="M8.33334 15H11.6667" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" />
</svg>
<select
v-model="statusFilter"
class="w-full rounded-full border border-[#d7e9de] bg-white px-4 py-3 text-sm font-semibold text-[#123824] outline-none transition focus:border-[#139957] focus:shadow-[0_0_0_3px_rgba(19,153,87,0.12)] md:w-64"
class="min-w-0 bg-transparent outline-none"
>
<option value="ALL">Все заказы</option>
<option value="WAITING">Ожидают подтверждения</option>
<option value="ACTIVE">Активные</option>
<option value="CLOSED">Закрытые</option>
</select>
</label>
<label class="flex items-center gap-2 rounded-full border border-[#d7e9de] bg-white px-4 py-3 text-sm font-semibold text-[#123824]">
<svg class="h-4 w-4 text-[#6b8576]" viewBox="0 0 20 20" fill="none">
<rect x="3" y="4.5" width="14" height="12" rx="2.5" stroke="currentColor" stroke-width="1.6" />
<path d="M6 2.5V6" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" />
<path d="M14 2.5V6" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" />
<path d="M3 8.5H17" stroke="currentColor" stroke-width="1.6" />
</svg>
<input
v-model="dateFrom"
type="date"
class="min-w-0 bg-transparent outline-none"
>
</label>
<label class="flex items-center gap-2 rounded-full border border-[#d7e9de] bg-white px-4 py-3 text-sm font-semibold text-[#123824]">
<svg class="h-4 w-4 text-[#6b8576]" viewBox="0 0 20 20" fill="none">
<rect x="3" y="4.5" width="14" height="12" rx="2.5" stroke="currentColor" stroke-width="1.6" />
<path d="M6 2.5V6" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" />
<path d="M14 2.5V6" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" />
<path d="M3 8.5H17" stroke="currentColor" stroke-width="1.6" />
</svg>
<input
v-model="dateTo"
type="date"
class="min-w-0 bg-transparent outline-none"
>
</label>
</div>
</template>
</UiSectionSearchHero>

View File

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

View File

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

View File

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

View File

@@ -1,138 +1,25 @@
<script setup lang="ts">
import { useMutation, useQuery } from '@vue/apollo-composable';
import {
CreateMyDeliveryAddressDocument,
DeleteMyDeliveryAddressDocument,
MyDeliveryAddressesDocument,
SetMyDefaultDeliveryAddressDocument,
type MyDeliveryAddressesQuery,
} from '~/composables/graphql/generated';
type AddressSuggestion = {
value: string;
unrestricted_value?: string;
data?: {
fias_id?: string;
};
};
type DeliveryAddressItem = MyDeliveryAddressesQuery['myDeliveryAddresses'][number];
const addressFeedback = ref('');
const addressFeedbackTone = ref<'success' | 'error'>('success');
const route = useRoute();
const addressFeedback = ref(route.query.created === '1' ? 'Адрес сохранён.' : '');
const addressFeedbackTone = ref<'success' | 'error'>(route.query.created === '1' ? 'success' : 'error');
const deliveryAddressesQuery = useQuery(MyDeliveryAddressesDocument);
const createAddressMutation = useMutation(CreateMyDeliveryAddressDocument, { throws: 'never' });
const setDefaultAddressMutation = useMutation(SetMyDefaultDeliveryAddressDocument, { throws: 'never' });
const deleteAddressMutation = useMutation(DeleteMyDeliveryAddressDocument, { throws: 'never' });
const addressForm = reactive({
label: '',
address: '',
unrestrictedValue: '',
fiasId: '',
});
const addressSearch = ref('');
const addressSuggestions = ref<AddressSuggestion[]>([]);
const addressLoading = ref(false);
const addressOpen = ref(false);
const addressSearchTimer = ref<ReturnType<typeof setTimeout> | null>(null);
const addressBusyId = ref<string | null>(null);
const addressDropdownRef = ref<HTMLElement | null>(null);
const deliveryAddresses = computed<DeliveryAddressItem[]>(() => deliveryAddressesQuery.result.value?.myDeliveryAddresses ?? []);
function clearAddressTimer() {
if (!addressSearchTimer.value) {
return;
}
clearTimeout(addressSearchTimer.value);
addressSearchTimer.value = null;
}
async function fetchAddressSuggestions() {
const query = addressSearch.value.trim();
if (query.length < 2) {
addressSuggestions.value = [];
addressOpen.value = false;
return;
}
addressLoading.value = true;
await $fetch<{ suggestions: AddressSuggestion[] }>('/api/dadata/address', {
method: 'POST',
body: { query },
})
.then((response) => {
addressSuggestions.value = response.suggestions || [];
addressOpen.value = addressSuggestions.value.length > 0;
})
.finally(() => {
addressLoading.value = false;
});
}
function scheduleAddressSuggest() {
clearAddressTimer();
addressSearchTimer.value = setTimeout(() => {
void fetchAddressSuggestions();
}, 250);
}
function applyAddressSuggestion(item: AddressSuggestion) {
addressOpen.value = false;
addressSearch.value = item.value;
addressForm.address = item.value;
addressForm.unrestrictedValue = item.unrestricted_value || item.value;
addressForm.fiasId = item.data?.fias_id || '';
}
function closeDropdownsFromOutside(event: MouseEvent) {
const target = event.target as Node | null;
if (addressDropdownRef.value && target && !addressDropdownRef.value.contains(target)) {
addressOpen.value = false;
}
}
async function addDeliveryAddress() {
addressFeedback.value = '';
const normalizedAddress = addressForm.address.trim() || addressSearch.value.trim();
if (normalizedAddress.length < 5) {
addressFeedbackTone.value = 'error';
addressFeedback.value = 'Введите адрес через подсказки DaData.';
return;
}
const result = await createAddressMutation.mutate({
input: {
label: addressForm.label.trim() ? addressForm.label.trim() : null,
address: normalizedAddress,
unrestrictedValue: addressForm.unrestrictedValue.trim() ? addressForm.unrestrictedValue.trim() : null,
fiasId: addressForm.fiasId.trim() ? addressForm.fiasId.trim() : null,
},
});
const payload = result?.data?.createMyDeliveryAddress;
if (!payload) {
addressFeedbackTone.value = 'error';
addressFeedback.value = createAddressMutation.error.value?.message || 'Не удалось добавить адрес.';
return;
}
addressForm.label = '';
addressForm.address = '';
addressForm.unrestrictedValue = '';
addressForm.fiasId = '';
addressSearch.value = '';
addressSuggestions.value = [];
addressOpen.value = false;
addressFeedbackTone.value = 'success';
addressFeedback.value = 'Адрес сохранён.';
await deliveryAddressesQuery.refetch();
}
async function setDefaultAddress(addressId: string) {
addressFeedback.value = '';
addressBusyId.value = addressId;
@@ -170,113 +57,78 @@ async function deleteAddress(addressId: string) {
addressFeedback.value = 'Адрес удалён.';
await deliveryAddressesQuery.refetch();
}
onMounted(() => {
document.addEventListener('click', closeDropdownsFromOutside);
});
onBeforeUnmount(() => {
document.removeEventListener('click', closeDropdownsFromOutside);
clearAddressTimer();
});
</script>
<template>
<section class="space-y-6">
<NuxtLink to="/profile" class="link link-hover text-sm"> Назад в профиль</NuxtLink>
<h1 class="text-3xl font-extrabold text-[#0f2f20]">Адреса доставки</h1>
<div class="surface-card rounded-3xl p-5">
<p class="text-sm text-[#355947]">
Добавьте адрес через DaData и выберите основной. Этот адрес будет использоваться по умолчанию в корзине.
</p>
<fieldset class="fieldset mt-4">
<legend class="fieldset-legend">Название адреса (необязательно)</legend>
<input v-model="addressForm.label" type="text" class="input w-full" placeholder="Склад МСК" >
</fieldset>
<div ref="addressDropdownRef" class="relative mt-2">
<fieldset class="fieldset">
<legend class="fieldset-legend">Поиск адреса (DaData)</legend>
<input
v-model="addressSearch"
type="text"
class="input w-full"
placeholder="Начните вводить адрес"
@input="scheduleAddressSuggest"
@focus="addressOpen = addressSuggestions.length > 0"
<UiBackHeader
to="/profile"
back-label="Назад в профиль"
title="Адреса доставки"
subtitle="Выберите основной адрес для заказов или добавьте новый."
>
</fieldset>
<span v-if="addressLoading" class="loading loading-spinner loading-sm absolute right-3 top-1/2 -translate-y-1/2" />
<template #actions>
<NuxtLink
to="/profile/addresses/new"
class="btn rounded-full border-0 bg-[#123824] px-6 text-white hover:bg-[#0f2f20]"
>
Добавить
</NuxtLink>
</template>
</UiBackHeader>
<div
v-if="addressOpen && addressSuggestions.length > 0"
class="absolute z-30 mt-2 max-h-72 w-full overflow-auto rounded-box bg-base-100 p-2"
v-if="addressFeedback"
class="rounded-[24px] border px-4 py-3 text-sm font-medium"
:class="addressFeedbackTone === 'success'
? 'border-[#cbe9d6] bg-[#f1fbf5] text-[#1c6b45]'
: 'border-[#f1d1c7] bg-[#fff3ef] text-[#9d4426]'"
>
<button
v-for="item in addressSuggestions"
:key="`${item.value}-${item.data?.fias_id || ''}`"
type="button"
class="btn btn-ghost mb-1 h-auto min-h-0 w-full justify-start whitespace-normal px-3 py-2 text-left"
@click="applyAddressSuggestion(item)"
>
<span class="block text-sm font-semibold">{{ item.value }}</span>
</button>
</div>
</div>
<button class="btn btn-primary mt-4 w-full" :disabled="createAddressMutation.loading.value" @click="addDeliveryAddress">
{{ createAddressMutation.loading.value ? 'Добавляем' : 'Добавить адрес' }}
</button>
<div v-if="addressFeedback" class="alert mt-3" :class="addressFeedbackTone === 'success' ? 'alert-success' : 'alert-error'">
{{ addressFeedback }}
</div>
<div v-if="deliveryAddressesQuery.loading.value" class="rounded-[28px] bg-white px-5 py-4 text-sm text-[#355947] shadow-[0_18px_38px_rgba(18,56,36,0.08)]">
Загружаем адреса...
</div>
<div class="surface-card rounded-3xl p-5">
<h2 class="text-xl font-bold text-[#123824]">Список адресов</h2>
<div class="mt-4 space-y-2">
<div v-if="deliveryAddressesQuery.loading.value" class="alert surface-card">Загрузка адресов...</div>
<div v-else-if="deliveryAddresses.length === 0" class="alert surface-card">
<div v-else-if="deliveryAddresses.length === 0" class="rounded-[28px] bg-white px-5 py-4 text-sm text-[#355947] shadow-[0_18px_38px_rgba(18,56,36,0.08)]">
Пока нет адресов доставки.
</div>
<div v-else class="space-y-4">
<article
v-for="address in deliveryAddresses"
:key="address.id"
class="rounded-2xl bg-[#f8fbf9] p-3 transition hover:shadow-md"
class="rounded-[28px] bg-white px-5 py-4 shadow-[0_18px_38px_rgba(18,56,36,0.08)]"
>
<div class="flex items-start justify-between gap-2">
<div>
<p class="font-semibold text-[#123824]">{{ address.label || 'Адрес доставки' }}</p>
<p class="text-sm text-[#355947]">{{ address.unrestrictedValue || address.address }}</p>
<div class="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
<div class="min-w-0 space-y-1">
<div class="flex flex-wrap items-center gap-2">
<p class="text-lg font-bold text-[#123824]">{{ address.label || 'Адрес доставки' }}</p>
<span v-if="address.isDefault" class="badge border-0 bg-[#e8f5ec] text-[#1c6b45]">Основной</span>
</div>
<span v-if="address.isDefault" class="badge badge-success">Основной</span>
<p class="text-sm leading-6 text-[#557562]">{{ address.unrestrictedValue || address.address }}</p>
</div>
<div class="mt-3 flex flex-wrap gap-2">
<div class="flex flex-wrap gap-2">
<button
v-if="!address.isDefault"
class="btn btn-sm"
class="btn rounded-full border border-[#d7e6dc] bg-white px-5 text-[#123824] hover:border-[#bed6c7] hover:bg-[#f6fbf8]"
:disabled="addressBusyId === address.id"
@click="setDefaultAddress(address.id)"
>
{{ addressBusyId === address.id ? 'Сохраняем' : 'Сделать основным' }}
{{ addressBusyId === address.id ? 'Сохраняем...' : 'Сделать основным' }}
</button>
<button
class="btn btn-sm btn-ghost text-error"
class="btn rounded-full border border-[#e5cfc7] bg-[#fff5f1] px-5 text-[#a64d2d] hover:border-[#deb5a8] hover:bg-[#ffe8e0]"
:disabled="addressBusyId === address.id"
@click="deleteAddress(address.id)"
>
{{ addressBusyId === address.id ? 'Удаляем' : 'Удалить' }}
{{ addressBusyId === address.id ? 'Удаляем...' : 'Удалить' }}
</button>
</div>
</div>
</article>
</div>
</div>
</section>
</template>

View File

@@ -0,0 +1,186 @@
<script setup lang="ts">
import { useMutation } from '@vue/apollo-composable';
import { CreateMyDeliveryAddressDocument } from '~/composables/graphql/generated';
type AddressSuggestion = {
value: string;
unrestricted_value?: string;
data?: {
fias_id?: string;
};
};
const addressFeedback = ref('');
const addressLoading = ref(false);
const addressOpen = ref(false);
const addressSearch = ref('');
const addressSuggestions = ref<AddressSuggestion[]>([]);
const addressSearchTimer = ref<ReturnType<typeof setTimeout> | null>(null);
const addressDropdownRef = ref<HTMLElement | null>(null);
const createAddressMutation = useMutation(CreateMyDeliveryAddressDocument, { throws: 'never' });
const addressForm = reactive({
label: '',
address: '',
unrestrictedValue: '',
fiasId: '',
});
function clearAddressTimer() {
if (!addressSearchTimer.value) {
return;
}
clearTimeout(addressSearchTimer.value);
addressSearchTimer.value = null;
}
async function fetchAddressSuggestions() {
const query = addressSearch.value.trim();
if (query.length < 2) {
addressSuggestions.value = [];
addressOpen.value = false;
return;
}
addressLoading.value = true;
await $fetch<{ suggestions: AddressSuggestion[] }>('/api/dadata/address', {
method: 'POST',
body: { query },
})
.then((response) => {
addressSuggestions.value = response.suggestions || [];
addressOpen.value = addressSuggestions.value.length > 0;
})
.finally(() => {
addressLoading.value = false;
});
}
function scheduleAddressSuggest() {
clearAddressTimer();
addressSearchTimer.value = setTimeout(() => {
void fetchAddressSuggestions();
}, 250);
}
function applyAddressSuggestion(item: AddressSuggestion) {
addressOpen.value = false;
addressSearch.value = item.value;
addressForm.address = item.value;
addressForm.unrestrictedValue = item.unrestricted_value || item.value;
addressForm.fiasId = item.data?.fias_id || '';
}
function closeDropdownsFromOutside(event: MouseEvent) {
const target = event.target as Node | null;
if (addressDropdownRef.value && target && !addressDropdownRef.value.contains(target)) {
addressOpen.value = false;
}
}
async function addDeliveryAddress() {
addressFeedback.value = '';
const normalizedAddress = addressForm.address.trim() || addressSearch.value.trim();
if (normalizedAddress.length < 5) {
addressFeedback.value = 'Введите адрес через подсказки DaData.';
return;
}
const result = await createAddressMutation.mutate({
input: {
label: addressForm.label.trim() ? addressForm.label.trim() : null,
address: normalizedAddress,
unrestrictedValue: addressForm.unrestrictedValue.trim() ? addressForm.unrestrictedValue.trim() : null,
fiasId: addressForm.fiasId.trim() ? addressForm.fiasId.trim() : null,
},
});
if (!result?.data?.createMyDeliveryAddress) {
addressFeedback.value = createAddressMutation.error.value?.message || 'Не удалось добавить адрес.';
return;
}
await navigateTo('/profile/addresses?created=1');
}
onMounted(() => {
document.addEventListener('click', closeDropdownsFromOutside);
});
onBeforeUnmount(() => {
document.removeEventListener('click', closeDropdownsFromOutside);
clearAddressTimer();
});
</script>
<template>
<section class="space-y-6">
<UiBackHeader
to="/profile/addresses"
back-label="Назад к адресам"
title="Новый адрес"
subtitle="Найдите адрес через DaData и сохраните его в профиль."
/>
<div class="rounded-[28px] bg-white px-5 py-5 shadow-[0_18px_38px_rgba(18,56,36,0.08)] md:px-6 md:py-6">
<fieldset class="fieldset">
<legend class="fieldset-legend">Название адреса</legend>
<input v-model="addressForm.label" type="text" class="input w-full" placeholder="Склад МСК">
</fieldset>
<div ref="addressDropdownRef" class="relative mt-4">
<fieldset class="fieldset">
<legend class="fieldset-legend">Адрес</legend>
<input
v-model="addressSearch"
type="text"
class="input w-full"
placeholder="Начните вводить адрес"
@input="scheduleAddressSuggest"
@focus="addressOpen = addressSuggestions.length > 0"
>
</fieldset>
<span v-if="addressLoading" class="loading loading-spinner loading-sm absolute right-3 top-1/2 -translate-y-1/2" />
<div
v-if="addressOpen && addressSuggestions.length > 0"
class="absolute z-30 mt-2 max-h-72 w-full overflow-auto rounded-[24px] bg-white p-2 shadow-[0_18px_38px_rgba(18,56,36,0.08)]"
>
<button
v-for="item in addressSuggestions"
:key="`${item.value}-${item.data?.fias_id || ''}`"
type="button"
class="w-full rounded-[18px] px-3 py-3 text-left transition hover:bg-[#f6fbf8]"
@click="applyAddressSuggestion(item)"
>
<span class="block text-sm font-semibold text-[#123824]">{{ item.value }}</span>
</button>
</div>
</div>
<div
v-if="addressFeedback"
class="mt-4 rounded-[20px] border border-[#f1d1c7] bg-[#fff3ef] px-4 py-3 text-sm font-medium text-[#9d4426]"
>
{{ addressFeedback }}
</div>
<div class="mt-6 flex flex-col gap-3 md:flex-row">
<button
class="btn rounded-full border-0 bg-[#123824] px-6 text-white hover:bg-[#0f2f20]"
:disabled="createAddressMutation.loading.value"
@click="addDeliveryAddress"
>
{{ createAddressMutation.loading.value ? 'Сохраняем...' : 'Сохранить адрес' }}
</button>
<NuxtLink to="/profile/addresses" class="btn rounded-full border border-[#d7e6dc] bg-white px-6 text-[#123824] hover:border-[#bed6c7] hover:bg-[#f6fbf8]">
Отмена
</NuxtLink>
</div>
</div>
</section>
</template>

View File

@@ -94,7 +94,6 @@ watch(
{ immediate: true },
);
const profileUpdatedAt = computed(() => profileQuery.result.value?.myCounterpartyProfile?.updatedAt ?? null);
const profileIsComplete = computed(() => isCounterpartyProfileComplete(counterpartyForm));
function clearPartyTimer() {
@@ -255,179 +254,210 @@ onBeforeUnmount(() => {
<template>
<section class="space-y-6">
<NuxtLink to="/profile" class="link link-hover text-sm"> Назад в профиль</NuxtLink>
<div class="space-y-2">
<h1 class="text-3xl font-extrabold text-[#0f2f20]">Карточка контрагента</h1>
<div class="surface-card rounded-3xl p-5">
<p class="text-sm text-[#355947]">
Заполните реквизиты, чтобы оформить заявки и получить полный функционал личного кабинета.
<p class="text-sm leading-6 text-[#466653]">
Заполните реквизиты компании, банка, подписанта и основания.
</p>
</div>
<div class="mt-4 space-y-4">
<section>
<h2 class="mb-3 text-base font-bold">1. Контрагент (DaData)</h2>
<div class="space-y-4">
<section class="surface-card rounded-3xl p-5 md:p-6">
<div class="mb-5 space-y-1">
<h2 class="text-xl font-black tracking-[-0.02em] text-[#123824]">Данные компании</h2>
<p class="text-sm leading-6 text-[#5c7b69]">
Найдите компанию через DaData или заполните реквизиты вручную.
</p>
</div>
<div class="grid gap-4">
<div ref="partyDropdownRef" class="relative">
<fieldset class="fieldset">
<legend class="fieldset-legend">Поиск компании</legend>
<label class="block space-y-2">
<span class="text-sm font-semibold text-[#355947]">Поиск компании</span>
<div class="relative">
<input
v-model="companySearch"
type="text"
class="input w-full"
placeholder="Введите название или ИНН"
class="input manager-field w-full pr-11"
placeholder="Введите название компании или ИНН"
@input="schedulePartySuggest"
@focus="partyOpen = partySuggestions.length > 0"
>
</fieldset>
<span v-if="partyLoading" class="loading loading-spinner loading-sm absolute right-3 top-1/2 -translate-y-1/2" />
<span v-if="partyLoading" class="loading loading-spinner loading-sm absolute right-4 top-1/2 -translate-y-1/2 text-[#5c7b69]" />
</div>
</label>
<div
v-if="partyOpen && partySuggestions.length > 0"
class="absolute z-30 mt-2 max-h-72 w-full overflow-auto rounded-box bg-base-100 p-2"
class="absolute z-30 mt-2 max-h-72 w-full overflow-auto rounded-[1.4rem] border border-[#e1ebe4] bg-white p-2 shadow-[0_24px_48px_rgba(18,56,36,0.12)]"
>
<button
v-for="item in partySuggestions"
:key="`${item.value}-${item.data?.inn || ''}`"
type="button"
class="btn btn-ghost mb-1 h-auto min-h-0 w-full justify-start whitespace-normal px-3 py-2 text-left"
class="mb-1 w-full rounded-2xl px-4 py-3 text-left transition hover:bg-[#f5faf7]"
@click="applyPartySuggestion(item)"
>
<span>
<span class="block text-sm font-semibold">{{ item.value }}</span>
<span class="block text-xs opacity-70">ИНН: {{ item.data?.inn || '—' }} <span v-if="item.data?.kpp"> КПП: {{ item.data.kpp }}</span></span>
<span class="block text-sm font-semibold text-[#123824]">{{ item.value }}</span>
<span class="mt-1 block text-xs text-[#5c7b69]">
ИНН: {{ item.data?.inn || '—' }}<span v-if="item.data?.kpp"> КПП: {{ item.data.kpp }}</span>
</span>
</button>
</div>
</div>
<fieldset class="fieldset">
<legend class="fieldset-legend">Краткое наименование</legend>
<input v-model="counterpartyForm.companyName" type="text" class="input w-full" >
</fieldset>
<div class="grid gap-4 md:grid-cols-2">
<label class="block space-y-2">
<span class="text-sm font-semibold text-[#355947]">Краткое наименование</span>
<input v-model="counterpartyForm.companyName" type="text" class="input manager-field w-full">
</label>
<fieldset class="fieldset">
<legend class="fieldset-legend">Полное наименование</legend>
<input v-model="counterpartyForm.companyFullName" type="text" class="input w-full" >
</fieldset>
<div class="grid gap-3 sm:grid-cols-3">
<fieldset class="fieldset">
<legend class="fieldset-legend">ИНН</legend>
<input v-model="counterpartyForm.inn" type="text" class="input w-full" >
</fieldset>
<fieldset class="fieldset">
<legend class="fieldset-legend">КПП</legend>
<input v-model="counterpartyForm.kpp" type="text" class="input w-full" >
</fieldset>
<fieldset class="fieldset">
<legend class="fieldset-legend">ОГРН</legend>
<input v-model="counterpartyForm.ogrn" type="text" class="input w-full" >
</fieldset>
<label class="block space-y-2">
<span class="text-sm font-semibold text-[#355947]">Полное наименование</span>
<input v-model="counterpartyForm.companyFullName" type="text" class="input manager-field w-full">
</label>
</div>
<fieldset class="fieldset">
<legend class="fieldset-legend">Юридический адрес</legend>
<textarea v-model="counterpartyForm.legalAddress" class="textarea min-h-24 w-full" />
</fieldset>
<div class="grid gap-4 md:grid-cols-3">
<label class="block space-y-2">
<span class="text-sm font-semibold text-[#355947]">ИНН</span>
<input v-model="counterpartyForm.inn" type="text" class="input manager-field w-full">
</label>
<label class="block space-y-2">
<span class="text-sm font-semibold text-[#355947]">КПП</span>
<input v-model="counterpartyForm.kpp" type="text" class="input manager-field w-full">
</label>
<label class="block space-y-2">
<span class="text-sm font-semibold text-[#355947]">ОГРН</span>
<input v-model="counterpartyForm.ogrn" type="text" class="input manager-field w-full">
</label>
</div>
<label class="block space-y-2">
<span class="text-sm font-semibold text-[#355947]">Юридический адрес</span>
<textarea v-model="counterpartyForm.legalAddress" class="textarea manager-field min-h-28 w-full" />
</label>
</div>
</section>
<div class="divider my-0" />
<section>
<h2 class="mb-3 text-base font-bold">2. Банк (DaData)</h2>
<section class="surface-card rounded-3xl p-5 md:p-6">
<div class="mb-5">
<h2 class="text-xl font-black tracking-[-0.02em] text-[#123824]">Банковские реквизиты</h2>
</div>
<div class="grid gap-4">
<div ref="bankDropdownRef" class="relative">
<fieldset class="fieldset">
<legend class="fieldset-legend">Поиск банка</legend>
<label class="block space-y-2">
<span class="text-sm font-semibold text-[#355947]">Поиск банка</span>
<div class="relative">
<input
v-model="bankSearch"
type="text"
class="input w-full"
class="input manager-field w-full pr-11"
placeholder="Введите название банка"
@input="scheduleBankSuggest"
@focus="bankOpen = bankSuggestions.length > 0"
>
</fieldset>
<span v-if="bankLoading" class="loading loading-spinner loading-sm absolute right-3 top-1/2 -translate-y-1/2" />
<span v-if="bankLoading" class="loading loading-spinner loading-sm absolute right-4 top-1/2 -translate-y-1/2 text-[#5c7b69]" />
</div>
</label>
<div
v-if="bankOpen && bankSuggestions.length > 0"
class="absolute z-30 mt-2 max-h-72 w-full overflow-auto rounded-box bg-base-100 p-2"
class="absolute z-30 mt-2 max-h-72 w-full overflow-auto rounded-[1.4rem] border border-[#e1ebe4] bg-white p-2 shadow-[0_24px_48px_rgba(18,56,36,0.12)]"
>
<button
v-for="item in bankSuggestions"
:key="`${item.value}-${item.data?.bic || ''}`"
type="button"
class="btn btn-ghost mb-1 h-auto min-h-0 w-full justify-start whitespace-normal px-3 py-2 text-left"
class="mb-1 w-full rounded-2xl px-4 py-3 text-left transition hover:bg-[#f5faf7]"
@click="applyBankSuggestion(item)"
>
<span>
<span class="block text-sm font-semibold">{{ item.value }}</span>
<span class="block text-xs opacity-70">БИК: {{ item.data?.bic || '—' }}</span>
</span>
<span class="block text-sm font-semibold text-[#123824]">{{ item.value }}</span>
<span class="mt-1 block text-xs text-[#5c7b69]">БИК: {{ item.data?.bic || '—' }}</span>
</button>
</div>
</div>
<fieldset class="fieldset">
<legend class="fieldset-legend">Банк</legend>
<input v-model="counterpartyForm.bankName" type="text" class="input w-full" >
</fieldset>
<div class="grid gap-4 md:grid-cols-2">
<label class="block space-y-2">
<span class="text-sm font-semibold text-[#355947]">Банк</span>
<input v-model="counterpartyForm.bankName" type="text" class="input manager-field w-full">
</label>
<div class="grid gap-3 sm:grid-cols-2">
<fieldset class="fieldset">
<legend class="fieldset-legend">БИК</legend>
<input v-model="counterpartyForm.bik" type="text" class="input w-full" >
</fieldset>
<fieldset class="fieldset">
<legend class="fieldset-legend">Корр. счет</legend>
<input v-model="counterpartyForm.correspondentAccount" type="text" class="input w-full" >
</fieldset>
<label class="block space-y-2">
<span class="text-sm font-semibold text-[#355947]">БИК</span>
<input v-model="counterpartyForm.bik" type="text" class="input manager-field w-full">
</label>
</div>
<fieldset class="fieldset">
<legend class="fieldset-legend">Расчетный счет</legend>
<input v-model="counterpartyForm.checkingAccount" type="text" class="input w-full" >
</fieldset>
<div class="grid gap-4 md:grid-cols-2">
<label class="block space-y-2">
<span class="text-sm font-semibold text-[#355947]">Корреспондентский счет</span>
<input v-model="counterpartyForm.correspondentAccount" type="text" class="input manager-field w-full">
</label>
<label class="block space-y-2">
<span class="text-sm font-semibold text-[#355947]">Расчетный счет</span>
<input v-model="counterpartyForm.checkingAccount" type="text" class="input manager-field w-full">
</label>
</div>
</div>
</section>
<div class="divider my-0" />
<section class="surface-card rounded-3xl p-5 md:p-6">
<div class="mb-5">
<h2 class="text-xl font-black tracking-[-0.02em] text-[#123824]">Подписанты и основания</h2>
</div>
<section>
<h2 class="mb-3 text-base font-bold">3. Подписант и основание</h2>
<div class="grid gap-4">
<div class="grid gap-4 md:grid-cols-2">
<label class="block space-y-2">
<span class="text-sm font-semibold text-[#355947]">ФИО подписанта</span>
<input
v-model="counterpartyForm.signerFullName"
type="text"
class="input manager-field w-full"
placeholder="Иванов Иван Иванович"
>
</label>
<fieldset class="fieldset">
<legend class="fieldset-legend">ФИО подписанта</legend>
<input v-model="counterpartyForm.signerFullName" type="text" class="input w-full" placeholder="Иванов Иван Иванович" >
</fieldset>
<label class="block space-y-2">
<span class="text-sm font-semibold text-[#355947]">Должность</span>
<input
v-model="counterpartyForm.signerPosition"
type="text"
class="input manager-field w-full"
placeholder="Генеральный директор"
>
</label>
</div>
<fieldset class="fieldset">
<legend class="fieldset-legend">Должность</legend>
<input v-model="counterpartyForm.signerPosition" type="text" class="input w-full" placeholder="Генеральный директор" >
</fieldset>
<fieldset class="fieldset">
<legend class="fieldset-legend">Основание полномочий</legend>
<label class="block space-y-2">
<span class="text-sm font-semibold text-[#355947]">Основание полномочий</span>
<textarea
v-model="counterpartyForm.signerBasis"
class="textarea min-h-24 w-full"
class="textarea manager-field min-h-28 w-full"
placeholder="Действует на основании Устава"
/>
</fieldset>
<button class="btn btn-primary mt-4 w-full" :disabled="saveCounterpartyMutation.loading.value || !profileIsComplete" @click="saveCounterpartyProfile">
{{ saveCounterpartyMutation.loading.value ? 'Сохраняем' : 'Сохранить' }}
</button>
<p v-if="profileUpdatedAt" class="mt-2 text-xs opacity-70">Обновлено: {{ new Date(profileUpdatedAt).toLocaleString() }}</p>
</section>
</label>
</div>
<div v-if="profileFeedback" class="alert mt-4" :class="profileFeedbackTone === 'success' ? 'alert-success' : 'alert-error'">
<div class="mt-6 border-t border-[#edf2ee] pt-5">
<button
class="btn h-12 w-full rounded-full border-0 bg-[#123824] px-7 text-white shadow-[0_18px_34px_rgba(18,56,36,0.18)] hover:bg-[#0f2f20] disabled:border-0 disabled:bg-[#cfd8d2] disabled:text-[#6f8579] md:w-auto"
:disabled="saveCounterpartyMutation.loading.value || !profileIsComplete"
@click="saveCounterpartyProfile"
>
{{ saveCounterpartyMutation.loading.value ? 'Сохраняем...' : 'Сохранить' }}
</button>
</div>
</section>
<div v-if="profileFeedback" class="alert" :class="profileFeedbackTone === 'success' ? 'alert-success' : 'alert-error'">
{{ profileFeedback }}
</div>
</div>

View File

@@ -1,6 +1,7 @@
<script setup lang="ts">
import { useQuery } from '@vue/apollo-composable';
import { useMutation, useQuery } from '@vue/apollo-composable';
import {
DeleteMyMessengerConnectionDocument,
MeDocument,
MyMessengerConnectionsDocument,
} from '~/composables/graphql/generated';
@@ -12,9 +13,11 @@ import {
} from '~/composables/useMessengerConnectionPresentation';
import { useMessengerStart } from '~/composables/useMessengerStart';
type MessengerChannel = 'TELEGRAM' | 'MAX';
type MessengerItem = {
id: string;
type: 'TELEGRAM' | 'MAX';
type: MessengerChannel;
isActive: boolean;
channelId: string;
displayName?: string | null;
@@ -22,23 +25,50 @@ type MessengerItem = {
avatarAvailable?: boolean | null;
};
type MessengerOption = {
channel: MessengerChannel;
label: string;
buttonClass: string;
iconClass: string;
unavailableText: string;
};
const config = useRuntimeConfig();
const feedback = ref('');
const meQuery = useQuery(MeDocument);
const connectionsQuery = useQuery(MyMessengerConnectionsDocument);
const deleteConnectionMutation = useMutation(DeleteMyMessengerConnectionDocument);
const { openMessengerBot, pendingChannel } = useMessengerStart();
const telegramConnection = computed(() =>
connectionsQuery.result.value?.myMessengerConnections?.find(
(item: MessengerItem) => item.type === 'TELEGRAM' && item.isActive,
),
) ?? null,
);
const maxConnection = computed(() =>
connectionsQuery.result.value?.myMessengerConnections?.find(
(item: MessengerItem) => item.type === 'MAX' && item.isActive,
),
) ?? null,
);
const messengerOptions: MessengerOption[] = [
{
channel: 'TELEGRAM',
label: 'Telegram',
buttonClass: 'bg-[#1a9c63] text-white hover:bg-[#148553]',
iconClass: 'bg-[#123824] text-white',
unavailableText: 'Telegram пока не настроен в окружении фронта.',
},
{
channel: 'MAX',
label: 'MAX',
buttonClass: 'bg-[#2b7fff] text-white hover:bg-[#1d6df1]',
iconClass: 'bg-[#2b7fff] text-white',
unavailableText: 'MAX пока не настроен в окружении фронта.',
},
];
function buildBotConnectUrl(baseUrl: string) {
const accountEmail = meQuery.result.value?.me?.email?.trim().toLowerCase();
if (!accountEmail || !baseUrl) {
@@ -51,9 +81,32 @@ function buildBotConnectUrl(baseUrl: string) {
const telegramConnectUrl = computed(() => buildBotConnectUrl(config.public.telegramBotUrl || ''));
const maxConnectUrl = computed(() => buildBotConnectUrl(config.public.maxBotUrl || ''));
async function connectMessenger(channel: 'TELEGRAM' | 'MAX') {
const baseUrl = channel === 'TELEGRAM' ? telegramConnectUrl.value : maxConnectUrl.value;
function connectUrl(channel: MessengerChannel) {
return channel === 'TELEGRAM' ? telegramConnectUrl.value : maxConnectUrl.value;
}
function connectionFor(channel: MessengerChannel) {
return channel === 'TELEGRAM' ? telegramConnection.value : maxConnection.value;
}
const activeConnections = computed(() => messengerOptions
.map((option) => ({
option,
connection: connectionFor(option.channel),
}))
.filter((item) => Boolean(item.connection)));
const availableOptions = computed(() => messengerOptions
.filter((option) => !connectionFor(option.channel)));
async function connectMessenger(channel: MessengerChannel) {
feedback.value = '';
const baseUrl = connectUrl(channel);
if (!baseUrl) {
feedback.value = channel === 'MAX'
? 'MAX не откроется, пока не задан NUXT_PUBLIC_MAX_BOT_URL.'
: 'Telegram не откроется, пока не задан NUXT_PUBLIC_TELEGRAM_BOT_URL.';
return;
}
@@ -63,91 +116,123 @@ async function connectMessenger(channel: 'TELEGRAM' | 'MAX') {
redirectPath: `/profile/notifications/success?connected=${channel.toLowerCase()}`,
});
}
async function removeConnection(connectionId: string) {
feedback.value = '';
const result = await deleteConnectionMutation.mutate({
connectionId,
});
if (!result?.data?.deleteMyMessengerConnection) {
feedback.value = 'Не удалось отключить аккаунт. Попробуйте еще раз.';
return;
}
await connectionsQuery.refetch();
}
</script>
<template>
<section class="space-y-6">
<NuxtLink to="/profile" class="link link-hover text-sm"> Назад в профиль</NuxtLink>
<h1 class="text-3xl font-extrabold text-[#0f2f20]">Уведомления</h1>
<UiBackHeader
to="/profile"
back-label="Назад в профиль"
title="Уведомления"
subtitle="Подключите мессенджер, чтобы получать уведомления по заказам."
/>
<div class="surface-card rounded-3xl p-5">
<p class="text-sm text-[#355947]">
Подключите Telegram и Max, чтобы получать статусы заказов и важные уведомления в удобном канале.
</p>
<div class="mt-4 space-y-3">
<div class="rounded-2xl bg-[#f8fbf9] p-4 transition hover:shadow-md">
<p class="font-semibold">Telegram</p>
<div v-if="telegramConnection" class="mt-3 flex items-center gap-3 rounded-2xl bg-white px-3 py-2">
<div v-if="messengerConnectionAvatarSrc(telegramConnection)" class="avatar">
<div class="h-11 w-11 rounded-full">
<img :src="messengerConnectionAvatarSrc(telegramConnection)" :alt="messengerConnectionName(telegramConnection)">
</div>
</div>
<div v-else class="avatar placeholder">
<div class="h-11 w-11 rounded-full bg-[#123824] text-sm font-bold text-white">
<span>{{ messengerConnectionInitials(telegramConnection, 'TG') }}</span>
</div>
</div>
<div class="min-w-0">
<p class="truncate text-sm font-semibold text-[#123824]">
{{ messengerConnectionName(telegramConnection) }}
</p>
<p class="truncate text-xs text-[#5c7b69]">
{{ messengerConnectionHandle(telegramConnection) || 'Подключен' }}
</p>
</div>
</div>
<p v-else class="text-sm opacity-80">Не подключен</p>
<button
class="btn btn-secondary mt-3 w-full"
:class="{ 'btn-disabled pointer-events-none': !telegramConnectUrl }"
:disabled="pendingChannel === 'TELEGRAM' || !telegramConnectUrl"
@click="connectMessenger('TELEGRAM')"
<div
v-if="feedback"
class="rounded-[24px] border px-4 py-3 text-sm font-medium"
:class="feedback.includes('Не удалось') || feedback.includes('не откроется')
? 'border-[#f1d1c7] bg-[#fff3ef] text-[#9d4426]'
: 'border-[#cbe9d6] bg-[#f1fbf5] text-[#1c6b45]'"
>
{{
pendingChannel === 'TELEGRAM'
? 'Открываем Telegram…'
: telegramConnection
? 'Переподключить Telegram'
: 'Подключить Telegram'
}}
{{ feedback }}
</div>
<div
v-if="activeConnections.length > 0"
class="space-y-4"
>
<article
v-for="{ option, connection } in activeConnections"
:key="connection!.id"
class="rounded-[28px] bg-white px-5 py-4 shadow-[0_18px_38px_rgba(18,56,36,0.08)]"
>
<div class="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
<div class="flex min-w-0 items-center gap-4">
<div v-if="messengerConnectionAvatarSrc(connection)" class="avatar">
<div class="h-14 w-14 rounded-[20px]">
<img :src="messengerConnectionAvatarSrc(connection)" :alt="messengerConnectionName(connection)">
</div>
</div>
<div
v-else
class="flex h-14 w-14 items-center justify-center rounded-[20px] text-sm font-black"
:class="option.iconClass"
>
{{ messengerConnectionInitials(connection, option.channel === 'TELEGRAM' ? 'TG' : 'MX') }}
</div>
<div class="min-w-0">
<p class="truncate text-lg font-bold text-[#123824]">{{ messengerConnectionName(connection) }}</p>
<p class="truncate text-sm text-[#557562]">{{ messengerConnectionHandle(connection) || connection.channelId }}</p>
</div>
</div>
<button
class="btn rounded-full border border-[#e5cfc7] bg-[#fff5f1] px-5 text-[#a64d2d] hover:border-[#deb5a8] hover:bg-[#ffe8e0]"
:disabled="deleteConnectionMutation.loading.value"
@click="removeConnection(connection!.id)"
>
{{ deleteConnectionMutation.loading.value ? 'Отключаем...' : 'Отключить' }}
</button>
</div>
</article>
</div>
<div class="rounded-2xl bg-[#f8fbf9] p-4 transition hover:shadow-md">
<p class="font-semibold">Max</p>
<div v-if="maxConnection" class="mt-3 flex items-center gap-3 rounded-2xl bg-white px-3 py-2">
<div class="avatar placeholder">
<div class="h-11 w-11 rounded-full bg-[#2b7fff] text-sm font-bold text-white">
<span>{{ messengerConnectionInitials(maxConnection, 'MX') }}</span>
</div>
</div>
<div class="min-w-0">
<p class="truncate text-sm font-semibold text-[#123824]">
{{ messengerConnectionName(maxConnection) }}
</p>
<p class="truncate text-xs text-[#5c7b69]">
{{ messengerConnectionHandle(maxConnection) || 'Подключен' }}
</p>
</div>
</div>
<p v-else class="text-sm opacity-80">Не подключен</p>
<button
class="btn btn-accent mt-3 w-full"
:class="{ 'btn-disabled pointer-events-none': !maxConnectUrl }"
:disabled="pendingChannel === 'MAX' || !maxConnectUrl"
@click="connectMessenger('MAX')"
<p
v-else
class="text-sm leading-6 text-[#557562]"
>
{{
pendingChannel === 'MAX'
? 'Открываем Max…'
: maxConnection
? 'Переподключить Max'
: 'Подключить Max'
}}
</button>
Пока ничего не подключено.
</p>
<div
v-if="availableOptions.length > 0"
class="space-y-4"
>
<button
v-for="option in availableOptions"
:key="option.channel"
class="flex w-full items-center justify-between rounded-[28px] border-0 px-5 py-5 text-left shadow-[0_18px_38px_rgba(18,56,36,0.08)] transition"
:class="[option.buttonClass, { 'opacity-60': !connectUrl(option.channel) }]"
:disabled="pendingChannel === option.channel || !connectUrl(option.channel)"
@click="connectMessenger(option.channel)"
>
<div class="flex items-center gap-4">
<div
class="inline-flex h-11 min-w-11 items-center justify-center rounded-2xl bg-white text-sm font-black"
:class="option.channel === 'TELEGRAM' ? 'text-[#1a9c63]' : 'text-[#2b7fff]'"
>
{{ option.channel === 'TELEGRAM' ? 'TG' : 'MAX' }}
</div>
<p class="text-base font-semibold text-white">
{{ pendingChannel === option.channel ? `Открываем ${option.label}...` : `Подключить ${option.label}` }}
</p>
</div>
</button>
<div class="space-y-2">
<p
v-for="option in availableOptions.filter((item) => !connectUrl(item.channel))"
:key="`${option.channel}-hint`"
class="text-sm text-[#8b5a49]"
>
{{ option.unavailableText }}
</p>
</div>
</div>
</section>

View File

@@ -48,12 +48,12 @@ const successConnection = computed(() =>
const profileName = computed(() => meQuery.result.value?.me?.fullName?.trim() || meQuery.result.value?.me?.email || 'Пользователь');
const successTitle = computed(() =>
connectedChannel.value === 'telegram' ? 'Telegram успешно подключен' : 'Канал успешно подключен',
connectedChannel.value === 'telegram' ? 'Telegram успешно подключен' : 'MAX успешно подключен',
);
const successText = computed(() =>
connectedChannel.value === 'telegram'
? 'Теперь этот Telegram привязан к вашему личному кабинету. Все важные уведомления и статусы заказов будут приходить сюда.'
: 'Канал успешно привязан к вашему личному кабинету.',
: 'Теперь этот MAX привязан к вашему личному кабинету. Все важные уведомления и статусы заказов будут приходить сюда.',
);
const successAvatarSrc = computed(() => messengerConnectionAvatarSrc(successConnection.value));
const successAvatarInitials = computed(() =>

117
app/pages/settings-sync.vue Normal file
View File

@@ -0,0 +1,117 @@
<script setup lang="ts">
import { useQuery } from '@vue/apollo-composable';
import {
IntegrationSyncDashboardDocument,
type IntegrationSyncDashboardQuery,
} from '~/composables/graphql/generated';
definePageMeta({
middleware: ['manager-only'],
path: '/admin/settings/sync',
});
type SyncItem = IntegrationSyncDashboardQuery['integrationSyncDashboard']['items'][number];
const syncDashboardQuery = useQuery(IntegrationSyncDashboardDocument);
const dashboard = computed(() => syncDashboardQuery.result.value?.integrationSyncDashboard ?? null);
const syncItems = computed<SyncItem[]>(() => dashboard.value?.items ?? []);
function itemIsHealthy(item: SyncItem) {
return item.syncedCount > 0 && Boolean(item.lastSyncedAt);
}
function statusLabel(item: SyncItem) {
return itemIsHealthy(item) ? 'Работает' : 'Нет данных';
}
function syncSummary(item: SyncItem) {
if (!item.syncedCount) {
return 'Данных пока нет.';
}
return `Загружено ${item.syncedCount} записей.`;
}
function formatDateTime(value?: string | null) {
if (!value) {
return 'Пока нет';
}
return new Date(value).toLocaleString('ru-RU', {
day: '2-digit',
month: 'short',
hour: '2-digit',
minute: '2-digit',
});
}
</script>
<template>
<section class="space-y-6">
<div class="space-y-2">
<h1 class="text-3xl font-extrabold text-[#0f2f20]">1С</h1>
<p class="text-sm leading-6 text-[#557562]">
Статус синхронизации по ключевым событиям.
</p>
</div>
<div v-if="syncDashboardQuery.loading.value" class="manager-empty-state">
Загружаем статусы...
</div>
<template v-else-if="dashboard">
<section class="space-y-4">
<article
v-for="item in syncItems"
:key="item.id"
class="rounded-[28px] bg-white p-5 shadow-[0_18px_38px_rgba(18,56,36,0.08)]"
>
<div class="flex flex-col gap-4 md:flex-row md:items-start md:justify-between">
<div class="min-w-0 space-y-3">
<div class="flex flex-wrap items-center gap-3">
<span
class="inline-flex items-center gap-2 rounded-full px-3 py-1 text-xs font-bold uppercase tracking-[0.12em]"
:class="itemIsHealthy(item) ? 'bg-[#e8f5ec] text-[#1c6b45]' : 'bg-[#fff1ec] text-[#b54b2f]'"
>
<span
class="h-2.5 w-2.5 rounded-full"
:class="itemIsHealthy(item) ? 'bg-[#1c6b45]' : 'bg-[#d76745]'"
/>
{{ statusLabel(item) }}
</span>
<span class="text-xs font-semibold uppercase tracking-[0.12em] text-[#6a8a76]">
{{ item.source }}
</span>
</div>
<div class="space-y-1">
<h2 class="text-xl font-bold text-[#123824]">{{ item.title }}</h2>
<p class="text-sm text-[#557562]">
Последний run: {{ formatDateTime(item.lastSyncedAt) }}
</p>
</div>
<p class="text-sm leading-6 text-[#123824]">
{{ syncSummary(item) }}
</p>
<p class="text-sm leading-6 text-[#557562]">
{{ item.note }}
</p>
</div>
<div class="rounded-[20px] bg-[#f7fbf8] px-4 py-3 text-left md:min-w-[180px] md:text-right">
<p class="text-xs font-semibold uppercase tracking-[0.12em] text-[#688676]">
Обновлений
</p>
<p class="mt-2 text-2xl font-black tracking-[-0.04em] text-[#123824]">
{{ item.syncedCount }}
</p>
</div>
</div>
</article>
</section>
</template>
</section>
</template>

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 25 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 36 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 44 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 6.0 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 6.1 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 6.2 KiB

View File

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

After

Width:  |  Height:  |  Size: 4.6 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 6.4 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 5.7 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 5.3 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 5.7 KiB

View File

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

After

Width:  |  Height:  |  Size: 4.6 KiB

View File

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

After

Width:  |  Height:  |  Size: 2.5 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 5.8 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 7.0 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 11 KiB

View File

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

After

Width:  |  Height:  |  Size: 3.5 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 7.4 KiB

View File

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

View File

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

2752
docs/tz-fregat.typ Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,8 @@
mutation CreateBonusProgramLink($userId: ID!) {
createBonusProgramLink(userId: $userId) {
userId
token
url
expiresAt
}
}

View File

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

View File

@@ -0,0 +1,3 @@
mutation DeleteMyMessengerConnection($connectionId: ID!) {
deleteMyMessengerConnection(connectionId: $connectionId)
}

View File

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

View File

@@ -0,0 +1,19 @@
query IntegrationSyncDashboard {
integrationSyncDashboard {
generatedAt
lastActivityAt
totalOrders
totalProducts
totalClients
items {
id
title
description
source
syncedCount
lastSyncedAt
status
note
}
}
}

View File

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

View File

@@ -193,6 +193,33 @@ type NotificationTemplate {
channels: [NotificationTemplateChannel!]!
}
type IntegrationSyncItem {
id: ID!
title: String!
description: String!
source: String!
syncedCount: Int!
lastSyncedAt: DateTime
status: String!
note: String!
}
type IntegrationSyncDashboard {
generatedAt: DateTime!
lastActivityAt: DateTime
totalOrders: Int!
totalProducts: Int!
totalClients: Int!
items: [IntegrationSyncItem!]!
}
type BonusProgramLink {
userId: ID!
token: String!
url: String!
expiresAt: DateTime!
}
type Warehouse {
id: ID!
code: String!
@@ -215,11 +242,29 @@ type Product {
thicknessMicron: Int
sleeveBrand: String
quantityPerBox: String
tags: [String!]!
isCustomizable: Boolean!
isActive: Boolean!
availableInWarehouses: [ProductWarehouseBalance!]!
}
type CatalogProductTypeSetting {
productType: String!
showQuantityPerBox: Boolean!
allowCustomLength: Boolean!
customLengthMinM: Int
customLengthMaxM: Int
customLengthStepM: Int
allowCustomSleeveBrand: Boolean!
allowCustomLabel: Boolean!
widthOptionsMm: [Int!]!
lengthOptionsM: [Int!]!
thicknessOptionsMicron: [Int!]!
sleeveOptions: [String!]!
colorOptions: [String!]!
labelOptions: [String!]!
}
type CartItem {
id: ID!
productId: ID!
@@ -380,8 +425,10 @@ type Query {
myMessengerConnections: [MessengerConnection!]!
myNotificationHistory(channel: MessengerType!, limit: Int = 50): [NotificationHistoryItem!]!
notificationTemplates: [NotificationTemplate!]!
integrationSyncDashboard: IntegrationSyncDashboard!
managerNotificationHistory(userId: ID!, channel: MessengerType!, limit: Int = 50): [NotificationHistoryItem!]!
clientProducts: [Product!]!
catalogProductTypeSettings: [CatalogProductTypeSetting!]!
order(id: ID!): Order
myOrders: [Order!]!
myCurrentOrders: [Order!]!
@@ -462,6 +509,23 @@ input UpdateCartItemQuantityInput {
quantity: Float!
}
input UpsertCatalogProductTypeSettingInput {
productType: String!
showQuantityPerBox: Boolean!
allowCustomLength: Boolean!
customLengthMinM: Int
customLengthMaxM: Int
customLengthStepM: Int
allowCustomSleeveBrand: Boolean!
allowCustomLabel: Boolean!
widthOptionsMm: [Int!]!
lengthOptionsM: [Int!]!
thicknessOptionsMicron: [Int!]!
sleeveOptions: [String!]!
colorOptions: [String!]!
labelOptions: [String!]!
}
input ReadyOrderItemInput {
productId: ID!
quantity: Float!
@@ -523,7 +587,9 @@ type Mutation {
createInvitation(input: CreateInvitationInput!): Invitation!
acceptInvitation(input: AcceptInvitationInput!): User!
connectMessenger(input: ConnectMessengerInput!): MessengerConnection!
deleteMyMessengerConnection(connectionId: ID!): Boolean!
upsertMyCounterpartyProfile(input: UpsertMyCounterpartyProfileInput!): CounterpartyProfile!
upsertCatalogProductTypeSetting(input: UpsertCatalogProductTypeSettingInput!): CatalogProductTypeSetting!
addProductToCart(productId: ID!): Cart!
updateCartItemQuantity(input: UpdateCartItemQuantityInput!): Cart!
removeCartItem(productId: ID!): Cart!
@@ -541,6 +607,7 @@ type Mutation {
clientReviewOrder(orderId: ID!, decision: Decision!): Order!
createReferral(input: CreateReferralInput!): ReferralLink!
createBonusProgramLink(userId: ID!): BonusProgramLink!
addBonusTransaction(input: AddBonusTransactionInput!): BonusTransaction!
requestRewardWithdrawal(input: RequestRewardWithdrawalInput!): RewardWithdrawalRequest!
reviewRewardWithdrawal(input: ReviewRewardWithdrawalInput!): RewardWithdrawalRequest!

View File

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

2953
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

9
pnpm-workspace.yaml Normal file
View File

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