Compare commits
87 Commits
17b5a87699
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f9be58d061 | ||
|
|
898171cb5f | ||
|
|
5f68cc80b1 | ||
|
|
0ce037df6e | ||
|
|
d3f56efac1 | ||
|
|
49e0e444d9 | ||
|
|
6a49cbcc30 | ||
|
|
107623ca92 | ||
|
|
dfc053b723 | ||
|
|
81efc78029 | ||
|
|
948385bc32 | ||
|
|
283abf95a1 | ||
|
|
fb22a6b11d | ||
|
|
b971444976 | ||
|
|
415fcf40fe | ||
|
|
d9cefb9f54 | ||
|
|
a52eb45ca3 | ||
|
|
3d790b2102 | ||
|
|
790b3a1d99 | ||
|
|
3885782afd | ||
|
|
d21ff3437f | ||
|
|
d86d817bce | ||
|
|
234f46c082 | ||
|
|
bbd9dcfb5a | ||
|
|
ac312a3a62 | ||
|
|
0a96adbb78 | ||
|
|
98ae168a93 | ||
|
|
e050fd55a5 | ||
|
|
58e9d6806d | ||
|
|
542ad1b648 | ||
|
|
b7a5018c6e | ||
|
|
eb6dcf9a52 | ||
|
|
d514eac990 | ||
|
|
3a3bd09a8c | ||
|
|
fc6117c8f5 | ||
|
|
fccb3039bf | ||
|
|
46bb36d63c | ||
|
|
ef0622fe89 | ||
|
|
df721e273d | ||
|
|
3b3959ced0 | ||
|
|
e8ff766c24 | ||
|
|
03ac74e10b | ||
|
|
09054647aa | ||
|
|
93074c5c14 | ||
|
|
73adbb76c7 | ||
|
|
0236d88b20 | ||
|
|
76ab87620e | ||
|
|
21e40d3fa1 | ||
|
|
848b491a90 | ||
|
|
28b29480bc | ||
|
|
2b134940f0 | ||
|
|
6f1df4bf00 | ||
|
|
872dba648c | ||
|
|
de5fc6b4a8 | ||
|
|
d6f1a03501 | ||
|
|
a5fd0a7d5e | ||
|
|
7ed5fbd66d | ||
|
|
e8fbe84e4f | ||
|
|
f8880d75c6 | ||
|
|
21ce43b790 | ||
|
|
8d6bc7346c | ||
|
|
5173956b06 | ||
|
|
249e081dec | ||
|
|
345301e138 | ||
|
|
af5d06f990 | ||
|
|
647947d9cb | ||
|
|
a54b4f4405 | ||
|
|
86eee08d87 | ||
|
|
722dbb89cb | ||
|
|
5eafdd4e8f | ||
|
|
c70328d352 | ||
|
|
403aeea838 | ||
|
|
74634f9759 | ||
|
|
a820d1f7ee | ||
|
|
fe775cc968 | ||
|
|
77015ed243 | ||
|
|
6ed821a295 | ||
|
|
5db6474e94 | ||
|
|
fa953738db | ||
|
|
537e323d13 | ||
|
|
f1129199bd | ||
|
|
aabebe9b90 | ||
|
|
868dcf3270 | ||
|
|
b640885ef0 | ||
|
|
0380c54d60 | ||
|
|
372626e2ed | ||
|
|
3ea9753cc9 |
2
.gitignore
vendored
@@ -5,6 +5,8 @@
|
|||||||
.nitro
|
.nitro
|
||||||
.cache
|
.cache
|
||||||
dist
|
dist
|
||||||
|
docs/.vitepress/cache
|
||||||
|
docs/export
|
||||||
|
|
||||||
# Node dependencies
|
# Node dependencies
|
||||||
node_modules
|
node_modules
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ WORKDIR /app
|
|||||||
|
|
||||||
RUN corepack enable
|
RUN corepack enable
|
||||||
|
|
||||||
COPY package.json pnpm-lock.yaml ./
|
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
|
||||||
RUN pnpm install --frozen-lockfile
|
RUN pnpm install --frozen-lockfile
|
||||||
|
|
||||||
FROM deps AS build
|
FROM deps AS build
|
||||||
|
|||||||
128
app/app.vue
@@ -17,69 +17,94 @@ const managerPageTabs = computed(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
route.path === '/client-orders'
|
route.path === '/admin/orders'
|
||||||
|| route.path.startsWith('/client-orders/')
|
|| route.path.startsWith('/admin/orders/')
|
||||||
|| route.path === '/clients'
|
|
||||||
|| route.path.startsWith('/clients/')
|
|
||||||
) {
|
) {
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
key: 'orders',
|
key: 'orders',
|
||||||
label: 'Заказы',
|
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: {
|
to: {
|
||||||
path: '/client-orders',
|
path: '/admin/orders',
|
||||||
query: route.path === '/client-orders' ? route.query : {},
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'clients',
|
key: 'clients',
|
||||||
label: 'Клиенты',
|
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: {
|
to: {
|
||||||
path: '/clients',
|
path: '/admin/orders/clients',
|
||||||
query: route.path === '/clients' ? route.query : {},
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
if (route.path === '/bonus-system') {
|
if (route.path.startsWith('/admin/bonuses')) {
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
key: 'balances',
|
key: 'balances',
|
||||||
label: 'Балансы',
|
label: 'Бонусные счета',
|
||||||
active: route.query.tab !== 'withdrawals' && route.query.tab !== 'products' && route.query.tab !== 'manager',
|
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: {
|
to: {
|
||||||
path: '/bonus-system',
|
path: '/admin/bonuses/balances',
|
||||||
query: {
|
|
||||||
...route.query,
|
|
||||||
tab: 'balances',
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'withdrawals',
|
key: 'withdrawals',
|
||||||
label: 'Заявки на выплату',
|
label: 'Заявки на выплату',
|
||||||
active: route.query.tab === 'withdrawals',
|
active: route.path === '/admin/bonuses/requests'
|
||||||
|
|| route.path.startsWith('/admin/bonuses/requests/'),
|
||||||
to: {
|
to: {
|
||||||
path: '/bonus-system',
|
path: '/admin/bonuses/requests',
|
||||||
query: {
|
|
||||||
...route.query,
|
|
||||||
tab: 'withdrawals',
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'products',
|
key: 'rewards',
|
||||||
label: 'Товары',
|
label: 'Вознаграждения',
|
||||||
active: route.query.tab === 'products' || route.query.tab === 'manager',
|
active: route.path === '/admin/bonuses/rewards',
|
||||||
to: {
|
to: {
|
||||||
path: '/bonus-system',
|
path: '/admin/bonuses/rewards',
|
||||||
query: {
|
},
|
||||||
...route.query,
|
},
|
||||||
tab: 'products',
|
];
|
||||||
},
|
}
|
||||||
|
|
||||||
|
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',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
@@ -87,16 +112,39 @@ const managerPageTabs = computed(() => {
|
|||||||
|
|
||||||
return [];
|
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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div :class="isBonusProgramPage ? 'bonus-program-shell' : 'lk-shell'" data-theme="aqua">
|
<div :class="isBonusProgramPage ? 'bonus-program-shell' : 'lk-shell'" data-theme="aqua">
|
||||||
<UiAppHeader v-if="!isLoginPage && !isBonusProgramPage" />
|
<UiAppHeader v-if="!isLoginPage && !isBonusProgramPage" />
|
||||||
<main
|
<main :class="mainClass">
|
||||||
: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]' : '']"
|
|
||||||
>
|
|
||||||
<div v-if="managerPageTabs.length && !isBonusProgramPage" class="lk-page-tabs-shell">
|
<div v-if="managerPageTabs.length && !isBonusProgramPage" class="lk-page-tabs-shell">
|
||||||
<nav class="manager-page-tabs" aria-label="Разделы страницы">
|
<nav class="manager-page-tabs" aria-label="Разделы страницы">
|
||||||
<NuxtLink
|
<NuxtLink
|
||||||
@@ -111,11 +159,7 @@ const managerPageTabs = computed(() => {
|
|||||||
</nav>
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div :class="pageFrameClass">
|
||||||
:class="isBonusProgramPage
|
|
||||||
? 'bonus-program-stage'
|
|
||||||
: ['lk-content-canvas', { 'lk-content-canvas--with-tabs': managerPageTabs.length }]"
|
|
||||||
>
|
|
||||||
<NuxtPage />
|
<NuxtPage />
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
@@ -508,9 +508,9 @@ body {
|
|||||||
|
|
||||||
.manager-dock {
|
.manager-dock {
|
||||||
pointer-events: auto;
|
pointer-events: auto;
|
||||||
display: grid;
|
display: flex;
|
||||||
width: min(100%, 18.5rem);
|
width: fit-content;
|
||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
max-width: min(100%, calc(100vw - 2rem));
|
||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
border-radius: 1.75rem;
|
border-radius: 1.75rem;
|
||||||
background: rgba(255, 255, 255, 0.92);
|
background: rgba(255, 255, 255, 0.92);
|
||||||
@@ -524,7 +524,7 @@ body {
|
|||||||
|
|
||||||
.manager-dock__item {
|
.manager-dock__item {
|
||||||
display: flex;
|
display: flex;
|
||||||
min-width: 0;
|
min-width: 5.5rem;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
|||||||
@@ -1,15 +1,28 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { useQuery } from '@vue/apollo-composable';
|
import { useQuery } from '@vue/apollo-composable';
|
||||||
import { ClientProductsDocument, type ClientProductsQuery } from '~/composables/graphql/generated';
|
import {
|
||||||
|
CatalogProductTypeSettingsDocument,
|
||||||
|
ClientProductsDocument,
|
||||||
|
type CatalogProductTypeSettingsQuery,
|
||||||
|
type ClientProductsQuery,
|
||||||
|
} from '~/composables/graphql/generated';
|
||||||
import { useClientCart } from '~/composables/useClientCart';
|
import { useClientCart } from '~/composables/useClientCart';
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
productTypeSlug: string;
|
||||||
|
}>();
|
||||||
|
|
||||||
type ProductNode = ClientProductsQuery['clientProducts'][number];
|
type ProductNode = ClientProductsQuery['clientProducts'][number];
|
||||||
type ParamFieldKey = 'widthMm' | 'lengthM' | 'thicknessMicron' | 'sleeveBrand' | 'quantityPerBox';
|
type CatalogProductTypeSettingNode = CatalogProductTypeSettingsQuery['catalogProductTypeSettings'][number];
|
||||||
|
type ParamFieldKey = 'widthMm' | 'lengthM' | 'thicknessMicron' | 'sleeveBrand' | 'quantityPerBox' | 'colorTag' | 'labelTag';
|
||||||
type ParamValue = number | string;
|
type ParamValue = number | string;
|
||||||
|
|
||||||
type ParsedProduct = ProductNode & {
|
type ParsedProduct = ProductNode & {
|
||||||
productTypeLabel: string;
|
productTypeLabel: string;
|
||||||
quantityPerBoxOptions: string[];
|
quantityPerBoxOptions: string[];
|
||||||
|
normalizedTags: string[];
|
||||||
|
colorTags: string[];
|
||||||
|
labelTags: string[];
|
||||||
};
|
};
|
||||||
|
|
||||||
type ProductGroup = {
|
type ProductGroup = {
|
||||||
@@ -24,16 +37,37 @@ type GroupState = {
|
|||||||
thicknessMicron: number | null;
|
thicknessMicron: number | null;
|
||||||
sleeveBrand: string | null;
|
sleeveBrand: string | null;
|
||||||
quantityPerBox: string | null;
|
quantityPerBox: string | null;
|
||||||
isExpanded: boolean;
|
colorTag: string | null;
|
||||||
|
labelTag: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
const PARAM_KEYS: ParamFieldKey[] = ['widthMm', 'lengthM', 'thicknessMicron', 'sleeveBrand', 'quantityPerBox'];
|
const COLOR_TAGS = ['прозрачный', 'коричневый', 'белый', 'черный', 'желтый', 'зеленый', 'красный', 'синий', 'оранжевый', 'красно-белый'];
|
||||||
|
const LABEL_TAGS = ['хрупкое', 'подарок', 'акция'];
|
||||||
|
const PARAM_KEYS: ParamFieldKey[] = ['widthMm', 'lengthM', 'thicknessMicron', 'sleeveBrand', 'quantityPerBox', 'colorTag', 'labelTag'];
|
||||||
|
const DEFAULT_CATALOG_PRODUCT_TYPE_SETTING: CatalogProductTypeSettingNode = {
|
||||||
|
productType: '',
|
||||||
|
showQuantityPerBox: false,
|
||||||
|
allowCustomLength: false,
|
||||||
|
customLengthMinM: null,
|
||||||
|
customLengthMaxM: null,
|
||||||
|
customLengthStepM: null,
|
||||||
|
allowCustomSleeveBrand: false,
|
||||||
|
allowCustomLabel: false,
|
||||||
|
widthOptionsMm: [],
|
||||||
|
lengthOptionsM: [],
|
||||||
|
thicknessOptionsMicron: [],
|
||||||
|
sleeveOptions: [],
|
||||||
|
colorOptions: [],
|
||||||
|
labelOptions: [],
|
||||||
|
};
|
||||||
const parameterFields: Array<{ key: ParamFieldKey; label: string }> = [
|
const parameterFields: Array<{ key: ParamFieldKey; label: string }> = [
|
||||||
{ key: 'widthMm', label: 'Ширина' },
|
{ key: 'widthMm', label: 'Ширина' },
|
||||||
{ key: 'lengthM', label: 'Длина' },
|
{ key: 'lengthM', label: 'Длина' },
|
||||||
{ key: 'thicknessMicron', label: 'Толщина' },
|
{ key: 'thicknessMicron', label: 'Толщина' },
|
||||||
{ key: 'sleeveBrand', label: 'Втулка' },
|
{ key: 'sleeveBrand', label: 'Втулка' },
|
||||||
{ key: 'quantityPerBox', label: 'Короб' },
|
{ key: 'quantityPerBox', label: 'Короб' },
|
||||||
|
{ key: 'colorTag', label: 'Цвет' },
|
||||||
|
{ key: 'labelTag', label: 'Надпись' },
|
||||||
];
|
];
|
||||||
|
|
||||||
const coverPresets = [
|
const coverPresets = [
|
||||||
@@ -42,11 +76,21 @@ const coverPresets = [
|
|||||||
['#e8f5ec', '#b2e0c6', '#7dd0a9'],
|
['#e8f5ec', '#b2e0c6', '#7dd0a9'],
|
||||||
];
|
];
|
||||||
|
|
||||||
const { result, loading, error } = useQuery(ClientProductsDocument);
|
const productsQuery = useQuery(ClientProductsDocument);
|
||||||
const search = ref('');
|
const catalogSettingsQuery = useQuery(CatalogProductTypeSettingsDocument);
|
||||||
const groupStates = reactive<Record<string, GroupState>>({});
|
const groupStates = reactive<Record<string, GroupState>>({});
|
||||||
const { addProduct, getQuantity, incrementQuantity, decrementQuantity } = useClientCart();
|
const { addProduct, getQuantity, incrementQuantity, decrementQuantity } = useClientCart();
|
||||||
|
|
||||||
|
const loading = computed(() => productsQuery.loading.value || catalogSettingsQuery.loading.value);
|
||||||
|
const error = computed(() => productsQuery.error.value || catalogSettingsQuery.error.value);
|
||||||
|
|
||||||
|
const catalogSettingsByType = computed<Record<string, CatalogProductTypeSettingNode>>(() => (
|
||||||
|
Object.fromEntries(
|
||||||
|
(catalogSettingsQuery.result.value?.catalogProductTypeSettings ?? [])
|
||||||
|
.map((setting) => [setting.productType, setting]),
|
||||||
|
)
|
||||||
|
));
|
||||||
|
|
||||||
function normalizeText(value: string | null | undefined) {
|
function normalizeText(value: string | null | undefined) {
|
||||||
return String(value ?? '').replaceAll(/\s+/g, ' ').trim();
|
return String(value ?? '').replaceAll(/\s+/g, ' ').trim();
|
||||||
}
|
}
|
||||||
@@ -85,10 +129,15 @@ function createProductCover(name: string, sku: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function hydrateProduct(product: ProductNode): ParsedProduct {
|
function hydrateProduct(product: ProductNode): ParsedProduct {
|
||||||
|
const normalizedTags = product.tags.map((tag) => normalizeText(tag)).filter(Boolean).sort((a, b) => a.localeCompare(b, 'ru'));
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...product,
|
...product,
|
||||||
productTypeLabel: normalizeText(product.productType) || 'Без типа',
|
productTypeLabel: normalizeText(product.productType) || 'Без типа',
|
||||||
quantityPerBoxOptions: splitBoxValues(product.quantityPerBox),
|
quantityPerBoxOptions: splitBoxValues(product.quantityPerBox),
|
||||||
|
normalizedTags,
|
||||||
|
colorTags: normalizedTags.filter((tag) => COLOR_TAGS.includes(tag)),
|
||||||
|
labelTags: normalizedTags.filter((tag) => LABEL_TAGS.includes(tag)),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -121,27 +170,10 @@ function compareProducts(a: ParsedProduct, b: ParsedProduct) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const parsedProducts = computed<ParsedProduct[]>(() => {
|
const parsedProducts = computed<ParsedProduct[]>(() => {
|
||||||
const list = result.value?.clientProducts ?? [];
|
const list = productsQuery.result.value?.clientProducts ?? [];
|
||||||
const query = search.value.trim().toLowerCase();
|
|
||||||
|
|
||||||
return list
|
return list
|
||||||
.map(hydrateProduct)
|
.map(hydrateProduct)
|
||||||
.filter((product) => {
|
|
||||||
if (!query) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
return [
|
|
||||||
product.name,
|
|
||||||
product.sku,
|
|
||||||
product.productTypeLabel,
|
|
||||||
String(product.widthMm ?? ''),
|
|
||||||
String(product.lengthM ?? ''),
|
|
||||||
String(product.thicknessMicron ?? ''),
|
|
||||||
normalizeText(product.sleeveBrand),
|
|
||||||
normalizeText(product.quantityPerBox),
|
|
||||||
].some((part) => part.toLowerCase().includes(query));
|
|
||||||
})
|
|
||||||
.sort(compareProducts);
|
.sort(compareProducts);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -160,7 +192,11 @@ const productGroups = computed<ProductGroup[]>(() => {
|
|||||||
return [...map.entries()]
|
return [...map.entries()]
|
||||||
.sort((a, b) => a[0].localeCompare(b[0], 'ru'))
|
.sort((a, b) => a[0].localeCompare(b[0], 'ru'))
|
||||||
.map(([typeLabel, products]) => ({
|
.map(([typeLabel, products]) => ({
|
||||||
key: typeLabel.toLowerCase().replaceAll(/\s+/g, '-'),
|
key: typeLabel
|
||||||
|
.toLowerCase()
|
||||||
|
.replaceAll(/[^a-z0-9а-яё]+/gi, '-')
|
||||||
|
.replaceAll(/-+/g, '-')
|
||||||
|
.replaceAll(/^-|-$/g, ''),
|
||||||
typeLabel,
|
typeLabel,
|
||||||
products: [...products].sort(compareProducts),
|
products: [...products].sort(compareProducts),
|
||||||
}));
|
}));
|
||||||
@@ -186,6 +222,20 @@ function getAllFieldOptions(group: ProductGroup, field: ParamFieldKey) {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (field === 'colorTag') {
|
||||||
|
for (const tag of product.colorTags) {
|
||||||
|
values.add(tag);
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (field === 'labelTag') {
|
||||||
|
for (const tag of product.labelTags) {
|
||||||
|
values.add(tag);
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
const value = product[field];
|
const value = product[field];
|
||||||
if (value !== null && value !== undefined) {
|
if (value !== null && value !== undefined) {
|
||||||
values.add(value);
|
values.add(value);
|
||||||
@@ -195,25 +245,39 @@ function getAllFieldOptions(group: ProductGroup, field: ParamFieldKey) {
|
|||||||
return sortParamValues([...values]);
|
return sortParamValues([...values]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function groupCatalogSetting(group: ProductGroup) {
|
||||||
|
return catalogSettingsByType.value[group.typeLabel] ?? {
|
||||||
|
...DEFAULT_CATALOG_PRODUCT_TYPE_SETTING,
|
||||||
|
productType: group.typeLabel,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function visibleFields(group: ProductGroup) {
|
function visibleFields(group: ProductGroup) {
|
||||||
return parameterFields.filter((field) => getAllFieldOptions(group, field.key).length > 1);
|
return parameterFields.filter((field) => {
|
||||||
|
if (field.key === 'quantityPerBox') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return getAllFieldOptions(group, field.key).length > 1;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function visibleFieldsByColumn(group: ProductGroup) {
|
const selectedGroup = computed(() => productGroups.value.find((group) => group.key === props.productTypeSlug) ?? null);
|
||||||
const visibleKeys = new Set(visibleFields(group).map((field) => field.key));
|
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) => (
|
return productGroups.value[currentGroupIndex.value - 1] ?? null;
|
||||||
visibleKeys.has(field.key)
|
});
|
||||||
&& ['widthMm', 'lengthM'].includes(field.key)
|
const nextGroup = computed(() => {
|
||||||
));
|
if (currentGroupIndex.value < 0 || currentGroupIndex.value >= productGroups.value.length - 1) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
const rightColumn = parameterFields.filter((field) => (
|
return productGroups.value[currentGroupIndex.value + 1] ?? null;
|
||||||
visibleKeys.has(field.key)
|
});
|
||||||
&& ['thicknessMicron', 'quantityPerBox', 'sleeveBrand'].includes(field.key)
|
|
||||||
));
|
|
||||||
|
|
||||||
return { leftColumn, rightColumn };
|
|
||||||
}
|
|
||||||
|
|
||||||
function requiredKeys(group: ProductGroup) {
|
function requiredKeys(group: ProductGroup) {
|
||||||
return visibleFields(group).map((field) => field.key);
|
return visibleFields(group).map((field) => field.key);
|
||||||
@@ -228,7 +292,8 @@ function createGroupState(group: ProductGroup): GroupState {
|
|||||||
thicknessMicron: firstProduct?.thicknessMicron ?? null,
|
thicknessMicron: firstProduct?.thicknessMicron ?? null,
|
||||||
sleeveBrand: firstProduct?.sleeveBrand ?? null,
|
sleeveBrand: firstProduct?.sleeveBrand ?? null,
|
||||||
quantityPerBox: firstProduct?.quantityPerBoxOptions[0] ?? null,
|
quantityPerBox: firstProduct?.quantityPerBoxOptions[0] ?? null,
|
||||||
isExpanded: false,
|
colorTag: firstProduct?.colorTags[0] ?? null,
|
||||||
|
labelTag: firstProduct?.labelTags[0] ?? null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -265,6 +330,14 @@ function matchesProductState(product: ParsedProduct, state: GroupState, keys: Pa
|
|||||||
return product.quantityPerBoxOptions.includes(String(state[key]));
|
return product.quantityPerBoxOptions.includes(String(state[key]));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (key === 'colorTag') {
|
||||||
|
return product.colorTags.includes(String(state[key]));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (key === 'labelTag') {
|
||||||
|
return product.labelTags.includes(String(state[key]));
|
||||||
|
}
|
||||||
|
|
||||||
return product[key] === state[key];
|
return product[key] === state[key];
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -274,7 +347,7 @@ function selectedProduct(group: ProductGroup) {
|
|||||||
const state = getGroupState(group);
|
const state = getGroupState(group);
|
||||||
|
|
||||||
if (keys.length === 0) {
|
if (keys.length === 0) {
|
||||||
return group.products.length === 1 ? group.products[0] : null;
|
return group.products[0] ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (keys.some((key) => state[key] === null)) {
|
if (keys.some((key) => state[key] === null)) {
|
||||||
@@ -282,7 +355,7 @@ function selectedProduct(group: ProductGroup) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const matches = group.products.filter((product) => matchesProductState(product, state, keys));
|
const matches = group.products.filter((product) => matchesProductState(product, state, keys));
|
||||||
return matches.length === 1 ? matches[0] : null;
|
return matches[0] ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatOptionLabel(field: ParamFieldKey, value: ParamValue) {
|
function formatOptionLabel(field: ParamFieldKey, value: ParamValue) {
|
||||||
@@ -303,6 +376,14 @@ function productHasOption(product: ParsedProduct, field: ParamFieldKey, option:
|
|||||||
return product.quantityPerBoxOptions.includes(String(option));
|
return product.quantityPerBoxOptions.includes(String(option));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (field === 'colorTag') {
|
||||||
|
return product.colorTags.includes(String(option));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (field === 'labelTag') {
|
||||||
|
return product.labelTags.includes(String(option));
|
||||||
|
}
|
||||||
|
|
||||||
return product[field] === option;
|
return product[field] === option;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -359,6 +440,8 @@ function applyProductToState(state: GroupState, product: ParsedProduct, preferre
|
|||||||
state.lengthM = product.lengthM ?? null;
|
state.lengthM = product.lengthM ?? null;
|
||||||
state.thicknessMicron = product.thicknessMicron ?? null;
|
state.thicknessMicron = product.thicknessMicron ?? null;
|
||||||
state.sleeveBrand = product.sleeveBrand ?? null;
|
state.sleeveBrand = product.sleeveBrand ?? null;
|
||||||
|
state.colorTag = product.colorTags[0] ?? null;
|
||||||
|
state.labelTag = product.labelTags[0] ?? null;
|
||||||
|
|
||||||
if (preferredBoxOption !== null && product.quantityPerBoxOptions.includes(String(preferredBoxOption))) {
|
if (preferredBoxOption !== null && product.quantityPerBoxOptions.includes(String(preferredBoxOption))) {
|
||||||
state.quantityPerBox = String(preferredBoxOption);
|
state.quantityPerBox = String(preferredBoxOption);
|
||||||
@@ -389,8 +472,112 @@ function articleLabel(group: ProductGroup) {
|
|||||||
return selectedProduct(group)?.sku ?? '—';
|
return selectedProduct(group)?.sku ?? '—';
|
||||||
}
|
}
|
||||||
|
|
||||||
function toggleExpanded(group: ProductGroup) {
|
function formatLengthRange(setting: CatalogProductTypeSettingNode) {
|
||||||
getGroupState(group).isExpanded = !getGroupState(group).isExpanded;
|
if (!setting.customLengthMinM || !setting.customLengthMaxM || !setting.customLengthStepM) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${setting.customLengthMinM}-${setting.customLengthMaxM} м, шаг ${setting.customLengthStepM} м`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function fieldHelperText(group: ProductGroup, field: ParamFieldKey) {
|
||||||
|
const setting = groupCatalogSetting(group);
|
||||||
|
|
||||||
|
if (field === 'widthMm') {
|
||||||
|
return 'Ширина определяет, насколько широкой будет полоса материала в работе и при намотке.';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (field === 'lengthM') {
|
||||||
|
const customRange = formatLengthRange(setting);
|
||||||
|
if (setting.allowCustomLength && customRange) {
|
||||||
|
return `Можно выбрать стандартный метраж из наличия или заказать свой вариант. Доступный диапазон: ${customRange}.`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'Длина показывает, сколько метров материала будет в одном рулоне.';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (field === 'thicknessMicron') {
|
||||||
|
return 'Толщина влияет на плотность, прочность и общее ощущение материала в работе.';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (field === 'sleeveBrand') {
|
||||||
|
if (setting.allowCustomSleeveBrand) {
|
||||||
|
return 'Можно выбрать стандартную втулку или сделать свою с логотипом под заказ.';
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'Втулка находится внутри рулона и влияет на совместимость с вашим оборудованием.';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (field === 'colorTag') {
|
||||||
|
return 'Цвет нужен для визуального отличия, маркировки и внешнего вида готового рулона.';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (field === 'labelTag') {
|
||||||
|
if (setting.allowCustomLabel) {
|
||||||
|
return 'Можно взять стандартную маркировку из каталога или нанести свою надпись.';
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'Надпись или маркировка помогает сразу выбрать нужный готовый вариант.';
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'Параметр товара.';
|
||||||
|
}
|
||||||
|
|
||||||
|
function customizationDetails(group: ProductGroup) {
|
||||||
|
const setting = groupCatalogSetting(group);
|
||||||
|
const details: string[] = [];
|
||||||
|
const customRange = formatLengthRange(setting);
|
||||||
|
|
||||||
|
if (setting.allowCustomLength && customRange) {
|
||||||
|
details.push(`Своя длина: ${customRange}.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (setting.allowCustomSleeveBrand) {
|
||||||
|
details.push('Втулка с логотипом под заказ.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (setting.allowCustomLabel) {
|
||||||
|
details.push('Можно нанести свою надпись.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return details;
|
||||||
|
}
|
||||||
|
|
||||||
|
function totalAvailableQty(product: ParsedProduct) {
|
||||||
|
return product.availableInWarehouses.reduce((sum, balance) => sum + Number(balance.availableQty || 0), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
function warehouseAvailability(product: ParsedProduct) {
|
||||||
|
return product.availableInWarehouses
|
||||||
|
.filter((balance) => Number(balance.availableQty || 0) > 0)
|
||||||
|
.map((balance) => `${balance.warehouse.code}: ${balance.availableQty}`)
|
||||||
|
.join(' · ');
|
||||||
|
}
|
||||||
|
|
||||||
|
function availabilityTone(product: ParsedProduct) {
|
||||||
|
const qty = totalAvailableQty(product);
|
||||||
|
|
||||||
|
if (qty <= 0) {
|
||||||
|
return 'bg-[#d95c5c]';
|
||||||
|
}
|
||||||
|
if (qty < 20) {
|
||||||
|
return 'bg-[#e2b534]';
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'bg-[#2aa36b]';
|
||||||
|
}
|
||||||
|
|
||||||
|
function availabilityLabel(product: ParsedProduct) {
|
||||||
|
const qty = totalAvailableQty(product);
|
||||||
|
|
||||||
|
if (qty <= 0) {
|
||||||
|
return 'Нет в наличии';
|
||||||
|
}
|
||||||
|
if (qty < 20) {
|
||||||
|
return 'Остаток ограничен';
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'В наличии';
|
||||||
}
|
}
|
||||||
|
|
||||||
function incrementProduct(product: ProductNode) {
|
function incrementProduct(product: ProductNode) {
|
||||||
@@ -429,222 +616,259 @@ function decrementSelected(group: ProductGroup) {
|
|||||||
decrementProduct(product.id);
|
decrementProduct(product.id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function productDetailPath(group: ProductGroup) {
|
||||||
|
return `/products/${group.key}`;
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<section class="space-y-5">
|
<section class="space-y-5">
|
||||||
<UiSectionSearchHero
|
|
||||||
v-model="search"
|
|
||||||
title="Каталог"
|
|
||||||
search-placeholder="Поиск по артикулу, типу товара или параметрам"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div v-if="loading" class="alert surface-card border-0">Загрузка каталога...</div>
|
<div v-if="loading" class="alert surface-card border-0">Загрузка каталога...</div>
|
||||||
<div v-else-if="error" class="alert alert-error">{{ error.message }}</div>
|
<div v-else-if="error" class="alert alert-error">{{ error.message }}</div>
|
||||||
|
<div v-else-if="selectedGroup" class="relative pb-10">
|
||||||
<div v-else-if="productGroups.length" class="space-y-4">
|
<NuxtLink
|
||||||
<article
|
v-if="previousGroup"
|
||||||
v-for="group in productGroups"
|
:to="productDetailPath(previousGroup)"
|
||||||
:key="group.key"
|
class="absolute left-[-212px] top-28 z-10 hidden w-44 rounded-[28px] border border-[#e6efe9] bg-white p-3 shadow-[0_20px_40px_rgba(18,56,36,0.08)] transition hover:-translate-x-2 hover:shadow-[0_28px_48px_rgba(18,56,36,0.12)] 2xl:block"
|
||||||
class="surface-card rounded-3xl p-4 md:p-5"
|
|
||||||
>
|
>
|
||||||
<div class="grid gap-4 xl:grid-cols-6 xl:items-start">
|
<img
|
||||||
<div class="p-3 xl:col-span-1">
|
: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
|
<img
|
||||||
:src="createProductCover(group.typeLabel, group.key)"
|
:src="createProductCover(selectedGroup.typeLabel, articleLabel(selectedGroup))"
|
||||||
:alt="`Превью группы ${group.typeLabel}`"
|
:alt="selectedGroup.typeLabel"
|
||||||
class="aspect-square w-full rounded-[24px] object-cover"
|
class="aspect-[5/4] w-full rounded-[26px] object-cover"
|
||||||
loading="lazy"
|
loading="lazy"
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="p-4 md:p-5 xl:col-span-4">
|
<div
|
||||||
<div class="mb-4">
|
v-if="customizationDetails(selectedGroup).length"
|
||||||
<h2 class="text-2xl font-bold text-[#163624]">{{ group.typeLabel }}</h2>
|
class="rounded-[28px] border border-[#dce9e1] bg-[#f7fbf8] p-4"
|
||||||
</div>
|
>
|
||||||
|
<div class="space-y-2">
|
||||||
<div class="grid gap-8 md:grid-cols-2">
|
<p
|
||||||
<div class="space-y-4">
|
v-for="note in customizationDetails(selectedGroup)"
|
||||||
<div
|
:key="`${selectedGroup.key}-${note}`"
|
||||||
v-for="field in visibleFieldsByColumn(group).leftColumn"
|
class="text-sm leading-6 text-[#456555]"
|
||||||
:key="`${group.key}-${field.key}`"
|
>
|
||||||
class="border-b border-base-200 pb-4 last:border-b-0 last:pb-0"
|
{{ note }}
|
||||||
>
|
</p>
|
||||||
<p class="text-sm font-semibold text-[#163624]">{{ field.label }}</p>
|
|
||||||
|
|
||||||
<div class="mt-3 flex flex-wrap gap-2">
|
|
||||||
<label
|
|
||||||
v-for="option in getAllFieldOptions(group, field.key)"
|
|
||||||
:key="`${group.key}-${field.key}-${option}`"
|
|
||||||
class="btn btn-sm rounded-full text-sm normal-case shadow-none transition"
|
|
||||||
:class="[
|
|
||||||
getGroupState(group)[field.key] === option
|
|
||||||
? 'bg-neutral text-neutral-content'
|
|
||||||
: isOptionAvailable(group, field.key, option)
|
|
||||||
? 'bg-base-100 text-base-content hover:bg-base-200'
|
|
||||||
: 'bg-[#eef1f4] text-[#7b8591] hover:bg-[#e3e7eb]',
|
|
||||||
'cursor-pointer',
|
|
||||||
]"
|
|
||||||
>
|
|
||||||
<input
|
|
||||||
type="radio"
|
|
||||||
class="sr-only"
|
|
||||||
:name="`${group.key}-${field.key}`"
|
|
||||||
:checked="getGroupState(group)[field.key] === option"
|
|
||||||
@change="updateField(group, field.key, option)"
|
|
||||||
>
|
|
||||||
<span>{{ formatOptionLabel(field.key, option) }}</span>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="space-y-4">
|
|
||||||
<div
|
|
||||||
v-for="field in visibleFieldsByColumn(group).rightColumn"
|
|
||||||
:key="`${group.key}-${field.key}`"
|
|
||||||
class="border-b border-base-200 pb-4 last:border-b-0 last:pb-0"
|
|
||||||
>
|
|
||||||
<p class="text-sm font-semibold text-[#163624]">{{ field.label }}</p>
|
|
||||||
|
|
||||||
<div class="mt-3 flex flex-wrap gap-2">
|
|
||||||
<label
|
|
||||||
v-for="option in getAllFieldOptions(group, field.key)"
|
|
||||||
:key="`${group.key}-${field.key}-${option}`"
|
|
||||||
class="btn btn-sm rounded-full text-sm normal-case shadow-none transition"
|
|
||||||
:class="[
|
|
||||||
getGroupState(group)[field.key] === option
|
|
||||||
? 'bg-neutral text-neutral-content'
|
|
||||||
: isOptionAvailable(group, field.key, option)
|
|
||||||
? 'bg-base-100 text-base-content hover:bg-base-200'
|
|
||||||
: 'bg-[#eef1f4] text-[#7b8591] hover:bg-[#e3e7eb]',
|
|
||||||
'cursor-pointer',
|
|
||||||
]"
|
|
||||||
>
|
|
||||||
<input
|
|
||||||
type="radio"
|
|
||||||
class="sr-only"
|
|
||||||
:name="`${group.key}-${field.key}`"
|
|
||||||
:checked="getGroupState(group)[field.key] === option"
|
|
||||||
@change="updateField(group, field.key, option)"
|
|
||||||
>
|
|
||||||
<span>{{ formatOptionLabel(field.key, option) }}</span>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<aside class="p-4 md:p-5 xl:col-span-1">
|
|
||||||
<div class="flex h-full flex-col justify-between gap-4">
|
|
||||||
<div />
|
|
||||||
|
|
||||||
<div class="space-y-3">
|
|
||||||
<button
|
|
||||||
v-if="selectedQty(group) === 0"
|
|
||||||
class="btn h-11 w-full rounded-full border-0 bg-[#139957] text-sm font-semibold text-white hover:bg-[#0d854a]"
|
|
||||||
:disabled="!selectedProduct(group)"
|
|
||||||
@click="incrementSelected(group)"
|
|
||||||
>
|
|
||||||
В корзину
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<div v-else class="rounded-[22px] border border-base-300 bg-base-100 px-2 py-1">
|
|
||||||
<div class="flex items-center justify-between gap-2">
|
|
||||||
<button
|
|
||||||
class="btn btn-square btn-sm"
|
|
||||||
:disabled="selectedQty(group) === 0"
|
|
||||||
@click="decrementSelected(group)"
|
|
||||||
>
|
|
||||||
-
|
|
||||||
</button>
|
|
||||||
<div class="min-w-10 text-center text-lg font-semibold text-[#163624]">{{ selectedQty(group) }}</div>
|
|
||||||
<button class="btn btn-square btn-sm" :disabled="!selectedProduct(group)" @click="incrementSelected(group)">
|
|
||||||
+
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p class="text-center text-sm font-medium text-base-content/55">{{ articleLabel(group) }}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</aside>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div class="space-y-4">
|
||||||
v-if="getGroupState(group).isExpanded"
|
<article
|
||||||
class="mt-4 overflow-x-auto rounded-[28px] bg-white"
|
v-for="field in visibleFields(selectedGroup)"
|
||||||
>
|
:key="`${selectedGroup.key}-${field.key}`"
|
||||||
<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">
|
class="rounded-[28px] border border-[#e6efe9] bg-white p-4 shadow-[0_18px_36px_rgba(18,56,36,0.05)]"
|
||||||
|
>
|
||||||
|
<div class="flex flex-wrap gap-2">
|
||||||
|
<label
|
||||||
|
v-for="option in getAllFieldOptions(selectedGroup, field.key)"
|
||||||
|
:key="`${selectedGroup.key}-${field.key}-${option}`"
|
||||||
|
class="cursor-pointer rounded-2xl border px-4 py-2 text-sm font-medium transition"
|
||||||
|
:class="[
|
||||||
|
getGroupState(selectedGroup)[field.key] === option
|
||||||
|
? 'border-[#163624] bg-[#163624] text-white'
|
||||||
|
: isOptionAvailable(selectedGroup, field.key, option)
|
||||||
|
? 'border-[#dce9e1] bg-white text-[#163624] hover:border-[#163624]'
|
||||||
|
: 'border-[#e6eaee] bg-[#f3f5f7] text-[#8a949d]',
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
class="sr-only"
|
||||||
|
:name="`${selectedGroup.key}-${field.key}`"
|
||||||
|
:checked="getGroupState(selectedGroup)[field.key] === option"
|
||||||
|
@change="updateField(selectedGroup, field.key, option)"
|
||||||
|
>
|
||||||
|
<span>{{ formatOptionLabel(field.key, option) }}</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<details class="mt-3 rounded-[20px] bg-[#f7fbf8] px-4 py-3 text-sm text-[#587064]">
|
||||||
|
<summary class="cursor-pointer font-medium text-[#355947]">Подробнее</summary>
|
||||||
|
<p class="mt-2 leading-6">{{ fieldHelperText(selectedGroup, field.key) }}</p>
|
||||||
|
</details>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<aside class="self-start xl:sticky xl:top-24">
|
||||||
|
<div class="rounded-[30px] border border-[#e6efe9] bg-white p-5 shadow-[0_24px_48px_rgba(18,56,36,0.08)]">
|
||||||
|
<p class="mt-1 text-lg font-medium leading-tight text-[#163624]">{{ articleLabel(selectedGroup) }}</p>
|
||||||
|
|
||||||
|
<button
|
||||||
|
v-if="selectedQty(selectedGroup) === 0"
|
||||||
|
class="btn mt-4 h-12 w-full rounded-full border-0 bg-[#139957] px-6 text-base font-semibold text-white hover:bg-[#0d854a]"
|
||||||
|
:disabled="!selectedProduct(selectedGroup)"
|
||||||
|
@click="incrementSelected(selectedGroup)"
|
||||||
|
>
|
||||||
|
В корзину
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-else
|
||||||
|
class="mt-4 flex items-center justify-between rounded-[24px] border border-[#dce9e1] bg-[#f8fbf9] px-2 py-2"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
class="btn btn-square border-0 bg-white text-[#163624] shadow-none hover:bg-white"
|
||||||
|
:disabled="selectedQty(selectedGroup) === 0"
|
||||||
|
@click="decrementSelected(selectedGroup)"
|
||||||
|
>
|
||||||
|
-
|
||||||
|
</button>
|
||||||
|
<div class="min-w-12 text-center text-lg font-semibold text-[#163624]">{{ selectedQty(selectedGroup) }}</div>
|
||||||
|
<button
|
||||||
|
class="btn btn-square border-0 bg-white text-[#163624] shadow-none hover:bg-white"
|
||||||
|
:disabled="!selectedProduct(selectedGroup)"
|
||||||
|
@click="incrementSelected(selectedGroup)"
|
||||||
|
>
|
||||||
|
+
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-8">
|
||||||
|
<p class="mb-4 text-base font-semibold text-[#163624]">Доступные варианты</p>
|
||||||
|
|
||||||
|
<div class="overflow-x-auto rounded-[24px] border border-[#edf4ef] bg-white">
|
||||||
|
<table class="table bg-white">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr class="text-[#587064]">
|
||||||
<th class="border-b border-base-300">Артикул</th>
|
<th>SKU</th>
|
||||||
<th class="border-b border-base-300">Ширина</th>
|
<th>Ширина</th>
|
||||||
<th class="border-b border-base-300">Длина</th>
|
<th>Длина</th>
|
||||||
<th class="border-b border-base-300">Толщина</th>
|
<th>Толщина</th>
|
||||||
<th class="border-b border-base-300">Втулка</th>
|
<th>Втулка</th>
|
||||||
<th class="border-b border-base-300">Короб</th>
|
<th>Цвет</th>
|
||||||
<th class="border-b border-base-300 text-right">Действие</th>
|
<th>Надпись</th>
|
||||||
|
<th>Остаток</th>
|
||||||
|
<th class="text-right">Действие</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr v-for="product in group.products" :key="`${group.key}-${product.id}`">
|
<tr v-for="product in selectedGroup.products" :key="`${selectedGroup.key}-${product.id}`" class="align-middle">
|
||||||
<td class="border-b border-base-200">{{ product.sku }}</td>
|
<td class="font-semibold text-[#163624]">{{ product.sku }}</td>
|
||||||
<td class="border-b border-base-200">{{ product.widthMm ?? '—' }}</td>
|
<td>{{ product.widthMm ?? '—' }}</td>
|
||||||
<td class="border-b border-base-200">{{ product.lengthM ?? '—' }}</td>
|
<td>{{ product.lengthM ?? '—' }}</td>
|
||||||
<td class="border-b border-base-200">{{ product.thicknessMicron ?? '—' }}</td>
|
<td>{{ product.thicknessMicron ?? '—' }}</td>
|
||||||
<td class="border-b border-base-200">{{ product.sleeveBrand ?? '—' }}</td>
|
<td>{{ product.sleeveBrand ?? '—' }}</td>
|
||||||
<td class="border-b border-base-200">{{ product.quantityPerBox ?? '—' }}</td>
|
<td>{{ product.colorTags.join(', ') || '—' }}</td>
|
||||||
<td class="border-b border-base-200 text-right">
|
<td>{{ product.labelTags.join(', ') || '—' }}</td>
|
||||||
|
<td>
|
||||||
|
<div class="flex min-w-[180px] items-center gap-3">
|
||||||
|
<span class="h-3 w-3 rounded-sm" :class="availabilityTone(product)" />
|
||||||
|
<div class="min-w-0">
|
||||||
|
<p class="text-sm font-medium text-[#163624]">{{ availabilityLabel(product) }}</p>
|
||||||
|
<p class="text-xs text-[#607569]">
|
||||||
|
{{ totalAvailableQty(product) }}<span v-if="warehouseAvailability(product)"> · {{ warehouseAvailability(product) }}</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td class="text-right">
|
||||||
<button
|
<button
|
||||||
v-if="getQuantity(product.id) === 0"
|
v-if="getQuantity(product.id) === 0"
|
||||||
class="btn h-9 rounded-full border-0 bg-[#139957] px-4 text-xs font-semibold text-white hover:bg-[#0d854a]"
|
class="btn h-10 rounded-full border-0 bg-[#139957] px-5 text-sm font-semibold text-white hover:bg-[#0d854a]"
|
||||||
@click="incrementProduct(product)"
|
@click="incrementProduct(product)"
|
||||||
>
|
>
|
||||||
В корзину
|
В корзину
|
||||||
</button>
|
</button>
|
||||||
<div v-else class="ml-auto flex w-28 items-center justify-between rounded-xl border border-base-300 px-1 py-1">
|
<div v-else class="ml-auto flex w-32 items-center justify-between rounded-[20px] border border-[#dce9e1] bg-white px-2 py-2">
|
||||||
<button
|
<button
|
||||||
class="btn btn-xs btn-square"
|
class="btn btn-xs btn-square border-0 bg-[#f3f7f4] text-[#163624] shadow-none hover:bg-[#eaf2ec]"
|
||||||
:disabled="getQuantity(product.id) === 0"
|
:disabled="getQuantity(product.id) === 0"
|
||||||
@click="decrementProduct(product.id)"
|
@click="decrementProduct(product.id)"
|
||||||
>
|
>
|
||||||
-
|
-
|
||||||
</button>
|
</button>
|
||||||
<span class="min-w-8 text-center text-sm font-semibold">{{ getQuantity(product.id) }}</span>
|
<span class="min-w-8 text-center text-sm font-semibold text-[#163624]">{{ getQuantity(product.id) }}</span>
|
||||||
<button class="btn btn-xs btn-square" @click="incrementProduct(product)">+</button>
|
<button
|
||||||
|
class="btn btn-xs btn-square border-0 bg-[#f3f7f4] text-[#163624] shadow-none hover:bg-[#eaf2ec]"
|
||||||
|
@click="incrementProduct(product)"
|
||||||
|
>
|
||||||
|
+
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
<button
|
|
||||||
class="btn btn-ghost mt-3 w-full justify-center gap-2"
|
|
||||||
@click="toggleExpanded(group)"
|
|
||||||
>
|
|
||||||
<svg
|
|
||||||
class="h-4 w-4 transition-transform"
|
|
||||||
:class="{ 'rotate-180': getGroupState(group).isExpanded }"
|
|
||||||
viewBox="0 0 20 20"
|
|
||||||
fill="none"
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
d="M5 7.5L10 12.5L15 7.5"
|
|
||||||
stroke="currentColor"
|
|
||||||
stroke-width="1.8"
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
<span>{{ getGroupState(group).isExpanded ? 'Свернуть все варианты' : 'Развернуть все варианты' }}</span>
|
|
||||||
</button>
|
|
||||||
</article>
|
|
||||||
</div>
|
</div>
|
||||||
|
<div v-else class="alert surface-card border-0">Такой тип товара не найден.</div>
|
||||||
<div v-else class="alert surface-card border-0">По текущему запросу товары не найдены.</div>
|
|
||||||
</section>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
127
app/components/catalog/CatalogProductTypeList.vue
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { useQuery } from '@vue/apollo-composable';
|
||||||
|
import {
|
||||||
|
ClientProductsDocument,
|
||||||
|
type ClientProductsQuery,
|
||||||
|
} from '~/composables/graphql/generated';
|
||||||
|
|
||||||
|
type ProductNode = ClientProductsQuery['clientProducts'][number];
|
||||||
|
type ProductTypeCard = {
|
||||||
|
key: string;
|
||||||
|
typeLabel: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const productsQuery = useQuery(ClientProductsDocument);
|
||||||
|
const search = ref('');
|
||||||
|
|
||||||
|
const loading = computed(() => productsQuery.loading.value);
|
||||||
|
const error = computed(() => productsQuery.error.value);
|
||||||
|
|
||||||
|
function normalizeText(value: string | null | undefined) {
|
||||||
|
return String(value ?? '').replaceAll(/\s+/g, ' ').trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function slugifyTypeLabel(value: string) {
|
||||||
|
return normalizeText(value)
|
||||||
|
.toLowerCase()
|
||||||
|
.replaceAll(/[^a-z0-9а-яё]+/gi, '-')
|
||||||
|
.replaceAll(/-+/g, '-')
|
||||||
|
.replaceAll(/^-|-$/g, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
function createProductCover(name: string, sku: string) {
|
||||||
|
const coverPresets = [
|
||||||
|
['#d9f5e6', '#9ce8c1', '#6fd09d'],
|
||||||
|
['#eaf9ef', '#b3e8cb', '#76c89f'],
|
||||||
|
['#e8f5ec', '#b2e0c6', '#7dd0a9'],
|
||||||
|
];
|
||||||
|
const seed = `${name}${sku}`.split('').reduce((acc, char) => acc + char.charCodeAt(0), 0);
|
||||||
|
const [start, middle, finish] = coverPresets[seed % coverPresets.length];
|
||||||
|
const firstLetter = name.trim().charAt(0).toUpperCase() || 'P';
|
||||||
|
|
||||||
|
const svg = `
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 320 220">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="g" x1="0" y1="0" x2="1" y2="1">
|
||||||
|
<stop offset="0%" stop-color="${start}" />
|
||||||
|
<stop offset="55%" stop-color="${middle}" />
|
||||||
|
<stop offset="100%" stop-color="${finish}" />
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<rect width="320" height="220" fill="url(#g)" rx="22" />
|
||||||
|
<g opacity="0.15">
|
||||||
|
<circle cx="266" cy="45" r="55" fill="#0f7a49" />
|
||||||
|
<circle cx="42" cy="198" r="55" fill="#0f7a49" />
|
||||||
|
</g>
|
||||||
|
<text x="50%" y="56%" text-anchor="middle" fill="#11412c" font-family="Manrope, sans-serif" font-size="84" font-weight="700">${firstLetter}</text>
|
||||||
|
</svg>
|
||||||
|
`.trim();
|
||||||
|
|
||||||
|
return `data:image/svg+xml;utf8,${encodeURIComponent(svg)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const productTypeCards = computed<ProductTypeCard[]>(() => {
|
||||||
|
const products = productsQuery.result.value?.clientProducts ?? [];
|
||||||
|
const query = search.value.trim().toLowerCase();
|
||||||
|
const grouped = new Map<string, ProductNode[]>();
|
||||||
|
|
||||||
|
for (const product of products) {
|
||||||
|
const typeLabel = normalizeText(product.productType) || 'Без типа';
|
||||||
|
const existing = grouped.get(typeLabel);
|
||||||
|
if (existing) {
|
||||||
|
existing.push(product);
|
||||||
|
} else {
|
||||||
|
grouped.set(typeLabel, [product]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return [...grouped.entries()]
|
||||||
|
.map(([typeLabel]) => ({
|
||||||
|
key: slugifyTypeLabel(typeLabel),
|
||||||
|
typeLabel,
|
||||||
|
}))
|
||||||
|
.filter((card) => {
|
||||||
|
if (!query) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return card.typeLabel.toLowerCase().includes(query);
|
||||||
|
})
|
||||||
|
.sort((a, b) => a.typeLabel.localeCompare(b.typeLabel, 'ru'));
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section class="space-y-5">
|
||||||
|
<UiSectionSearchHero
|
||||||
|
v-model="search"
|
||||||
|
title="Каталог"
|
||||||
|
search-placeholder="Поиск по типу товара"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div v-if="loading" class="alert surface-card border-0">Загрузка каталога...</div>
|
||||||
|
<div v-else-if="error" class="alert alert-error">{{ error.message }}</div>
|
||||||
|
|
||||||
|
<div v-else-if="productTypeCards.length" class="grid grid-cols-2 gap-3 md:grid-cols-3 xl:grid-cols-5">
|
||||||
|
<NuxtLink
|
||||||
|
v-for="card in productTypeCards"
|
||||||
|
:key="card.key"
|
||||||
|
:to="`/products/${card.key}`"
|
||||||
|
class="surface-card block rounded-3xl p-3 transition hover:-translate-y-0.5 hover:shadow-[0_22px_42px_rgba(18,56,36,0.12)]"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
:src="createProductCover(card.typeLabel, card.key)"
|
||||||
|
:alt="`Превью ${card.typeLabel}`"
|
||||||
|
class="aspect-square w-full rounded-[24px] object-cover"
|
||||||
|
loading="lazy"
|
||||||
|
>
|
||||||
|
|
||||||
|
<div class="mt-3">
|
||||||
|
<h2 class="text-base font-bold leading-5 text-[#163624]">{{ card.typeLabel }}</h2>
|
||||||
|
</div>
|
||||||
|
</NuxtLink>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="alert surface-card border-0">По текущему запросу товары не найдены.</div>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
@@ -1,30 +1,26 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import {
|
||||||
|
getOrderStatusBadgePresentation,
|
||||||
|
type OrderStatusTone,
|
||||||
|
} from '~/composables/useOrderStatusPresentation';
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
status: string;
|
status: string;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const statusLabel = computed(() => {
|
const badgePresentation = computed(() => getOrderStatusBadgePresentation(props.status));
|
||||||
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 className = computed(() => {
|
function dotClass(tone: OrderStatusTone) {
|
||||||
if (props.status === 'COMPLETED') return 'bg-[#139957]';
|
if (tone === 'success') return 'bg-[#139957]';
|
||||||
if (props.status === 'CLIENT_REJECTED' || props.status === 'MANAGER_REJECTED') return 'bg-[#d94b55]';
|
if (tone === 'danger') return 'bg-[#d94b55]';
|
||||||
if (props.status === 'MANAGER_BLOCKED') return 'bg-[#f1a43a]';
|
if (tone === 'warning') return 'bg-[#f1a43a]';
|
||||||
if (props.status === 'NEW' || props.status === 'MANAGER_PROCESSING') return 'bg-[#f1a43a]';
|
|
||||||
return 'bg-[#2e8de4]';
|
return 'bg-[#2e8de4]';
|
||||||
});
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<span class="inline-flex items-center gap-2 text-sm font-semibold text-[#123824]">
|
<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="className" />
|
<span class="h-2.5 w-2.5 rounded-full" :class="dotClass(badgePresentation.tone)" />
|
||||||
<span>{{ statusLabel }}</span>
|
<span>{{ badgePresentation.label }}</span>
|
||||||
</span>
|
</span>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import OrderStatusBadge from '~/components/orders/OrderStatusBadge.vue';
|
import OrderStatusBadge from '~/components/orders/OrderStatusBadge.vue';
|
||||||
import { getOrderStatusPresentation } from '~/composables/useOrderStatusPresentation';
|
import {
|
||||||
|
getOrderStatusBadgePresentation,
|
||||||
|
getOrderStatusPresentation,
|
||||||
|
type OrderStatusTone,
|
||||||
|
} from '~/composables/useOrderStatusPresentation';
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
status: string;
|
status: string;
|
||||||
@@ -11,10 +15,53 @@ const props = defineProps<{
|
|||||||
const isExpanded = ref(false);
|
const isExpanded = ref(false);
|
||||||
|
|
||||||
const presentation = computed(() => getOrderStatusPresentation(props.status, props.createdAt, props.audience ?? 'client'));
|
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') {
|
function markerClass(state: 'done' | 'current' | 'upcoming') {
|
||||||
if (state === 'current') {
|
if (state === 'current') {
|
||||||
return 'bg-[#139957] ring-4 ring-[#dff4e8]';
|
return currentToneClass(currentBadge.value.tone).marker;
|
||||||
}
|
}
|
||||||
if (state === 'done') {
|
if (state === 'done') {
|
||||||
return 'bg-[#9dcfb0]';
|
return 'bg-[#9dcfb0]';
|
||||||
@@ -24,7 +71,9 @@ function markerClass(state: 'done' | 'current' | 'upcoming') {
|
|||||||
|
|
||||||
function connectorClass(state: 'done' | 'current' | 'upcoming') {
|
function connectorClass(state: 'done' | 'current' | 'upcoming') {
|
||||||
if (state === 'done' || state === 'current') {
|
if (state === 'done' || state === 'current') {
|
||||||
return 'bg-[#cfe5d7]';
|
return state === 'current'
|
||||||
|
? currentToneClass(currentBadge.value.tone).connector
|
||||||
|
: 'bg-[#cfe5d7]';
|
||||||
}
|
}
|
||||||
|
|
||||||
return 'bg-[#e4ece7]';
|
return 'bg-[#e4ece7]';
|
||||||
@@ -32,7 +81,7 @@ function connectorClass(state: 'done' | 'current' | 'upcoming') {
|
|||||||
|
|
||||||
function titleClass(state: 'done' | 'current' | 'upcoming') {
|
function titleClass(state: 'done' | 'current' | 'upcoming') {
|
||||||
if (state === 'current') {
|
if (state === 'current') {
|
||||||
return 'text-[#123824]';
|
return currentToneClass(currentBadge.value.tone).title;
|
||||||
}
|
}
|
||||||
if (state === 'done') {
|
if (state === 'done') {
|
||||||
return 'text-[#355947]';
|
return 'text-[#355947]';
|
||||||
@@ -43,7 +92,7 @@ function titleClass(state: 'done' | 'current' | 'upcoming') {
|
|||||||
|
|
||||||
function noteClass(state: 'done' | 'current' | 'upcoming') {
|
function noteClass(state: 'done' | 'current' | 'upcoming') {
|
||||||
if (state === 'current') {
|
if (state === 'current') {
|
||||||
return 'text-[#355947]';
|
return currentToneClass(currentBadge.value.tone).note;
|
||||||
}
|
}
|
||||||
if (state === 'done') {
|
if (state === 'done') {
|
||||||
return 'text-[#557562]';
|
return 'text-[#557562]';
|
||||||
@@ -51,6 +100,28 @@ function noteClass(state: 'done' | 'current' | 'upcoming') {
|
|||||||
|
|
||||||
return 'text-[#7d9688]';
|
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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -60,17 +131,19 @@ function noteClass(state: 'done' | 'current' | 'upcoming') {
|
|||||||
class="flex w-full items-start justify-between gap-4 text-left"
|
class="flex w-full items-start justify-between gap-4 text-left"
|
||||||
@click="isExpanded = !isExpanded"
|
@click="isExpanded = !isExpanded"
|
||||||
>
|
>
|
||||||
<div class="space-y-2">
|
<div class="space-y-3">
|
||||||
<h2 class="text-2xl font-black leading-tight text-[#123824]">
|
<div class="flex flex-wrap items-center gap-3">
|
||||||
{{ presentation.title }}
|
<h2 class="text-2xl font-black leading-tight text-[#123824]">
|
||||||
</h2>
|
{{ presentation.title }}
|
||||||
|
</h2>
|
||||||
|
<OrderStatusBadge :status="status" />
|
||||||
|
</div>
|
||||||
<p class="max-w-2xl text-sm leading-6 text-[#355947]">
|
<p class="max-w-2xl text-sm leading-6 text-[#355947]">
|
||||||
{{ presentation.summary }}
|
{{ presentation.summary }}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex items-center gap-3 pt-1">
|
<div class="flex items-center gap-3 pt-1">
|
||||||
<OrderStatusBadge :status="status" />
|
|
||||||
<span
|
<span
|
||||||
class="flex h-10 w-10 items-center justify-center rounded-full bg-[#f2f5f3] text-[#123824] transition-transform"
|
class="flex h-10 w-10 items-center justify-center rounded-full bg-[#f2f5f3] text-[#123824] transition-transform"
|
||||||
:class="{ 'rotate-180': isExpanded }"
|
:class="{ 'rotate-180': isExpanded }"
|
||||||
@@ -97,22 +170,24 @@ function noteClass(state: 'done' | 'current' | 'upcoming') {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div class="min-w-0 flex-1 pb-5">
|
||||||
class="min-w-0 flex-1 pb-5"
|
<div
|
||||||
: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)">
|
<div class="flex flex-wrap items-center justify-between gap-2">
|
||||||
{{ stage.label }}
|
<p class="text-sm font-semibold" :class="titleClass(stage.state)">
|
||||||
</p>
|
{{ stage.label }}
|
||||||
<p class="text-xs font-semibold uppercase tracking-[0.12em] text-[#5c7b69]">
|
</p>
|
||||||
{{ stage.dateLabel }}
|
<p class="text-xs font-semibold uppercase tracking-[0.12em]" :class="dateClass(stage.state)">
|
||||||
|
{{ stage.dateLabel }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="mt-2 text-sm leading-6" :class="noteClass(stage.state)">
|
||||||
|
{{ stage.note }}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p class="mt-2 text-sm leading-6" :class="noteClass(stage.state)">
|
|
||||||
{{ stage.note }}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -8,23 +8,20 @@ type DockItem = {
|
|||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
|
|
||||||
const dockItems: DockItem[] = [
|
const dockItems: DockItem[] = [
|
||||||
{ to: '/client-orders', label: 'Заказы', icon: 'orders' },
|
{ to: '/admin/orders', label: 'Заказы', icon: 'orders' },
|
||||||
{ to: '/bonus-system', label: 'Бонусы', icon: 'bonus' },
|
{ to: '/admin/bonuses/balances', label: 'Бонусы', icon: 'bonus' },
|
||||||
{ to: '/messages', label: 'Настройки', icon: 'settings' },
|
{ to: '/admin/settings/messages', label: 'Настройки', icon: 'settings' },
|
||||||
];
|
];
|
||||||
|
|
||||||
function isActive(path: string) {
|
function isActive(path: string) {
|
||||||
if (path === '/client-orders') {
|
if (path === '/admin/orders') {
|
||||||
return route.path === '/client-orders'
|
return route.path === '/admin/orders' || route.path.startsWith('/admin/orders/');
|
||||||
|| route.path.startsWith('/client-orders/')
|
|
||||||
|| route.path === '/clients'
|
|
||||||
|| route.path.startsWith('/clients/');
|
|
||||||
}
|
}
|
||||||
if (path === '/bonus-system') {
|
if (path === '/admin/bonuses/balances') {
|
||||||
return route.path === '/bonus-system' || route.path.startsWith('/bonus-system/');
|
return route.path.startsWith('/admin/bonuses');
|
||||||
}
|
}
|
||||||
if (path === '/messages') {
|
if (path === '/admin/settings/messages') {
|
||||||
return route.path === '/messages';
|
return route.path.startsWith('/admin/settings');
|
||||||
}
|
}
|
||||||
return route.path === path;
|
return route.path === path;
|
||||||
}
|
}
|
||||||
|
|||||||
38
app/components/ui/BackHeader.vue
Normal 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>
|
||||||
@@ -47,6 +47,14 @@ export type AuthSession = {
|
|||||||
user: User;
|
user: User;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type BonusProgramLink = {
|
||||||
|
__typename?: 'BonusProgramLink';
|
||||||
|
expiresAt: Scalars['DateTime']['output'];
|
||||||
|
token: Scalars['String']['output'];
|
||||||
|
url: Scalars['String']['output'];
|
||||||
|
userId: Scalars['ID']['output'];
|
||||||
|
};
|
||||||
|
|
||||||
export type BonusTransaction = {
|
export type BonusTransaction = {
|
||||||
__typename?: 'BonusTransaction';
|
__typename?: 'BonusTransaction';
|
||||||
amount: Scalars['Float']['output'];
|
amount: Scalars['Float']['output'];
|
||||||
@@ -81,6 +89,24 @@ export type CartItem = {
|
|||||||
updatedAt: Scalars['DateTime']['output'];
|
updatedAt: Scalars['DateTime']['output'];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type CatalogProductTypeSetting = {
|
||||||
|
__typename?: 'CatalogProductTypeSetting';
|
||||||
|
allowCustomLabel: Scalars['Boolean']['output'];
|
||||||
|
allowCustomLength: Scalars['Boolean']['output'];
|
||||||
|
allowCustomSleeveBrand: Scalars['Boolean']['output'];
|
||||||
|
colorOptions: Array<Scalars['String']['output']>;
|
||||||
|
customLengthMaxM?: Maybe<Scalars['Int']['output']>;
|
||||||
|
customLengthMinM?: Maybe<Scalars['Int']['output']>;
|
||||||
|
customLengthStepM?: Maybe<Scalars['Int']['output']>;
|
||||||
|
labelOptions: Array<Scalars['String']['output']>;
|
||||||
|
lengthOptionsM: Array<Scalars['Int']['output']>;
|
||||||
|
productType: Scalars['String']['output'];
|
||||||
|
showQuantityPerBox: Scalars['Boolean']['output'];
|
||||||
|
sleeveOptions: Array<Scalars['String']['output']>;
|
||||||
|
thicknessOptionsMicron: Array<Scalars['Int']['output']>;
|
||||||
|
widthOptionsMm: Array<Scalars['Int']['output']>;
|
||||||
|
};
|
||||||
|
|
||||||
export type Company = {
|
export type Company = {
|
||||||
__typename?: 'Company';
|
__typename?: 'Company';
|
||||||
id: Scalars['ID']['output'];
|
id: Scalars['ID']['output'];
|
||||||
@@ -152,6 +178,28 @@ export type DeliveryAddress = {
|
|||||||
userId: Scalars['ID']['output'];
|
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 = {
|
export type Invitation = {
|
||||||
__typename?: 'Invitation';
|
__typename?: 'Invitation';
|
||||||
acceptedAt?: Maybe<Scalars['DateTime']['output']>;
|
acceptedAt?: Maybe<Scalars['DateTime']['output']>;
|
||||||
@@ -278,6 +326,7 @@ export type Mutation = {
|
|||||||
clientReviewOrder: Order;
|
clientReviewOrder: Order;
|
||||||
connectMessenger: MessengerConnection;
|
connectMessenger: MessengerConnection;
|
||||||
consumeLoginToken: AuthSession;
|
consumeLoginToken: AuthSession;
|
||||||
|
createBonusProgramLink: BonusProgramLink;
|
||||||
createInvitation: Invitation;
|
createInvitation: Invitation;
|
||||||
createMyDeliveryAddress: DeliveryAddress;
|
createMyDeliveryAddress: DeliveryAddress;
|
||||||
createReferral: ReferralLink;
|
createReferral: ReferralLink;
|
||||||
@@ -297,6 +346,7 @@ export type Mutation = {
|
|||||||
submitCalculationOrder: Order;
|
submitCalculationOrder: Order;
|
||||||
submitReadyOrder: Order;
|
submitReadyOrder: Order;
|
||||||
updateCartItemQuantity: Cart;
|
updateCartItemQuantity: Cart;
|
||||||
|
upsertCatalogProductTypeSetting: CatalogProductTypeSetting;
|
||||||
upsertMyCounterpartyProfile: CounterpartyProfile;
|
upsertMyCounterpartyProfile: CounterpartyProfile;
|
||||||
verifyLoginCode: AuthSession;
|
verifyLoginCode: AuthSession;
|
||||||
};
|
};
|
||||||
@@ -333,6 +383,11 @@ export type MutationConsumeLoginTokenArgs = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
|
export type MutationCreateBonusProgramLinkArgs = {
|
||||||
|
userId: Scalars['ID']['input'];
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
export type MutationCreateInvitationArgs = {
|
export type MutationCreateInvitationArgs = {
|
||||||
input: CreateInvitationInput;
|
input: CreateInvitationInput;
|
||||||
};
|
};
|
||||||
@@ -431,6 +486,11 @@ export type MutationUpdateCartItemQuantityArgs = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
|
export type MutationUpsertCatalogProductTypeSettingArgs = {
|
||||||
|
input: UpsertCatalogProductTypeSettingInput;
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
export type MutationUpsertMyCounterpartyProfileArgs = {
|
export type MutationUpsertMyCounterpartyProfileArgs = {
|
||||||
input: UpsertMyCounterpartyProfileInput;
|
input: UpsertMyCounterpartyProfileInput;
|
||||||
};
|
};
|
||||||
@@ -543,6 +603,7 @@ export type Product = {
|
|||||||
quantityPerBox?: Maybe<Scalars['String']['output']>;
|
quantityPerBox?: Maybe<Scalars['String']['output']>;
|
||||||
sku: Scalars['String']['output'];
|
sku: Scalars['String']['output'];
|
||||||
sleeveBrand?: Maybe<Scalars['String']['output']>;
|
sleeveBrand?: Maybe<Scalars['String']['output']>;
|
||||||
|
tags: Array<Scalars['String']['output']>;
|
||||||
thicknessMicron?: Maybe<Scalars['Int']['output']>;
|
thicknessMicron?: Maybe<Scalars['Int']['output']>;
|
||||||
widthMm?: Maybe<Scalars['Int']['output']>;
|
widthMm?: Maybe<Scalars['Int']['output']>;
|
||||||
};
|
};
|
||||||
@@ -555,8 +616,10 @@ export type ProductWarehouseBalance = {
|
|||||||
|
|
||||||
export type Query = {
|
export type Query = {
|
||||||
__typename?: 'Query';
|
__typename?: 'Query';
|
||||||
|
catalogProductTypeSettings: Array<CatalogProductTypeSetting>;
|
||||||
clientProducts: Array<Product>;
|
clientProducts: Array<Product>;
|
||||||
healthcheck: Scalars['String']['output'];
|
healthcheck: Scalars['String']['output'];
|
||||||
|
integrationSyncDashboard: IntegrationSyncDashboard;
|
||||||
managerBonusAccount: ManagerBonusAccount;
|
managerBonusAccount: ManagerBonusAccount;
|
||||||
managerBonusBalances: Array<ManagerBonusBalance>;
|
managerBonusBalances: Array<ManagerBonusBalance>;
|
||||||
managerNotificationHistory: Array<NotificationHistoryItem>;
|
managerNotificationHistory: Array<NotificationHistoryItem>;
|
||||||
@@ -725,6 +788,23 @@ export type UpdateCartItemQuantityInput = {
|
|||||||
quantity: Scalars['Float']['input'];
|
quantity: Scalars['Float']['input'];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type UpsertCatalogProductTypeSettingInput = {
|
||||||
|
allowCustomLabel: Scalars['Boolean']['input'];
|
||||||
|
allowCustomLength: Scalars['Boolean']['input'];
|
||||||
|
allowCustomSleeveBrand: Scalars['Boolean']['input'];
|
||||||
|
colorOptions: Array<Scalars['String']['input']>;
|
||||||
|
customLengthMaxM?: InputMaybe<Scalars['Int']['input']>;
|
||||||
|
customLengthMinM?: InputMaybe<Scalars['Int']['input']>;
|
||||||
|
customLengthStepM?: InputMaybe<Scalars['Int']['input']>;
|
||||||
|
labelOptions: Array<Scalars['String']['input']>;
|
||||||
|
lengthOptionsM: Array<Scalars['Int']['input']>;
|
||||||
|
productType: Scalars['String']['input'];
|
||||||
|
showQuantityPerBox: Scalars['Boolean']['input'];
|
||||||
|
sleeveOptions: Array<Scalars['String']['input']>;
|
||||||
|
thicknessOptionsMicron: Array<Scalars['Int']['input']>;
|
||||||
|
widthOptionsMm: Array<Scalars['Int']['input']>;
|
||||||
|
};
|
||||||
|
|
||||||
export type UpsertMyCounterpartyProfileInput = {
|
export type UpsertMyCounterpartyProfileInput = {
|
||||||
bankName: Scalars['String']['input'];
|
bankName: Scalars['String']['input'];
|
||||||
bik: Scalars['String']['input'];
|
bik: Scalars['String']['input'];
|
||||||
@@ -807,6 +887,13 @@ export type VerifyLoginCodeMutationVariables = Exact<{
|
|||||||
|
|
||||||
export type VerifyLoginCodeMutation = { __typename?: 'Mutation', verifyLoginCode: { __typename?: 'AuthSession', accessToken: string, expiresAt: any, user: { __typename?: 'User', id: string, email: string, fullName: string, role: UserRole, company?: { __typename?: 'Company', id: string } | null } } };
|
export type VerifyLoginCodeMutation = { __typename?: 'Mutation', verifyLoginCode: { __typename?: 'AuthSession', accessToken: string, expiresAt: any, user: { __typename?: 'User', id: string, email: string, fullName: string, role: UserRole, company?: { __typename?: 'Company', id: string } | null } } };
|
||||||
|
|
||||||
|
export type CreateBonusProgramLinkMutationVariables = Exact<{
|
||||||
|
userId: Scalars['ID']['input'];
|
||||||
|
}>;
|
||||||
|
|
||||||
|
|
||||||
|
export type CreateBonusProgramLinkMutation = { __typename?: 'Mutation', createBonusProgramLink: { __typename?: 'BonusProgramLink', userId: string, token: string, url: string, expiresAt: any } };
|
||||||
|
|
||||||
export type RequestRewardWithdrawalMutationVariables = Exact<{
|
export type RequestRewardWithdrawalMutationVariables = Exact<{
|
||||||
input: RequestRewardWithdrawalInput;
|
input: RequestRewardWithdrawalInput;
|
||||||
}>;
|
}>;
|
||||||
@@ -855,7 +942,7 @@ export type UpdateCartItemQuantityMutation = { __typename?: 'Mutation', updateCa
|
|||||||
export type ClientProductsQueryVariables = Exact<{ [key: string]: never; }>;
|
export type ClientProductsQueryVariables = Exact<{ [key: string]: never; }>;
|
||||||
|
|
||||||
|
|
||||||
export type ClientProductsQuery = { __typename?: 'Query', clientProducts: Array<{ __typename?: 'Product', id: string, sku: string, name: string, description?: string | null, productType?: string | null, widthMm?: number | null, lengthM?: number | null, thicknessMicron?: number | null, sleeveBrand?: string | null, quantityPerBox?: string | null, isCustomizable: boolean, availableInWarehouses: Array<{ __typename?: 'ProductWarehouseBalance', availableQty: number, warehouse: { __typename?: 'Warehouse', id: string, code: string, name: string } }> }> };
|
export type ClientProductsQuery = { __typename?: 'Query', clientProducts: Array<{ __typename?: 'Product', id: string, sku: string, name: string, description?: string | null, productType?: string | null, widthMm?: number | null, lengthM?: number | null, thicknessMicron?: number | null, sleeveBrand?: string | null, quantityPerBox?: string | null, tags: Array<string>, isCustomizable: boolean, availableInWarehouses: Array<{ __typename?: 'ProductWarehouseBalance', availableQty: number, warehouse: { __typename?: 'Warehouse', id: string, code: string, name: string } }> }> };
|
||||||
|
|
||||||
export type AddBonusTransactionMutationVariables = Exact<{
|
export type AddBonusTransactionMutationVariables = Exact<{
|
||||||
input: AddBonusTransactionInput;
|
input: AddBonusTransactionInput;
|
||||||
@@ -1080,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 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`
|
export const ConsumeLoginTokenDocument = gql`
|
||||||
mutation ConsumeLoginToken($token: String!) {
|
mutation ConsumeLoginToken($token: String!) {
|
||||||
@@ -1255,6 +1359,38 @@ export function useVerifyLoginCodeMutation(options: VueApolloComposable.UseMutat
|
|||||||
return VueApolloComposable.useMutation<VerifyLoginCodeMutation, VerifyLoginCodeMutationVariables>(VerifyLoginCodeDocument, options);
|
return VueApolloComposable.useMutation<VerifyLoginCodeMutation, VerifyLoginCodeMutationVariables>(VerifyLoginCodeDocument, options);
|
||||||
}
|
}
|
||||||
export type VerifyLoginCodeMutationCompositionFunctionResult = VueApolloComposable.UseMutationReturn<VerifyLoginCodeMutation, VerifyLoginCodeMutationVariables>;
|
export type VerifyLoginCodeMutationCompositionFunctionResult = VueApolloComposable.UseMutationReturn<VerifyLoginCodeMutation, VerifyLoginCodeMutationVariables>;
|
||||||
|
export const CreateBonusProgramLinkDocument = gql`
|
||||||
|
mutation CreateBonusProgramLink($userId: ID!) {
|
||||||
|
createBonusProgramLink(userId: $userId) {
|
||||||
|
userId
|
||||||
|
token
|
||||||
|
url
|
||||||
|
expiresAt
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* __useCreateBonusProgramLinkMutation__
|
||||||
|
*
|
||||||
|
* To run a mutation, you first call `useCreateBonusProgramLinkMutation` within a Vue component and pass it any options that fit your needs.
|
||||||
|
* When your component renders, `useCreateBonusProgramLinkMutation` returns an object that includes:
|
||||||
|
* - A mutate function that you can call at any time to execute the mutation
|
||||||
|
* - Several other properties: https://v4.apollo.vuejs.org/api/use-mutation.html#return
|
||||||
|
*
|
||||||
|
* @param options that will be passed into the mutation, supported options are listed on: https://v4.apollo.vuejs.org/guide-composable/mutation.html#options;
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* const { mutate, loading, error, onDone } = useCreateBonusProgramLinkMutation({
|
||||||
|
* variables: {
|
||||||
|
* userId: // value for 'userId'
|
||||||
|
* },
|
||||||
|
* });
|
||||||
|
*/
|
||||||
|
export function useCreateBonusProgramLinkMutation(options: VueApolloComposable.UseMutationOptions<CreateBonusProgramLinkMutation, CreateBonusProgramLinkMutationVariables> | ReactiveFunction<VueApolloComposable.UseMutationOptions<CreateBonusProgramLinkMutation, CreateBonusProgramLinkMutationVariables>> = {}) {
|
||||||
|
return VueApolloComposable.useMutation<CreateBonusProgramLinkMutation, CreateBonusProgramLinkMutationVariables>(CreateBonusProgramLinkDocument, options);
|
||||||
|
}
|
||||||
|
export type CreateBonusProgramLinkMutationCompositionFunctionResult = VueApolloComposable.UseMutationReturn<CreateBonusProgramLinkMutation, CreateBonusProgramLinkMutationVariables>;
|
||||||
export const RequestRewardWithdrawalDocument = gql`
|
export const RequestRewardWithdrawalDocument = gql`
|
||||||
mutation RequestRewardWithdrawal($input: RequestRewardWithdrawalInput!) {
|
mutation RequestRewardWithdrawal($input: RequestRewardWithdrawalInput!) {
|
||||||
requestRewardWithdrawal(input: $input) {
|
requestRewardWithdrawal(input: $input) {
|
||||||
@@ -1548,6 +1684,7 @@ export const ClientProductsDocument = gql`
|
|||||||
thicknessMicron
|
thicknessMicron
|
||||||
sleeveBrand
|
sleeveBrand
|
||||||
quantityPerBox
|
quantityPerBox
|
||||||
|
tags
|
||||||
isCustomizable
|
isCustomizable
|
||||||
availableInWarehouses {
|
availableInWarehouses {
|
||||||
availableQty
|
availableQty
|
||||||
@@ -2844,3 +2981,126 @@ export function useUpsertMyCounterpartyProfileMutation(options: VueApolloComposa
|
|||||||
return VueApolloComposable.useMutation<UpsertMyCounterpartyProfileMutation, UpsertMyCounterpartyProfileMutationVariables>(UpsertMyCounterpartyProfileDocument, options);
|
return VueApolloComposable.useMutation<UpsertMyCounterpartyProfileMutation, UpsertMyCounterpartyProfileMutationVariables>(UpsertMyCounterpartyProfileDocument, options);
|
||||||
}
|
}
|
||||||
export type UpsertMyCounterpartyProfileMutationCompositionFunctionResult = VueApolloComposable.UseMutationReturn<UpsertMyCounterpartyProfileMutation, UpsertMyCounterpartyProfileMutationVariables>;
|
export type UpsertMyCounterpartyProfileMutationCompositionFunctionResult = VueApolloComposable.UseMutationReturn<UpsertMyCounterpartyProfileMutation, UpsertMyCounterpartyProfileMutationVariables>;
|
||||||
|
export const CatalogProductTypeSettingsDocument = gql`
|
||||||
|
query CatalogProductTypeSettings {
|
||||||
|
catalogProductTypeSettings {
|
||||||
|
productType
|
||||||
|
showQuantityPerBox
|
||||||
|
allowCustomLength
|
||||||
|
customLengthMinM
|
||||||
|
customLengthMaxM
|
||||||
|
customLengthStepM
|
||||||
|
allowCustomSleeveBrand
|
||||||
|
allowCustomLabel
|
||||||
|
widthOptionsMm
|
||||||
|
lengthOptionsM
|
||||||
|
thicknessOptionsMicron
|
||||||
|
sleeveOptions
|
||||||
|
colorOptions
|
||||||
|
labelOptions
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* __useCatalogProductTypeSettingsQuery__
|
||||||
|
*
|
||||||
|
* To run a query within a Vue component, call `useCatalogProductTypeSettingsQuery` and pass it any options that fit your needs.
|
||||||
|
* When your component renders, `useCatalogProductTypeSettingsQuery` returns an object from Apollo Client that contains result, loading and error properties
|
||||||
|
* you can use to render your UI.
|
||||||
|
*
|
||||||
|
* @param options that will be passed into the query, supported options are listed on: https://v4.apollo.vuejs.org/guide-composable/query.html#options;
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* const { result, loading, error } = useCatalogProductTypeSettingsQuery();
|
||||||
|
*/
|
||||||
|
export function useCatalogProductTypeSettingsQuery(options: VueApolloComposable.UseQueryOptions<CatalogProductTypeSettingsQuery, CatalogProductTypeSettingsQueryVariables> | VueCompositionApi.Ref<VueApolloComposable.UseQueryOptions<CatalogProductTypeSettingsQuery, CatalogProductTypeSettingsQueryVariables>> | ReactiveFunction<VueApolloComposable.UseQueryOptions<CatalogProductTypeSettingsQuery, CatalogProductTypeSettingsQueryVariables>> = {}) {
|
||||||
|
return VueApolloComposable.useQuery<CatalogProductTypeSettingsQuery, CatalogProductTypeSettingsQueryVariables>(CatalogProductTypeSettingsDocument, {}, options);
|
||||||
|
}
|
||||||
|
export function useCatalogProductTypeSettingsLazyQuery(options: VueApolloComposable.UseQueryOptions<CatalogProductTypeSettingsQuery, CatalogProductTypeSettingsQueryVariables> | VueCompositionApi.Ref<VueApolloComposable.UseQueryOptions<CatalogProductTypeSettingsQuery, CatalogProductTypeSettingsQueryVariables>> | ReactiveFunction<VueApolloComposable.UseQueryOptions<CatalogProductTypeSettingsQuery, CatalogProductTypeSettingsQueryVariables>> = {}) {
|
||||||
|
return VueApolloComposable.useLazyQuery<CatalogProductTypeSettingsQuery, CatalogProductTypeSettingsQueryVariables>(CatalogProductTypeSettingsDocument, {}, options);
|
||||||
|
}
|
||||||
|
export type CatalogProductTypeSettingsQueryCompositionFunctionResult = VueApolloComposable.UseQueryReturn<CatalogProductTypeSettingsQuery, CatalogProductTypeSettingsQueryVariables>;
|
||||||
|
export const IntegrationSyncDashboardDocument = gql`
|
||||||
|
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>;
|
||||||
@@ -9,6 +9,8 @@ type OrderStatusCode =
|
|||||||
| 'IN_PROGRESS'
|
| 'IN_PROGRESS'
|
||||||
| 'COMPLETED';
|
| 'COMPLETED';
|
||||||
|
|
||||||
|
export type OrderStatusTone = 'warning' | 'info' | 'success' | 'danger';
|
||||||
|
|
||||||
type TimelineStage = {
|
type TimelineStage = {
|
||||||
code: string;
|
code: string;
|
||||||
label: string;
|
label: string;
|
||||||
@@ -17,6 +19,11 @@ type TimelineStage = {
|
|||||||
state: 'done' | 'current' | 'upcoming';
|
state: 'done' | 'current' | 'upcoming';
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type StatusBadgePresentation = {
|
||||||
|
label: string;
|
||||||
|
tone: OrderStatusTone;
|
||||||
|
};
|
||||||
|
|
||||||
type StatusPresentation = {
|
type StatusPresentation = {
|
||||||
title: string;
|
title: string;
|
||||||
summary: string;
|
summary: string;
|
||||||
@@ -37,6 +44,18 @@ const DAY_FORMATTER = new Intl.DateTimeFormat('ru-RU', {
|
|||||||
month: 'long',
|
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) {
|
function addDays(date: Date, days: number) {
|
||||||
const next = new Date(date);
|
const next = new Date(date);
|
||||||
next.setDate(next.getDate() + days);
|
next.setDate(next.getDate() + days);
|
||||||
@@ -149,14 +168,14 @@ function buildClientStages(status: string, createdAt: string | Date): StatusPres
|
|||||||
stages: [
|
stages: [
|
||||||
{
|
{
|
||||||
code: 'NEW',
|
code: 'NEW',
|
||||||
label: 'Заказ создан',
|
label: 'Заявка',
|
||||||
note: 'Заказ принят в обработку.',
|
note: 'Заказ принят в обработку.',
|
||||||
dateLabel: formatDay(dates.created),
|
dateLabel: formatDay(dates.created),
|
||||||
state: 'done',
|
state: 'done',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
code: status,
|
code: status,
|
||||||
label: 'Заказ остановлен',
|
label: 'Отклонен',
|
||||||
note: 'Дальнейшее исполнение не планируется.',
|
note: 'Дальнейшее исполнение не планируется.',
|
||||||
dateLabel: formatDay(dates.approval),
|
dateLabel: formatDay(dates.approval),
|
||||||
state: 'current',
|
state: 'current',
|
||||||
@@ -172,14 +191,14 @@ function buildClientStages(status: string, createdAt: string | Date): StatusPres
|
|||||||
stages: [
|
stages: [
|
||||||
{
|
{
|
||||||
code: 'NEW',
|
code: 'NEW',
|
||||||
label: 'Заказ создан',
|
label: 'Заявка',
|
||||||
note: 'Заказ принят в обработку.',
|
note: 'Заказ принят в обработку.',
|
||||||
dateLabel: formatDay(dates.created),
|
dateLabel: formatDay(dates.created),
|
||||||
state: 'done',
|
state: 'done',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
code: status,
|
code: status,
|
||||||
label: 'Уточняем детали',
|
label: 'Пауза',
|
||||||
note: 'После уточнения покажем плановые даты по исполнению.',
|
note: 'После уточнения покажем плановые даты по исполнению.',
|
||||||
dateLabel: formatDay(dates.approval),
|
dateLabel: formatDay(dates.approval),
|
||||||
state: 'current',
|
state: 'current',
|
||||||
@@ -193,31 +212,45 @@ function buildClientStages(status: string, createdAt: string | Date): StatusPres
|
|||||||
const stages: TimelineStage[] = [
|
const stages: TimelineStage[] = [
|
||||||
{
|
{
|
||||||
code: 'NEW',
|
code: 'NEW',
|
||||||
label: 'Заказ создан',
|
label: 'Заявка',
|
||||||
note: 'Приняли заказ и начали обработку.',
|
note: 'Приняли заказ и начали обработку.',
|
||||||
dateLabel: formatDay(dates.created),
|
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',
|
code: 'CONFIRMED',
|
||||||
label: 'Производство',
|
label: 'Производство',
|
||||||
note: 'Плановая дата запуска или выхода из производства.',
|
note: 'Плановая дата запуска или выхода из производства.',
|
||||||
dateLabel: formatDay(dates.production),
|
dateLabel: formatDay(dates.production),
|
||||||
state: currentIndex > 3 ? 'done' : currentIndex >= 3 ? 'current' : 'upcoming',
|
state: currentIndex > 3 ? 'done' : currentIndex === 3 ? 'current' : 'upcoming',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
code: 'IN_PROGRESS',
|
code: 'IN_PROGRESS',
|
||||||
label: 'Отгрузка',
|
label: 'Отгрузка',
|
||||||
note: 'Плановая дата передачи в логистику.',
|
note: 'Плановая дата передачи в логистику.',
|
||||||
dateLabel: formatDay(dates.shipment),
|
dateLabel: formatDay(dates.shipment),
|
||||||
state: currentIndex > 4 ? 'done' : currentIndex >= 4 ? 'current' : 'upcoming',
|
state: currentIndex > 4 ? 'done' : currentIndex === 4 ? 'current' : 'upcoming',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
code: 'COMPLETED',
|
code: 'COMPLETED',
|
||||||
label: 'Доставка',
|
label: 'Доставка',
|
||||||
note: 'Плановая дата получения заказа.',
|
note: 'Плановая дата получения заказа.',
|
||||||
dateLabel: formatDay(dates.delivered),
|
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') {
|
if (status === 'CONFIRMED') {
|
||||||
return {
|
return {
|
||||||
title: 'Планируем производство',
|
title: 'Производство запланировано',
|
||||||
summary: `Ориентируемся на производство ${formatDay(dates.production)}, затем отгрузку и доставку по плану.`,
|
summary: `Ориентируемся на производство ${formatDay(dates.production)}, затем отгрузку и доставку по плану.`,
|
||||||
stages,
|
stages,
|
||||||
};
|
};
|
||||||
@@ -255,7 +288,7 @@ function buildClientStages(status: string, createdAt: string | Date): StatusPres
|
|||||||
|
|
||||||
if (status === 'IN_PROGRESS') {
|
if (status === 'IN_PROGRESS') {
|
||||||
return {
|
return {
|
||||||
title: 'Заказ в работе',
|
title: 'Готовим отгрузку',
|
||||||
summary: `Производство идёт. Следующая плановая дата: отгрузка около ${formatDay(dates.shipment)}.`,
|
summary: `Производство идёт. Следующая плановая дата: отгрузка около ${formatDay(dates.shipment)}.`,
|
||||||
stages,
|
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(
|
export function getOrderStatusPresentation(
|
||||||
status: string,
|
status: string,
|
||||||
createdAt: string | Date,
|
createdAt: string | Date,
|
||||||
|
|||||||
@@ -24,6 +24,12 @@ const availableBalance = computed(() => bonusAccount.value?.availableBalance ??
|
|||||||
const canWithdraw = computed(() => availableBalance.value >= 100);
|
const canWithdraw = computed(() => availableBalance.value >= 100);
|
||||||
const selectedEntry = computed(() => String(route.query.entry || '').trim());
|
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(() => {
|
const entryTitle = computed(() => {
|
||||||
if (selectedEntry.value.includes('withdrawal')) {
|
if (selectedEntry.value.includes('withdrawal')) {
|
||||||
return 'Вы открыли бонусную программу из уведомления о выводе.';
|
return 'Вы открыли бонусную программу из уведомления о выводе.';
|
||||||
@@ -115,14 +121,11 @@ async function submitWithdrawal() {
|
|||||||
</h1>
|
</h1>
|
||||||
<p class="bonus-program-copy">
|
<p class="bonus-program-copy">
|
||||||
{{ entryTitle }}
|
{{ entryTitle }}
|
||||||
Здесь отдельно живут баланс, начисления, выводы и переходы из бонусных уведомлений.
|
Здесь отдельно живут история начислений, магазин вознаграждений и выводы.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex flex-wrap gap-3">
|
<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 to="/notifications" class="bonus-program-ghost-button">
|
||||||
История уведомлений
|
История уведомлений
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
@@ -293,6 +296,36 @@ async function submitWithdrawal() {
|
|||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
</section>
|
</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>
|
</template>
|
||||||
</section>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,12 +1,15 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { useQuery } from '@vue/apollo-composable';
|
import { useMutation, useQuery } from '@vue/apollo-composable';
|
||||||
import {
|
import {
|
||||||
|
CreateBonusProgramLinkDocument,
|
||||||
ManagerBonusAccountDocument,
|
ManagerBonusAccountDocument,
|
||||||
type ManagerBonusAccountQuery,
|
type ManagerBonusAccountQuery,
|
||||||
} from '~/composables/graphql/generated';
|
} from '~/composables/graphql/generated';
|
||||||
|
|
||||||
definePageMeta({
|
definePageMeta({
|
||||||
middleware: ['manager-only'],
|
middleware: ['manager-only'],
|
||||||
|
path: '/admin/bonuses/balances/:userId',
|
||||||
|
alias: ['/bonus-system/:userId'],
|
||||||
});
|
});
|
||||||
|
|
||||||
type TransactionItem = ManagerBonusAccountQuery['managerBonusAccount']['transactions'][number];
|
type TransactionItem = ManagerBonusAccountQuery['managerBonusAccount']['transactions'][number];
|
||||||
@@ -14,6 +17,10 @@ type PendingWithdrawalItem = ManagerBonusAccountQuery['managerBonusAccount']['pe
|
|||||||
|
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
const userId = computed(() => String(route.params.userId || ''));
|
const userId = computed(() => String(route.params.userId || ''));
|
||||||
|
const createBonusProgramLinkMutation = useMutation(CreateBonusProgramLinkDocument, { throws: 'never' });
|
||||||
|
const bonusProgramLink = ref('');
|
||||||
|
const bonusProgramLinkExpiresAt = ref('');
|
||||||
|
const bonusProgramLinkFeedback = ref('');
|
||||||
|
|
||||||
const bonusAccountQuery = useQuery(ManagerBonusAccountDocument, () => ({
|
const bonusAccountQuery = useQuery(ManagerBonusAccountDocument, () => ({
|
||||||
userId: userId.value,
|
userId: userId.value,
|
||||||
@@ -33,12 +40,57 @@ function formatAmount(value: number) {
|
|||||||
function formatDateTime(value: string) {
|
function formatDateTime(value: string) {
|
||||||
return new Date(value).toLocaleString('ru-RU');
|
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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<section class="space-y-6">
|
<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 v-if="bonusAccountQuery.loading.value" class="manager-empty-state">
|
||||||
Загружаем бонусный счёт...
|
Загружаем бонусный счёт...
|
||||||
</div>
|
</div>
|
||||||
@@ -49,73 +101,150 @@ function formatDateTime(value: string) {
|
|||||||
|
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<div class="space-y-4">
|
<div class="space-y-4">
|
||||||
<div class="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
<UiBackHeader
|
||||||
<div class="space-y-1">
|
to="/admin/bonuses/balances"
|
||||||
<h1 class="text-3xl font-extrabold text-[#123824]">{{ bonusAccount.fullName }}</h1>
|
back-label="Назад к бонусным счетам"
|
||||||
<p v-if="bonusAccount.companyName || bonusAccount.email" class="text-sm text-[#5c7b69]">
|
:title="`Бонусный счёт ${bonusAccount.fullName}`"
|
||||||
{{ bonusAccount.companyName || bonusAccount.email }}
|
:subtitle="bonusAccount.companyName || bonusAccount.email || undefined"
|
||||||
</p>
|
>
|
||||||
</div>
|
<template #actions>
|
||||||
<div class="text-left sm:text-right">
|
<div class="flex flex-col gap-3 md:items-end">
|
||||||
<p class="text-[11px] font-semibold uppercase tracking-[0.18em] text-[#6a8a76]">Доступный бонус</p>
|
<div class="text-left md:text-right">
|
||||||
<p class="mt-2 text-3xl font-black leading-none text-[#123824]">{{ formatAmount(bonusAccount.balance) }}</p>
|
<p class="text-[11px] font-semibold uppercase tracking-[0.18em] text-[#6a8a76]">Доступный бонус</p>
|
||||||
</div>
|
<p class="mt-2 text-3xl font-black leading-none text-[#123824]">{{ formatAmount(bonusAccount.balance) }}</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 flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
|
||||||
<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>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<NuxtLink
|
|
||||||
:to="`/bonus-system/withdrawals/${withdrawal.id}`"
|
|
||||||
class="text-sm font-semibold text-[#0d854a]"
|
|
||||||
>
|
|
||||||
Проверить выплату
|
|
||||||
</NuxtLink>
|
|
||||||
</div>
|
</div>
|
||||||
</article>
|
|
||||||
|
<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 class="flex gap-3">
|
||||||
|
<a
|
||||||
|
:href="bonusProgramLink"
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
class="btn rounded-full border border-[#d7e9de] bg-white px-5 text-[#123824] hover:bg-[#f3f8f5]"
|
||||||
|
>
|
||||||
|
Открыть
|
||||||
|
</a>
|
||||||
|
<button
|
||||||
|
class="btn rounded-full border-0 bg-[#139957] px-5 text-white hover:bg-[#0d854a]"
|
||||||
|
@click="copyBonusProgramLink"
|
||||||
|
>
|
||||||
|
Скопировать
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</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-lg font-bold text-[#123824]">Заявки на выплату</p>
|
||||||
|
<p class="text-sm text-[#5c7b69]">Все активные выплаты по этому бонусному счёту.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<NuxtLink
|
||||||
|
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"
|
||||||
|
>
|
||||||
|
<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="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>
|
||||||
|
|
||||||
<div class="surface-card rounded-[32px] p-6">
|
<div class="space-y-3">
|
||||||
<p class="text-lg font-bold text-[#123824]">Транзакции</p>
|
<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 v-if="transactions.length === 0" class="manager-empty-state mt-4">
|
||||||
Начислений пока нет.
|
Начислений пока нет.
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-else class="mt-4 space-y-3">
|
<div v-else class="space-y-3">
|
||||||
<article
|
<article
|
||||||
v-for="transaction in transactions"
|
v-for="transaction in transactions"
|
||||||
:key="transaction.id"
|
: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="grid gap-4 md:grid-cols-[140px_minmax(0,1fr)_180px_140px] md:items-center md:gap-6">
|
||||||
<div class="space-y-1">
|
<div>
|
||||||
<p class="text-base font-semibold text-[#123824]">+{{ formatAmount(transaction.amount) }}</p>
|
<p class="text-base font-bold 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>
|
</div>
|
||||||
|
|
||||||
<NuxtLink
|
<div class="min-w-0 space-y-1">
|
||||||
v-if="transaction.orderId"
|
<p class="text-sm font-semibold text-[#123824]">Начисление</p>
|
||||||
:to="`/client-orders/${transaction.orderId}`"
|
<p class="text-sm text-[#355947]">{{ transaction.reason }}</p>
|
||||||
class="text-sm font-semibold text-[#0d854a]"
|
</div>
|
||||||
>
|
|
||||||
Открыть заказ
|
<div class="text-sm text-[#5c7b69]">
|
||||||
</NuxtLink>
|
{{ formatDateTime(transaction.createdAt) }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="text-left md:text-right">
|
||||||
|
<NuxtLink
|
||||||
|
v-if="transaction.orderId"
|
||||||
|
:to="`/admin/orders/${transaction.orderId}`"
|
||||||
|
class="text-sm font-semibold text-[#0d854a]"
|
||||||
|
>
|
||||||
|
Открыть заказ
|
||||||
|
</NuxtLink>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -2,11 +2,9 @@
|
|||||||
import { useQuery } from '@vue/apollo-composable';
|
import { useQuery } from '@vue/apollo-composable';
|
||||||
import {
|
import {
|
||||||
ManagerBonusBalancesDocument,
|
ManagerBonusBalancesDocument,
|
||||||
ManagerReferralLinksDocument,
|
|
||||||
ManagerUsersDocument,
|
ManagerUsersDocument,
|
||||||
ManagerWithdrawalRequestsDocument,
|
ManagerWithdrawalRequestsDocument,
|
||||||
type ManagerBonusBalancesQuery,
|
type ManagerBonusBalancesQuery,
|
||||||
type ManagerReferralLinksQuery,
|
|
||||||
type ManagerUsersQuery,
|
type ManagerUsersQuery,
|
||||||
type ManagerWithdrawalRequestsQuery,
|
type ManagerWithdrawalRequestsQuery,
|
||||||
} from '~/composables/graphql/generated';
|
} from '~/composables/graphql/generated';
|
||||||
@@ -14,10 +12,11 @@ import { messengerConnectionAvatarSrc } from '~/composables/useMessengerConnecti
|
|||||||
|
|
||||||
definePageMeta({
|
definePageMeta({
|
||||||
middleware: ['manager-only'],
|
middleware: ['manager-only'],
|
||||||
|
path: '/admin/bonuses/:section(balances|requests|rewards)?',
|
||||||
|
alias: ['/bonus-system'],
|
||||||
});
|
});
|
||||||
|
|
||||||
type BalanceItem = ManagerBonusBalancesQuery['managerBonusBalances'][number];
|
type BalanceItem = ManagerBonusBalancesQuery['managerBonusBalances'][number];
|
||||||
type ReferralLinkItem = ManagerReferralLinksQuery['managerReferralLinks'][number];
|
|
||||||
type ManagerUserItem = ManagerUsersQuery['managerUsers'][number];
|
type ManagerUserItem = ManagerUsersQuery['managerUsers'][number];
|
||||||
type WithdrawalItem = ManagerWithdrawalRequestsQuery['managerWithdrawalRequests'][number];
|
type WithdrawalItem = ManagerWithdrawalRequestsQuery['managerWithdrawalRequests'][number];
|
||||||
type ProductCard = {
|
type ProductCard = {
|
||||||
@@ -29,20 +28,18 @@ type ProductCard = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
const router = useRouter();
|
|
||||||
const search = ref('');
|
const search = ref('');
|
||||||
const balancesQuery = useQuery(ManagerBonusBalancesDocument);
|
const balancesQuery = useQuery(ManagerBonusBalancesDocument);
|
||||||
const referralLinksQuery = useQuery(ManagerReferralLinksDocument);
|
|
||||||
const usersQuery = useQuery(ManagerUsersDocument);
|
const usersQuery = useQuery(ManagerUsersDocument);
|
||||||
const withdrawalsQuery = useQuery(ManagerWithdrawalRequestsDocument, {
|
const withdrawalsQuery = useQuery(ManagerWithdrawalRequestsDocument, {
|
||||||
status: 'PENDING',
|
status: 'PENDING',
|
||||||
});
|
});
|
||||||
|
|
||||||
const activeTab = computed<'balances' | 'withdrawals' | 'rewards'>(() => {
|
const activeTab = computed<'balances' | 'withdrawals' | 'rewards'>(() => {
|
||||||
if (route.query.tab === 'withdrawals') {
|
if (route.path === '/admin/bonuses/requests') {
|
||||||
return 'withdrawals';
|
return 'withdrawals';
|
||||||
}
|
}
|
||||||
if (route.query.tab === 'rewards' || route.query.tab === 'products' || route.query.tab === 'manager') {
|
if (route.path === '/admin/bonuses/rewards') {
|
||||||
return 'rewards';
|
return 'rewards';
|
||||||
}
|
}
|
||||||
return 'balances';
|
return 'balances';
|
||||||
@@ -94,34 +91,15 @@ const productCards: ProductCard[] = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
const balances = computed<BalanceItem[]>(() => balancesQuery.result.value?.managerBonusBalances ?? []);
|
const balances = computed<BalanceItem[]>(() => balancesQuery.result.value?.managerBonusBalances ?? []);
|
||||||
const referralLinks = computed<ReferralLinkItem[]>(() => referralLinksQuery.result.value?.managerReferralLinks ?? []);
|
|
||||||
const users = computed<ManagerUserItem[]>(() => usersQuery.result.value?.managerUsers ?? []);
|
const users = computed<ManagerUserItem[]>(() => usersQuery.result.value?.managerUsers ?? []);
|
||||||
const withdrawals = computed<WithdrawalItem[]>(() => withdrawalsQuery.result.value?.managerWithdrawalRequests ?? []);
|
const withdrawals = computed<WithdrawalItem[]>(() => withdrawalsQuery.result.value?.managerWithdrawalRequests ?? []);
|
||||||
const usersById = computed(() => new Map(users.value.map((user) => [user.id, user])));
|
const usersById = computed(() => new Map(users.value.map((user) => [user.id, user])));
|
||||||
|
|
||||||
const referralLinksByReferrer = computed(() => {
|
|
||||||
const grouped = new Map<string, ReferralLinkItem[]>();
|
|
||||||
|
|
||||||
for (const link of referralLinks.value) {
|
|
||||||
const existing = grouped.get(link.referrerId) ?? [];
|
|
||||||
existing.push(link);
|
|
||||||
grouped.set(link.referrerId, existing);
|
|
||||||
}
|
|
||||||
|
|
||||||
return grouped;
|
|
||||||
});
|
|
||||||
|
|
||||||
const filteredBalances = computed(() => {
|
const filteredBalances = computed(() => {
|
||||||
const query = search.value.trim().toLowerCase();
|
const query = search.value.trim().toLowerCase();
|
||||||
|
|
||||||
return balances.value
|
return balances.value
|
||||||
.filter((item) => {
|
.filter((item) => {
|
||||||
const links = referralLinksByReferrer.value.get(item.userId);
|
|
||||||
|
|
||||||
if (!links?.length) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!query) {
|
if (!query) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -131,22 +109,11 @@ const filteredBalances = computed(() => {
|
|||||||
item.email,
|
item.email,
|
||||||
item.companyName || '',
|
item.companyName || '',
|
||||||
String(item.balance),
|
String(item.balance),
|
||||||
...links.flatMap((link) => [
|
String(item.transactionsCount),
|
||||||
link.refereeName,
|
|
||||||
link.refereeEmail,
|
|
||||||
link.refereeCompanyName || '',
|
|
||||||
String(link.bonusPercent),
|
|
||||||
]),
|
|
||||||
]
|
]
|
||||||
.join(' ')
|
.join(' ')
|
||||||
.toLowerCase()
|
.toLowerCase()
|
||||||
.includes(query);
|
.includes(query);
|
||||||
})
|
|
||||||
.slice()
|
|
||||||
.sort((left, right) => {
|
|
||||||
const leftLatest = referralLinksByReferrer.value.get(left.userId)?.[0]?.createdAt ?? '';
|
|
||||||
const rightLatest = referralLinksByReferrer.value.get(right.userId)?.[0]?.createdAt ?? '';
|
|
||||||
return rightLatest.localeCompare(leftLatest);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -222,24 +189,6 @@ const WITHDRAWAL_DATE_FORMATTER = new Intl.DateTimeFormat('ru-RU', {
|
|||||||
minute: '2-digit',
|
minute: '2-digit',
|
||||||
});
|
});
|
||||||
|
|
||||||
const bonusTabs = computed<Array<{ id: 'balances' | 'withdrawals' | 'rewards'; label: string }>>(() => [
|
|
||||||
{ id: 'balances', label: 'Балансы' },
|
|
||||||
{ id: 'withdrawals', label: 'Выплаты' },
|
|
||||||
{ id: 'rewards', label: 'Вознаграждения' },
|
|
||||||
]);
|
|
||||||
|
|
||||||
function setActiveTab(tab: 'balances' | 'withdrawals' | 'rewards') {
|
|
||||||
const query = { ...route.query };
|
|
||||||
|
|
||||||
if (tab === 'balances') {
|
|
||||||
delete query.tab;
|
|
||||||
} else {
|
|
||||||
query.tab = tab;
|
|
||||||
}
|
|
||||||
|
|
||||||
void router.replace({ query });
|
|
||||||
}
|
|
||||||
|
|
||||||
function userInitials(fullName: string) {
|
function userInitials(fullName: string) {
|
||||||
const parts = fullName
|
const parts = fullName
|
||||||
.trim()
|
.trim()
|
||||||
@@ -309,6 +258,10 @@ function productVisualLabel(product: ProductCard) {
|
|||||||
.map((part) => part.charAt(0).toUpperCase())
|
.map((part) => part.charAt(0).toUpperCase())
|
||||||
.join('');
|
.join('');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function compactProductTitle(product: ProductCard) {
|
||||||
|
return `Подарочная карта ${product.store}`;
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -321,38 +274,31 @@ function productVisualLabel(product: ProductCard) {
|
|||||||
: activeTab === 'withdrawals'
|
: activeTab === 'withdrawals'
|
||||||
? 'Номер выплаты, клиент или сумма'
|
? 'Номер выплаты, клиент или сумма'
|
||||||
: 'Название или номинал'"
|
: 'Название или номинал'"
|
||||||
/>
|
>
|
||||||
|
<template #controls>
|
||||||
<div class="surface-card rounded-3xl p-2">
|
<NuxtLink
|
||||||
<div class="flex flex-wrap gap-2">
|
v-if="activeTab === 'balances'"
|
||||||
<button
|
to="/admin/bonuses/links/new"
|
||||||
v-for="tab in bonusTabs"
|
class="btn btn-primary border-0"
|
||||||
:key="tab.id"
|
|
||||||
type="button"
|
|
||||||
class="rounded-full px-4 py-2 text-sm font-semibold transition"
|
|
||||||
:class="activeTab === tab.id
|
|
||||||
? 'bg-[#123824] text-white'
|
|
||||||
: 'bg-[#eef7f1] text-[#355947] hover:bg-[#e3f1e8]'"
|
|
||||||
@click="setActiveTab(tab.id)"
|
|
||||||
>
|
>
|
||||||
{{ tab.label }}
|
Добавить
|
||||||
</button>
|
</NuxtLink>
|
||||||
</div>
|
</template>
|
||||||
</div>
|
</UiSectionSearchHero>
|
||||||
|
|
||||||
<template v-if="activeTab === 'balances'">
|
<template v-if="activeTab === 'balances'">
|
||||||
<div v-if="balancesQuery.loading.value || referralLinksQuery.loading.value || usersQuery.loading.value" class="manager-empty-state">
|
<div v-if="balancesQuery.loading.value || usersQuery.loading.value" class="manager-empty-state">
|
||||||
Загружаем балансы...
|
Загружаем бонусные счета...
|
||||||
</div>
|
</div>
|
||||||
<div v-else-if="filteredBalances.length === 0" class="manager-empty-state">
|
<div v-else-if="filteredBalances.length === 0" class="manager-empty-state">
|
||||||
Бонусных связок пока нет.
|
Бонусных счетов пока нет.
|
||||||
</div>
|
</div>
|
||||||
<div v-else class="space-y-4">
|
<div v-else class="space-y-4">
|
||||||
<div class="grid gap-4 sm:grid-cols-2 xl:grid-cols-4 2xl:grid-cols-6">
|
<div class="grid gap-4 sm:grid-cols-2 xl:grid-cols-4 2xl:grid-cols-6">
|
||||||
<UsersGridCard
|
<UsersGridCard
|
||||||
v-for="item in visibleBalances"
|
v-for="item in visibleBalances"
|
||||||
:key="item.userId"
|
:key="item.userId"
|
||||||
:to="`/bonus-system/${item.userId}`"
|
:to="`/admin/bonuses/balances/${item.userId}`"
|
||||||
:full-name="item.fullName"
|
:full-name="item.fullName"
|
||||||
:avatar-src="messengerConnectionAvatarSrc(usersById.get(item.userId)?.telegramConnection)"
|
:avatar-src="messengerConnectionAvatarSrc(usersById.get(item.userId)?.telegramConnection)"
|
||||||
:initials="userInitials(item.fullName)"
|
:initials="userInitials(item.fullName)"
|
||||||
@@ -390,14 +336,15 @@ function productVisualLabel(product: ProductCard) {
|
|||||||
{{ productVisualLabel(product) }}
|
{{ productVisualLabel(product) }}
|
||||||
</div>
|
</div>
|
||||||
<div class="min-w-0 flex-1">
|
<div class="min-w-0 flex-1">
|
||||||
<p class="text-sm font-semibold text-[#6a8a76]">{{ product.store }}</p>
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
<h2 class="mt-1 text-lg font-bold leading-tight text-[#123824]">{{ product.title }}</h2>
|
<span class="rounded-full bg-[#eef5f0] px-3 py-1 text-xs font-bold uppercase tracking-[0.12em] text-[#4e7060]">
|
||||||
</div>
|
{{ product.store }}
|
||||||
</div>
|
</span>
|
||||||
<div class="mt-5 flex items-center justify-between gap-3">
|
<span class="rounded-full bg-[#fff8dc] px-3 py-1 text-xs font-bold text-[#7a5b00]">
|
||||||
<p class="text-sm text-[#557562]">Номинал</p>
|
{{ formatAmount(product.amount) }} ₽
|
||||||
<div class="rounded-full bg-[#fff8dc] px-4 py-2 text-sm font-bold text-[#123824]">
|
</span>
|
||||||
{{ formatAmount(product.amount) }} ₽
|
</div>
|
||||||
|
<h2 class="mt-3 text-lg font-bold leading-tight text-[#123824]">{{ compactProductTitle(product) }}</h2>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
@@ -415,7 +362,7 @@ function productVisualLabel(product: ProductCard) {
|
|||||||
<NuxtLink
|
<NuxtLink
|
||||||
v-for="withdrawal in visibleWithdrawals"
|
v-for="withdrawal in visibleWithdrawals"
|
||||||
:key="withdrawal.id"
|
:key="withdrawal.id"
|
||||||
:to="`/bonus-system/withdrawals/${withdrawal.id}`"
|
:to="`/admin/bonuses/requests/${withdrawal.id}`"
|
||||||
class="surface-card surface-card-interactive block rounded-[30px] bg-white px-4 py-4 md:px-5"
|
class="surface-card surface-card-interactive block rounded-[30px] bg-white px-4 py-4 md:px-5"
|
||||||
>
|
>
|
||||||
<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="grid gap-4 md:grid-cols-[minmax(0,1fr)_minmax(0,1.4fr)_180px_140px] md:items-center md:gap-6">
|
||||||
|
|||||||
@@ -1,155 +1,101 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { useMutation, useQuery } from '@vue/apollo-composable';
|
import { useMutation, useQuery } from '@vue/apollo-composable';
|
||||||
import {
|
import {
|
||||||
CreateReferralDocument,
|
CreateBonusProgramLinkDocument,
|
||||||
ManagerReferralLinksDocument,
|
|
||||||
ManagerUsersDocument,
|
ManagerUsersDocument,
|
||||||
type ManagerReferralLinksQuery,
|
type CreateBonusProgramLinkMutation,
|
||||||
type ManagerUsersQuery,
|
type ManagerUsersQuery,
|
||||||
} from '~/composables/graphql/generated';
|
} from '~/composables/graphql/generated';
|
||||||
|
|
||||||
definePageMeta({
|
definePageMeta({
|
||||||
middleware: ['manager-only'],
|
middleware: ['manager-only'],
|
||||||
|
path: '/admin/bonuses/links/new',
|
||||||
|
alias: ['/bonus-system/links/new', '/bonus-system/referrals/new', '/admin/bonuses/referrals/new'],
|
||||||
});
|
});
|
||||||
|
|
||||||
type ManagerUserItem = ManagerUsersQuery['managerUsers'][number];
|
type ManagerUserItem = ManagerUsersQuery['managerUsers'][number];
|
||||||
type ManagerReferralLinkItem = ManagerReferralLinksQuery['managerReferralLinks'][number];
|
const userId = ref('');
|
||||||
|
|
||||||
const referrerUserId = ref('');
|
|
||||||
const refereeUserId = ref('');
|
|
||||||
const bonusPercent = ref(5);
|
|
||||||
const createdReferralId = ref('');
|
|
||||||
const errorMessage = ref('');
|
const errorMessage = ref('');
|
||||||
|
const bonusProgramLink = ref('');
|
||||||
|
const bonusProgramLinkExpiresAt = ref('');
|
||||||
const usersQuery = useQuery(ManagerUsersDocument);
|
const usersQuery = useQuery(ManagerUsersDocument);
|
||||||
const linksQuery = useQuery(ManagerReferralLinksDocument);
|
const createBonusProgramLinkMutation = useMutation(CreateBonusProgramLinkDocument, { throws: 'never' });
|
||||||
const createReferralMutation = useMutation(CreateReferralDocument);
|
|
||||||
|
|
||||||
const clientOptions = computed<ManagerUserItem[]>(() => (
|
const clientOptions = computed<ManagerUserItem[]>(() => (
|
||||||
(usersQuery.result.value?.managerUsers ?? [])
|
(usersQuery.result.value?.managerUsers ?? [])
|
||||||
.filter((user) => user.role === 'CLIENT')
|
.filter((user) => user.role === 'CLIENT')
|
||||||
));
|
));
|
||||||
|
|
||||||
const referrerOptions = computed<ManagerUserItem[]>(() => (
|
|
||||||
clientOptions.value.filter((user) => user.id !== refereeUserId.value)
|
|
||||||
));
|
|
||||||
|
|
||||||
const refereeOptions = computed<ManagerUserItem[]>(() => (
|
|
||||||
clientOptions.value.filter((user) => user.id !== referrerUserId.value)
|
|
||||||
));
|
|
||||||
|
|
||||||
const referralLinks = computed<ManagerReferralLinkItem[]>(() => (
|
|
||||||
linksQuery.result.value?.managerReferralLinks ?? []
|
|
||||||
));
|
|
||||||
|
|
||||||
watch(referrerUserId, (value) => {
|
|
||||||
if (value && value === refereeUserId.value) {
|
|
||||||
refereeUserId.value = '';
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
watch(refereeUserId, (value) => {
|
|
||||||
if (value && value === referrerUserId.value) {
|
|
||||||
referrerUserId.value = '';
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
function userOptionLabel(user: ManagerUserItem) {
|
function userOptionLabel(user: ManagerUserItem) {
|
||||||
return [user.fullName, user.companyName || user.email]
|
return [user.fullName, user.companyName || user.email]
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.join(' • ');
|
.join(' • ');
|
||||||
}
|
}
|
||||||
|
|
||||||
async function createReferral() {
|
async function createBonusAccountLink() {
|
||||||
createdReferralId.value = '';
|
|
||||||
errorMessage.value = '';
|
errorMessage.value = '';
|
||||||
|
bonusProgramLink.value = '';
|
||||||
|
bonusProgramLinkExpiresAt.value = '';
|
||||||
|
|
||||||
if (!referrerUserId.value || !refereeUserId.value) {
|
if (!userId.value) {
|
||||||
errorMessage.value = 'Выберите обоих клиентов для связки.';
|
errorMessage.value = 'Выберите клиента.';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (referrerUserId.value === refereeUserId.value) {
|
const bonusLinkResponse = await createBonusProgramLinkMutation.mutate({
|
||||||
errorMessage.value = 'Нельзя связать клиента с самим собой.';
|
userId: userId.value,
|
||||||
|
});
|
||||||
|
|
||||||
|
const bonusLinkPayload: CreateBonusProgramLinkMutation['createBonusProgramLink'] | undefined = bonusLinkResponse?.data?.createBonusProgramLink;
|
||||||
|
if (!bonusLinkPayload?.url) {
|
||||||
|
errorMessage.value = createBonusProgramLinkMutation.error.value?.message || 'Не удалось сгенерировать ссылку.';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const normalizedBonusPercent = Number(bonusPercent.value);
|
bonusProgramLink.value = bonusLinkPayload.url;
|
||||||
if (!Number.isFinite(normalizedBonusPercent) || normalizedBonusPercent <= 0 || normalizedBonusPercent > 100) {
|
bonusProgramLinkExpiresAt.value = bonusLinkPayload.expiresAt;
|
||||||
errorMessage.value = 'Укажите процент бонуса от 0.01 до 100.';
|
}
|
||||||
|
|
||||||
|
async function copyBonusProgramLink() {
|
||||||
|
if (!bonusProgramLink.value) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
await navigator.clipboard.writeText(bonusProgramLink.value);
|
||||||
const response = await createReferralMutation.mutate({
|
}
|
||||||
input: {
|
|
||||||
referrerUserId: referrerUserId.value,
|
|
||||||
refereeUserId: refereeUserId.value,
|
|
||||||
bonusPercent: normalizedBonusPercent,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
createdReferralId.value = response?.data?.createReferral.id ?? '';
|
function formatDateTime(value: string) {
|
||||||
refereeUserId.value = '';
|
return new Date(value).toLocaleString('ru-RU');
|
||||||
await linksQuery.refetch();
|
|
||||||
} catch (error: any) {
|
|
||||||
errorMessage.value = error?.message || 'Не удалось создать бонусную связку.';
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<section class="space-y-6 max-w-3xl">
|
<section class="space-y-6 max-w-3xl">
|
||||||
<NuxtLink to="/bonus-system" class="text-sm font-semibold text-[#0d854a]">← Назад к бонусам</NuxtLink>
|
<UiBackHeader
|
||||||
|
to="/admin/bonuses/balances"
|
||||||
<div class="manager-hero">
|
back-label="Назад к бонусным счетам"
|
||||||
<p class="manager-eyebrow">Бонусы</p>
|
title="Создать бонусный счет"
|
||||||
<h1 class="manager-title">Создать бонусную связку клиентов</h1>
|
subtitle="Менеджер выбирает клиента и сразу получает ссылку, которую можно переслать ему."
|
||||||
<p class="max-w-2xl text-sm text-[#466653]">
|
/>
|
||||||
Первый клиент получает процент бонуса, когда заказ второго клиента переходит в статус доставленного.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="surface-card rounded-3xl p-5 space-y-4">
|
<div class="surface-card rounded-3xl p-5 space-y-4">
|
||||||
<label class="form-control">
|
<label class="form-control">
|
||||||
<span class="label-text">Клиент, который получает бонус</span>
|
<span class="label-text">Клиент</span>
|
||||||
<select v-model="referrerUserId" class="select manager-field w-full">
|
<select v-model="userId" class="select manager-field w-full">
|
||||||
<option value="">Выберите клиента</option>
|
<option value="">Выберите клиента</option>
|
||||||
<option v-for="user in referrerOptions" :key="user.id" :value="user.id">
|
<option v-for="user in clientOptions" :key="user.id" :value="user.id">
|
||||||
{{ userOptionLabel(user) }}
|
{{ userOptionLabel(user) }}
|
||||||
</option>
|
</option>
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<label class="form-control">
|
|
||||||
<span class="label-text">Клиент, с чьих заказов начисляется бонус</span>
|
|
||||||
<select v-model="refereeUserId" class="select manager-field w-full">
|
|
||||||
<option value="">Выберите клиента</option>
|
|
||||||
<option v-for="user in refereeOptions" :key="user.id" :value="user.id">
|
|
||||||
{{ userOptionLabel(user) }}
|
|
||||||
</option>
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<label class="form-control">
|
|
||||||
<span class="label-text">Процент бонусной программы</span>
|
|
||||||
<input
|
|
||||||
v-model="bonusPercent"
|
|
||||||
type="number"
|
|
||||||
min="0.01"
|
|
||||||
max="100"
|
|
||||||
step="0.01"
|
|
||||||
class="input manager-field w-full"
|
|
||||||
placeholder="5"
|
|
||||||
>
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<div class="mt-4">
|
<div class="mt-4">
|
||||||
<button
|
<button
|
||||||
class="btn btn-primary border-0"
|
class="btn btn-primary border-0"
|
||||||
:disabled="createReferralMutation.loading.value || usersQuery.loading.value"
|
:disabled="createBonusProgramLinkMutation.loading.value || usersQuery.loading.value"
|
||||||
@click="createReferral"
|
@click="createBonusAccountLink"
|
||||||
>
|
>
|
||||||
{{ createReferralMutation.loading.value ? 'Сохраняем...' : 'Создать связь' }}
|
{{ createBonusProgramLinkMutation.loading.value ? 'Генерируем...' : 'Создать ссылку' }}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -158,41 +104,36 @@ async function createReferral() {
|
|||||||
{{ errorMessage }}
|
{{ errorMessage }}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="createdReferralId" class="surface-card rounded-3xl p-5 text-sm text-[#123824]">
|
<article v-if="bonusProgramLink" class="surface-card rounded-3xl p-5 space-y-4">
|
||||||
Создана связь: <span class="font-semibold">{{ createdReferralId }}</span>
|
<div class="space-y-2">
|
||||||
</div>
|
<p class="text-sm font-semibold text-[#123824]">Ссылка в бонусный кабинет</p>
|
||||||
|
<p class="text-sm text-[#466653]">
|
||||||
<div class="space-y-4">
|
Эту ссылку менеджер может сразу отправить клиенту.
|
||||||
<div class="flex items-center justify-between gap-3">
|
</p>
|
||||||
<h2 class="text-lg font-bold text-[#123824]">Текущие бонусные связки</h2>
|
<div class="rounded-[20px] bg-[#f8fbf9] px-4 py-3 text-sm font-semibold text-[#123824] break-all">
|
||||||
<span class="text-sm text-[#466653]">{{ referralLinks.length }}</span>
|
{{ bonusProgramLink }}
|
||||||
|
</div>
|
||||||
|
<p v-if="bonusProgramLinkExpiresAt" class="text-xs text-[#5c7b69]">
|
||||||
|
Действует до {{ formatDateTime(bonusProgramLinkExpiresAt) }}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="linksQuery.loading.value" class="manager-empty-state">
|
<div class="flex flex-wrap gap-3">
|
||||||
Загружаем связки...
|
<a
|
||||||
</div>
|
:href="bonusProgramLink"
|
||||||
<div v-else-if="referralLinks.length === 0" class="manager-empty-state">
|
target="_blank"
|
||||||
Бонусных связок пока нет.
|
rel="noreferrer"
|
||||||
</div>
|
class="btn rounded-full border border-[#d7e9de] bg-white px-5 text-[#123824] hover:bg-[#f3f8f5]"
|
||||||
<div v-else class="space-y-3">
|
|
||||||
<article
|
|
||||||
v-for="link in referralLinks"
|
|
||||||
:key="link.id"
|
|
||||||
class="surface-card rounded-3xl p-5"
|
|
||||||
>
|
>
|
||||||
<div class="space-y-2">
|
Открыть
|
||||||
<p class="text-sm font-semibold text-[#123824]">
|
</a>
|
||||||
{{ link.referrerName }} получает {{ link.bonusPercent }}% с заказов {{ link.refereeName }}
|
<button
|
||||||
</p>
|
class="btn rounded-full border-0 bg-[#139957] px-5 text-white hover:bg-[#0d854a]"
|
||||||
<p class="text-sm text-[#466653]">
|
@click="copyBonusProgramLink"
|
||||||
{{ link.referrerCompanyName || link.referrerEmail }} → {{ link.refereeCompanyName || link.refereeEmail }}
|
>
|
||||||
</p>
|
Скопировать
|
||||||
<p class="text-xs text-[#5c7b69]">
|
</button>
|
||||||
Создано {{ new Date(link.createdAt).toLocaleString() }}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</article>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</article>
|
||||||
</section>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import { AddBonusTransactionDocument } from '~/composables/graphql/generated';
|
|||||||
|
|
||||||
definePageMeta({
|
definePageMeta({
|
||||||
middleware: ['manager-only'],
|
middleware: ['manager-only'],
|
||||||
|
path: '/admin/bonuses/transactions/new',
|
||||||
|
alias: ['/bonus-system/transactions/new'],
|
||||||
});
|
});
|
||||||
|
|
||||||
const userId = ref('');
|
const userId = ref('');
|
||||||
@@ -29,12 +31,11 @@ async function addBonus() {
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<section class="space-y-6 max-w-3xl">
|
<section class="space-y-6 max-w-3xl">
|
||||||
<NuxtLink to="/bonus-system" class="text-sm font-semibold text-[#0d854a]">← Назад к бонусам</NuxtLink>
|
<UiBackHeader
|
||||||
|
to="/admin/bonuses/balances"
|
||||||
<div class="manager-hero">
|
back-label="Назад к бонусным счетам"
|
||||||
<p class="manager-eyebrow">Бонусы</p>
|
title="Добавить бонусную транзакцию"
|
||||||
<h1 class="manager-title">Добавить бонусную транзакцию</h1>
|
/>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="surface-card rounded-3xl p-5 space-y-3">
|
<div class="surface-card rounded-3xl p-5 space-y-3">
|
||||||
<label class="form-control">
|
<label class="form-control">
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ import {
|
|||||||
|
|
||||||
definePageMeta({
|
definePageMeta({
|
||||||
middleware: ['manager-only'],
|
middleware: ['manager-only'],
|
||||||
|
path: '/admin/bonuses/requests/:id',
|
||||||
|
alias: ['/bonus-system/withdrawals/:id'],
|
||||||
});
|
});
|
||||||
|
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
@@ -19,14 +21,22 @@ const withdrawalsQuery = useQuery(ManagerWithdrawalRequestsDocument, {
|
|||||||
});
|
});
|
||||||
const reviewMutation = useMutation(ReviewRewardWithdrawalDocument);
|
const reviewMutation = useMutation(ReviewRewardWithdrawalDocument);
|
||||||
|
|
||||||
const decision = ref<'APPROVE' | 'REJECT'>('APPROVE');
|
|
||||||
const reviewComment = ref('');
|
|
||||||
const reviewResult = ref('');
|
const reviewResult = ref('');
|
||||||
|
const isProcessed = ref(true);
|
||||||
|
const savePending = computed(() => reviewMutation.loading.value);
|
||||||
|
|
||||||
const currentWithdrawal = computed(() =>
|
const currentWithdrawal = computed(() =>
|
||||||
(withdrawalsQuery.result.value?.managerWithdrawalRequests ?? []).find((item: WithdrawalItem) => item.id === withdrawalId.value),
|
(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() {
|
async function reviewWithdrawal() {
|
||||||
if (!currentWithdrawal.value) {
|
if (!currentWithdrawal.value) {
|
||||||
return;
|
return;
|
||||||
@@ -35,8 +45,7 @@ async function reviewWithdrawal() {
|
|||||||
const response = await reviewMutation.mutate({
|
const response = await reviewMutation.mutate({
|
||||||
input: {
|
input: {
|
||||||
withdrawalId: currentWithdrawal.value.id,
|
withdrawalId: currentWithdrawal.value.id,
|
||||||
decision: decision.value,
|
decision: isProcessed.value ? 'APPROVE' : 'REJECT',
|
||||||
reviewComment: reviewComment.value || undefined,
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -46,9 +55,7 @@ async function reviewWithdrawal() {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<section class="space-y-6 max-w-3xl">
|
<section class="space-y-6">
|
||||||
<NuxtLink to="/bonus-system" class="text-sm font-semibold text-[#0d854a]">← Назад к бонусам</NuxtLink>
|
|
||||||
|
|
||||||
<div v-if="withdrawalsQuery.loading.value" class="manager-empty-state">
|
<div v-if="withdrawalsQuery.loading.value" class="manager-empty-state">
|
||||||
Загружаем заявку на вывод...
|
Загружаем заявку на вывод...
|
||||||
</div>
|
</div>
|
||||||
@@ -58,30 +65,42 @@ async function reviewWithdrawal() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<div class="manager-hero">
|
<UiBackHeader
|
||||||
<p class="manager-eyebrow">Вывод</p>
|
to="/admin/bonuses/requests"
|
||||||
<h1 class="manager-title">Проверка заявки на вывод</h1>
|
back-label="Назад к бонусам"
|
||||||
<p class="manager-copy">
|
title="Проверка заявки на вывод"
|
||||||
{{ currentWithdrawal.requesterFullName }} · {{ currentWithdrawal.requesterEmail }} · Сумма: {{ currentWithdrawal.amount }}
|
:subtitle="`${currentWithdrawal.requesterFullName} · ${currentWithdrawal.requesterEmail} · Сумма: ${currentWithdrawal.amount}`"
|
||||||
</p>
|
/>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="surface-card rounded-3xl p-5 space-y-3">
|
<div class="surface-card rounded-3xl p-5 md:p-6">
|
||||||
<label class="form-control">
|
<div class="space-y-5">
|
||||||
<span class="label-text">Решение</span>
|
<label class="flex items-start gap-4 rounded-[24px] bg-[#f5faf7] px-4 py-4">
|
||||||
<select v-model="decision" class="select manager-field w-full">
|
<input
|
||||||
<option value="APPROVE">Одобрить</option>
|
v-model="isProcessed"
|
||||||
<option value="REJECT">Отклонить</option>
|
type="checkbox"
|
||||||
</select>
|
class="checkbox mt-1 border-[#b9d7c5] bg-white [--chkbg:#123824] [--chkfg:#ffffff]"
|
||||||
</label>
|
>
|
||||||
|
<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">
|
<button
|
||||||
<span class="label-text">Комментарий</span>
|
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]"
|
||||||
<textarea v-model="reviewComment" class="textarea manager-field min-h-28 w-full" placeholder="Комментарий для заявки" />
|
:disabled="savePending"
|
||||||
</label>
|
@click="reviewWithdrawal"
|
||||||
|
>
|
||||||
<div>
|
{{
|
||||||
<button class="btn btn-primary border-0" @click="reviewWithdrawal">Сохранить решение</button>
|
savePending
|
||||||
|
? 'Сохраняем...'
|
||||||
|
: isProcessed
|
||||||
|
? 'Провести выплату'
|
||||||
|
: 'Отклонить заявку'
|
||||||
|
}}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
394
app/pages/catalog-settings.vue
Normal file
@@ -0,0 +1,394 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { useMutation, useQuery } from '@vue/apollo-composable';
|
||||||
|
import {
|
||||||
|
CatalogProductTypeSettingsDocument,
|
||||||
|
UpsertCatalogProductTypeSettingDocument,
|
||||||
|
type CatalogProductTypeSettingsQuery,
|
||||||
|
} from '~/composables/graphql/generated';
|
||||||
|
|
||||||
|
definePageMeta({
|
||||||
|
middleware: ['manager-only'],
|
||||||
|
path: '/admin/settings/catalog',
|
||||||
|
});
|
||||||
|
|
||||||
|
type CatalogSettingItem = CatalogProductTypeSettingsQuery['catalogProductTypeSettings'][number];
|
||||||
|
type OptionKey =
|
||||||
|
| 'widthOptionsMm'
|
||||||
|
| 'lengthOptionsM'
|
||||||
|
| 'thicknessOptionsMicron'
|
||||||
|
| 'sleeveOptions'
|
||||||
|
| 'colorOptions'
|
||||||
|
| 'labelOptions';
|
||||||
|
type OptionKind = 'number' | 'text';
|
||||||
|
type CatalogSettingForm = {
|
||||||
|
productType: string;
|
||||||
|
allowCustomLength: boolean;
|
||||||
|
customLengthMinM: string;
|
||||||
|
customLengthMaxM: string;
|
||||||
|
customLengthStepM: string;
|
||||||
|
allowCustomSleeveBrand: boolean;
|
||||||
|
allowCustomLabel: boolean;
|
||||||
|
widthOptionsMm: string[];
|
||||||
|
lengthOptionsM: string[];
|
||||||
|
thicknessOptionsMicron: string[];
|
||||||
|
sleeveOptions: string[];
|
||||||
|
colorOptions: string[];
|
||||||
|
labelOptions: string[];
|
||||||
|
};
|
||||||
|
type OptionGroupDefinition = {
|
||||||
|
key: OptionKey;
|
||||||
|
label: string;
|
||||||
|
placeholder: string;
|
||||||
|
kind: OptionKind;
|
||||||
|
suffix?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const OPTION_GROUPS: OptionGroupDefinition[] = [
|
||||||
|
{ key: 'widthOptionsMm', label: 'Ширина', placeholder: 'Добавить ширину', kind: 'number', suffix: 'мм' },
|
||||||
|
{ key: 'lengthOptionsM', label: 'Длина', placeholder: 'Добавить длину', kind: 'number', suffix: 'м' },
|
||||||
|
{ key: 'thicknessOptionsMicron', label: 'Толщина', placeholder: 'Добавить толщину', kind: 'number', suffix: 'мкм' },
|
||||||
|
{ key: 'sleeveOptions', label: 'Втулка', placeholder: 'Добавить втулку', kind: 'text' },
|
||||||
|
{ key: 'colorOptions', label: 'Цвет', placeholder: 'Добавить цвет', kind: 'text' },
|
||||||
|
{ key: 'labelOptions', label: 'Надпись', placeholder: 'Добавить надпись', kind: 'text' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const settingsQuery = useQuery(CatalogProductTypeSettingsDocument);
|
||||||
|
const saveSettingMutation = useMutation(UpsertCatalogProductTypeSettingDocument, { throws: 'never' });
|
||||||
|
|
||||||
|
const forms = reactive<Record<string, CatalogSettingForm>>({});
|
||||||
|
const isSavingAll = ref(false);
|
||||||
|
const saveSuccess = ref('');
|
||||||
|
const saveError = ref('');
|
||||||
|
|
||||||
|
const settings = computed<CatalogSettingItem[]>(() => settingsQuery.result.value?.catalogProductTypeSettings ?? []);
|
||||||
|
const isLoading = computed(() => settingsQuery.loading.value);
|
||||||
|
|
||||||
|
function normalizeText(value: string | null | undefined) {
|
||||||
|
return String(value ?? '').replaceAll(/\s+/g, ' ').trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function toInputValue(value: number | null | undefined) {
|
||||||
|
return value == null ? '' : String(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseOptionalInteger(value: string) {
|
||||||
|
const normalized = value.trim();
|
||||||
|
if (!normalized) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = Number(normalized);
|
||||||
|
if (!Number.isInteger(parsed) || parsed <= 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return parsed;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeOptionEntry(value: string, kind: OptionKind) {
|
||||||
|
if (kind === 'number') {
|
||||||
|
const parsed = parseOptionalInteger(value);
|
||||||
|
return parsed == null ? '' : String(parsed);
|
||||||
|
}
|
||||||
|
|
||||||
|
return normalizeText(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeOptionList(values: Array<string | number | null | undefined>, kind: OptionKind) {
|
||||||
|
const normalizedValues = values
|
||||||
|
.map((value) => normalizeOptionEntry(String(value ?? ''), kind))
|
||||||
|
.filter(Boolean);
|
||||||
|
|
||||||
|
return [...new Set(normalizedValues)].sort((left, right) => {
|
||||||
|
if (kind === 'number') {
|
||||||
|
return Number(left) - Number(right);
|
||||||
|
}
|
||||||
|
|
||||||
|
return left.localeCompare(right, 'ru');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function createForm(item: CatalogSettingItem): CatalogSettingForm {
|
||||||
|
return {
|
||||||
|
productType: item.productType,
|
||||||
|
allowCustomLength: item.allowCustomLength,
|
||||||
|
customLengthMinM: toInputValue(item.customLengthMinM),
|
||||||
|
customLengthMaxM: toInputValue(item.customLengthMaxM),
|
||||||
|
customLengthStepM: toInputValue(item.customLengthStepM),
|
||||||
|
allowCustomSleeveBrand: item.allowCustomSleeveBrand,
|
||||||
|
allowCustomLabel: item.allowCustomLabel,
|
||||||
|
widthOptionsMm: normalizeOptionList(item.widthOptionsMm, 'number'),
|
||||||
|
lengthOptionsM: normalizeOptionList(item.lengthOptionsM, 'number'),
|
||||||
|
thicknessOptionsMicron: normalizeOptionList(item.thicknessOptionsMicron, 'number'),
|
||||||
|
sleeveOptions: normalizeOptionList(item.sleeveOptions, 'text'),
|
||||||
|
colorOptions: normalizeOptionList(item.colorOptions, 'text'),
|
||||||
|
labelOptions: normalizeOptionList(item.labelOptions, 'text'),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function formFor(item: CatalogSettingItem) {
|
||||||
|
forms[item.productType] ??= createForm(item);
|
||||||
|
return forms[item.productType];
|
||||||
|
}
|
||||||
|
|
||||||
|
function addOption(form: CatalogSettingForm, group: OptionGroupDefinition, rawValue: string) {
|
||||||
|
const value = normalizeOptionEntry(rawValue, group.kind);
|
||||||
|
if (!value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
form[group.key] = normalizeOptionList([...form[group.key], value], group.kind);
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeOption(form: CatalogSettingForm, groupKey: OptionKey, value: string) {
|
||||||
|
form[groupKey] = form[groupKey].filter((item) => item !== value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function openAddOptionPrompt(form: CatalogSettingForm, group: OptionGroupDefinition) {
|
||||||
|
const value = window.prompt(group.placeholder, '');
|
||||||
|
if (value == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
addOption(form, group, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function optionChipLabel(value: string, group: OptionGroupDefinition) {
|
||||||
|
return group.suffix ? `${value} ${group.suffix}` : value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseIntegerOptionList(values: string[]) {
|
||||||
|
return normalizeOptionList(values, 'number').map((value) => Number(value));
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseTextOptionList(values: string[]) {
|
||||||
|
return normalizeOptionList(values, 'text');
|
||||||
|
}
|
||||||
|
|
||||||
|
function enabledCustomizationCount(form: CatalogSettingForm) {
|
||||||
|
return [
|
||||||
|
form.allowCustomLength,
|
||||||
|
form.allowCustomSleeveBrand,
|
||||||
|
form.allowCustomLabel,
|
||||||
|
].filter(Boolean).length;
|
||||||
|
}
|
||||||
|
|
||||||
|
function filledParameterGroupCount(form: CatalogSettingForm) {
|
||||||
|
return OPTION_GROUPS.filter((group) => form[group.key].length > 0).length;
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
settings,
|
||||||
|
(items) => {
|
||||||
|
const activeTypes = new Set(items.map((item) => item.productType));
|
||||||
|
|
||||||
|
for (const productType of Object.keys(forms)) {
|
||||||
|
if (!activeTypes.has(productType)) {
|
||||||
|
Reflect.deleteProperty(forms, productType);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const item of items) {
|
||||||
|
forms[item.productType] = createForm(item);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ immediate: true },
|
||||||
|
);
|
||||||
|
|
||||||
|
async function saveAllSettings() {
|
||||||
|
saveSuccess.value = '';
|
||||||
|
saveError.value = '';
|
||||||
|
isSavingAll.value = true;
|
||||||
|
|
||||||
|
for (const item of settings.value) {
|
||||||
|
const form = formFor(item);
|
||||||
|
const result = await saveSettingMutation.mutate({
|
||||||
|
input: {
|
||||||
|
productType: form.productType,
|
||||||
|
showQuantityPerBox: false,
|
||||||
|
allowCustomLength: form.allowCustomLength,
|
||||||
|
customLengthMinM: form.allowCustomLength ? parseOptionalInteger(form.customLengthMinM) : null,
|
||||||
|
customLengthMaxM: form.allowCustomLength ? parseOptionalInteger(form.customLengthMaxM) : null,
|
||||||
|
customLengthStepM: form.allowCustomLength ? parseOptionalInteger(form.customLengthStepM) : null,
|
||||||
|
allowCustomSleeveBrand: form.allowCustomSleeveBrand,
|
||||||
|
allowCustomLabel: form.allowCustomLabel,
|
||||||
|
widthOptionsMm: parseIntegerOptionList(form.widthOptionsMm),
|
||||||
|
lengthOptionsM: parseIntegerOptionList(form.lengthOptionsM),
|
||||||
|
thicknessOptionsMicron: parseIntegerOptionList(form.thicknessOptionsMicron),
|
||||||
|
sleeveOptions: parseTextOptionList(form.sleeveOptions),
|
||||||
|
colorOptions: parseTextOptionList(form.colorOptions),
|
||||||
|
labelOptions: parseTextOptionList(form.labelOptions),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!result?.data?.upsertCatalogProductTypeSetting) {
|
||||||
|
saveError.value = saveSettingMutation.error.value?.message || `Не удалось сохранить настройки для "${form.productType}".`;
|
||||||
|
isSavingAll.value = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
forms[item.productType] = createForm(result.data.upsertCatalogProductTypeSetting);
|
||||||
|
}
|
||||||
|
|
||||||
|
isSavingAll.value = false;
|
||||||
|
saveSuccess.value = 'Настройки сохранены.';
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section class="space-y-6">
|
||||||
|
<h1 class="text-3xl font-extrabold text-[#0f2f20]">Каталог</h1>
|
||||||
|
|
||||||
|
<div v-if="isLoading" class="manager-empty-state">
|
||||||
|
Загружаем настройки каталога...
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else-if="settings.length === 0" class="manager-empty-state">
|
||||||
|
Типы товаров пока не появились в каталоге.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="space-y-3">
|
||||||
|
<details
|
||||||
|
v-for="item in settings"
|
||||||
|
:key="item.productType"
|
||||||
|
class="group rounded-[28px] bg-white shadow-[0_18px_38px_rgba(18,56,36,0.08)]"
|
||||||
|
>
|
||||||
|
<summary class="flex cursor-pointer list-none items-center justify-between gap-4 p-5">
|
||||||
|
<div class="min-w-0 space-y-1">
|
||||||
|
<h2 class="text-xl font-bold text-[#123824]">{{ item.productType }}</h2>
|
||||||
|
<p class="text-sm text-[#5a7667]">
|
||||||
|
{{ filledParameterGroupCount(formFor(item)) }} параметров, {{ enabledCustomizationCount(formFor(item)) }} кастомные возможности
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<span class="hidden text-sm font-semibold text-[#6a8a78] md:inline">Открыть</span>
|
||||||
|
<span class="inline-flex h-10 w-10 items-center justify-center rounded-full bg-[#eef7f1] text-[#1d5a3c] transition group-open:rotate-180">
|
||||||
|
<svg viewBox="0 0 20 20" fill="currentColor" class="h-5 w-5">
|
||||||
|
<path fill-rule="evenodd" d="M5.23 7.21a.75.75 0 0 1 1.06.02L10 11.168l3.71-3.938a.75.75 0 1 1 1.08 1.04l-4.25 4.512a.75.75 0 0 1-1.08 0L5.21 8.27a.75.75 0 0 1 .02-1.06Z" clip-rule="evenodd" />
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</summary>
|
||||||
|
|
||||||
|
<div class="space-y-5 border-t border-[#edf4ef] p-5">
|
||||||
|
<div class="space-y-3">
|
||||||
|
<label class="surface-card flex items-center gap-3 rounded-[22px] p-4">
|
||||||
|
<input v-model="formFor(item).allowCustomLength" type="checkbox" class="checkbox checkbox-success">
|
||||||
|
<span class="text-sm font-semibold text-[#123824]">Любая длина</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="surface-card flex items-center gap-3 rounded-[22px] p-4">
|
||||||
|
<input v-model="formFor(item).allowCustomSleeveBrand" type="checkbox" class="checkbox checkbox-success">
|
||||||
|
<span class="text-sm font-semibold text-[#123824]">Логотип на втулке</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="surface-card flex items-center gap-3 rounded-[22px] p-4">
|
||||||
|
<input v-model="formFor(item).allowCustomLabel" type="checkbox" class="checkbox checkbox-success">
|
||||||
|
<span class="text-sm font-semibold text-[#123824]">Нанесение надписи</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="formFor(item).allowCustomLength" class="rounded-[24px] bg-[#f7fbf8] p-4">
|
||||||
|
<div class="mb-4 text-sm font-bold uppercase tracking-[0.12em] text-[#355947]">Диапазон длины</div>
|
||||||
|
<div class="grid gap-3 md:grid-cols-3">
|
||||||
|
<label class="space-y-2">
|
||||||
|
<span class="text-sm font-semibold text-[#123824]">Мин. длина, м</span>
|
||||||
|
<input
|
||||||
|
v-model="formFor(item).customLengthMinM"
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
step="1"
|
||||||
|
class="input manager-field w-full"
|
||||||
|
>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="space-y-2">
|
||||||
|
<span class="text-sm font-semibold text-[#123824]">Макс. длина, м</span>
|
||||||
|
<input
|
||||||
|
v-model="formFor(item).customLengthMaxM"
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
step="1"
|
||||||
|
class="input manager-field w-full"
|
||||||
|
>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="space-y-2">
|
||||||
|
<span class="text-sm font-semibold text-[#123824]">Шаг, м</span>
|
||||||
|
<input
|
||||||
|
v-model="formFor(item).customLengthStepM"
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
step="1"
|
||||||
|
class="input manager-field w-full"
|
||||||
|
>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rounded-[24px] bg-[#f7fbf8] p-4">
|
||||||
|
<div class="mb-4 text-sm font-bold uppercase tracking-[0.12em] text-[#355947]">Параметры</div>
|
||||||
|
|
||||||
|
<div class="space-y-4">
|
||||||
|
<div
|
||||||
|
v-for="group in OPTION_GROUPS"
|
||||||
|
:key="`${item.productType}-${group.key}`"
|
||||||
|
class="group rounded-[20px] bg-white p-4"
|
||||||
|
>
|
||||||
|
<div class="space-y-3">
|
||||||
|
<div class="flex items-center justify-between gap-3">
|
||||||
|
<div class="text-sm font-semibold text-[#123824]">{{ group.label }}</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="inline-flex h-7 w-7 items-center justify-center rounded-full border border-[#d6e7dc] text-sm font-semibold text-[#6a8a78] opacity-0 transition group-hover:opacity-100 hover:border-[#9ccbb0] hover:text-[#155c3a]"
|
||||||
|
@click="openAddOptionPrompt(formFor(item), group)"
|
||||||
|
>
|
||||||
|
+
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-wrap gap-2">
|
||||||
|
<button
|
||||||
|
v-for="value in formFor(item)[group.key]"
|
||||||
|
:key="`${item.productType}-${group.key}-${value}`"
|
||||||
|
type="button"
|
||||||
|
class="group/chip inline-flex items-center gap-2 rounded-full bg-[#eef7f1] px-3 py-1 text-xs font-semibold text-[#1d5a3c]"
|
||||||
|
@click="removeOption(formFor(item), group.key, value)"
|
||||||
|
>
|
||||||
|
<span>{{ optionChipLabel(value, group) }}</span>
|
||||||
|
<span class="text-[11px] leading-none text-[#6a8a78] opacity-0 transition group-hover/chip:opacity-100">×</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<span
|
||||||
|
v-if="formFor(item)[group.key].length === 0"
|
||||||
|
class="rounded-full border border-dashed border-[#d6e7dc] px-3 py-1 text-xs font-medium text-[#7b8f84]"
|
||||||
|
>
|
||||||
|
Пока пусто
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="saveSuccess || saveError" class="space-y-2 text-sm">
|
||||||
|
<p v-if="saveSuccess" class="font-semibold text-[#1c6b45]">{{ saveSuccess }}</p>
|
||||||
|
<p v-if="saveError" class="font-semibold text-[#c4472d]">{{ saveError }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="settings.length" class="flex justify-end">
|
||||||
|
<button
|
||||||
|
class="btn h-11 rounded-full border-0 bg-[#139957] px-6 text-sm font-semibold text-white hover:bg-[#0d854a]"
|
||||||
|
:disabled="isSavingAll"
|
||||||
|
@click="saveAllSettings"
|
||||||
|
>
|
||||||
|
{{ isSavingAll ? 'Сохраняем…' : 'Сохранить' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
@@ -1,12 +1,15 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { useMutation, useQuery } from '@vue/apollo-composable';
|
import { useMutation, useQuery } from '@vue/apollo-composable';
|
||||||
import {
|
import {
|
||||||
|
ManagerUsersDetailDocument,
|
||||||
ManagerSetOrderOfferDocument,
|
ManagerSetOrderOfferDocument,
|
||||||
ManagerSetOrderStatusDocument,
|
ManagerSetOrderStatusDocument,
|
||||||
OrderStatus,
|
OrderStatus,
|
||||||
OrderDetailDocument,
|
OrderDetailDocument,
|
||||||
type OrderDetailQuery,
|
type OrderDetailQuery,
|
||||||
|
type ManagerUsersDetailQuery,
|
||||||
} from '~/composables/graphql/generated';
|
} from '~/composables/graphql/generated';
|
||||||
|
import { messengerConnectionAvatarSrc } from '~/composables/useMessengerConnectionPresentation';
|
||||||
import {
|
import {
|
||||||
formatPrice,
|
formatPrice,
|
||||||
} from '~/composables/useOrderDetailPresentation';
|
} from '~/composables/useOrderDetailPresentation';
|
||||||
@@ -14,12 +17,15 @@ import { formatOrderCode } from '~/composables/useOrderCodePresentation';
|
|||||||
|
|
||||||
definePageMeta({
|
definePageMeta({
|
||||||
middleware: ['manager-only'],
|
middleware: ['manager-only'],
|
||||||
|
path: '/admin/orders/:id',
|
||||||
|
alias: ['/client-orders/:id'],
|
||||||
});
|
});
|
||||||
|
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
const orderId = computed(() => String(route.params.id || ''));
|
const orderId = computed(() => String(route.params.id || ''));
|
||||||
|
|
||||||
type ManagerOrderItem = NonNullable<OrderDetailQuery['order']>;
|
type ManagerOrderItem = NonNullable<OrderDetailQuery['order']>;
|
||||||
|
type ManagerCustomerItem = ManagerUsersDetailQuery['managerUsers'][number];
|
||||||
type StatusOption = {
|
type StatusOption = {
|
||||||
value: OrderStatus;
|
value: OrderStatus;
|
||||||
label: string;
|
label: string;
|
||||||
@@ -28,6 +34,7 @@ type StatusOption = {
|
|||||||
const orderQuery = useQuery(OrderDetailDocument, () => ({
|
const orderQuery = useQuery(OrderDetailDocument, () => ({
|
||||||
id: orderId.value,
|
id: orderId.value,
|
||||||
}));
|
}));
|
||||||
|
const managerUsersQuery = useQuery(ManagerUsersDetailDocument);
|
||||||
|
|
||||||
const setOfferMutation = useMutation(ManagerSetOrderOfferDocument);
|
const setOfferMutation = useMutation(ManagerSetOrderOfferDocument);
|
||||||
const setOrderStatusMutation = useMutation(ManagerSetOrderStatusDocument);
|
const setOrderStatusMutation = useMutation(ManagerSetOrderStatusDocument);
|
||||||
@@ -49,6 +56,28 @@ const currentOrder = computed<ManagerOrderItem | null>(() =>
|
|||||||
);
|
);
|
||||||
|
|
||||||
const currentOrderCode = computed(() => formatOrderCode(currentOrder.value?.code));
|
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(
|
watch(
|
||||||
currentOrder,
|
currentOrder,
|
||||||
@@ -314,20 +343,40 @@ watch(
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<div class="surface-card rounded-3xl px-5 py-4">
|
<UiBackHeader
|
||||||
<div class="flex flex-wrap items-center gap-3">
|
to="/admin/orders"
|
||||||
<NuxtLink to="/client-orders" class="text-sm font-semibold text-[#0d854a]">
|
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>
|
</NuxtLink>
|
||||||
<span class="hidden h-4 w-px bg-[#d8e4dd] md:block" />
|
</template>
|
||||||
<span class="text-[11px] font-semibold uppercase tracking-[0.18em] text-[#6a8a76]">
|
</UiBackHeader>
|
||||||
Заказ
|
|
||||||
</span>
|
|
||||||
<span class="text-lg font-black tracking-[-0.03em] text-[#123824]">
|
|
||||||
{{ currentOrderCode }}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="space-y-4">
|
<div class="space-y-4">
|
||||||
<div class="surface-card rounded-3xl p-5">
|
<div class="surface-card rounded-3xl p-5">
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ import { formatPrice } from '~/composables/useOrderDetailPresentation';
|
|||||||
|
|
||||||
definePageMeta({
|
definePageMeta({
|
||||||
middleware: ['manager-only'],
|
middleware: ['manager-only'],
|
||||||
|
path: '/admin/orders',
|
||||||
|
alias: ['/client-orders'],
|
||||||
});
|
});
|
||||||
|
|
||||||
type ManagerOrderItem = ManagerOrdersQuery['managerOrders'][number];
|
type ManagerOrderItem = ManagerOrdersQuery['managerOrders'][number];
|
||||||
@@ -264,7 +266,7 @@ const calendarOptions = computed(() => ({
|
|||||||
};
|
};
|
||||||
},
|
},
|
||||||
eventClick: ({ event }: { event: { id: string } }) => {
|
eventClick: ({ event }: { event: { id: string } }) => {
|
||||||
void router.push(`/client-orders/${event.id}`);
|
void router.push(`/admin/orders/${event.id}`);
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
</script>
|
</script>
|
||||||
@@ -333,7 +335,7 @@ const calendarOptions = computed(() => ({
|
|||||||
<OrdersOrderSummaryCard
|
<OrdersOrderSummaryCard
|
||||||
v-for="order in visibleOrders"
|
v-for="order in visibleOrders"
|
||||||
:key="order.id"
|
:key="order.id"
|
||||||
:to="`/client-orders/${order.id}`"
|
:to="`/admin/orders/${order.id}`"
|
||||||
:code="order.code"
|
:code="order.code"
|
||||||
:status="order.status"
|
:status="order.status"
|
||||||
:created-at="order.createdAt"
|
:created-at="order.createdAt"
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ import { messengerConnectionAvatarSrc } from '~/composables/useMessengerConnecti
|
|||||||
|
|
||||||
definePageMeta({
|
definePageMeta({
|
||||||
middleware: ['manager-only'],
|
middleware: ['manager-only'],
|
||||||
|
path: '/admin/orders/:mode(clients|requests)/:id',
|
||||||
|
alias: ['/clients/:id'],
|
||||||
});
|
});
|
||||||
|
|
||||||
type ManagerUserItem = ManagerUsersDetailQuery['managerUsers'][number];
|
type ManagerUserItem = ManagerUsersDetailQuery['managerUsers'][number];
|
||||||
@@ -21,8 +23,9 @@ type RequestItem = RegistrationRequestsQuery['registrationRequests'][number];
|
|||||||
|
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
const entityId = computed(() => String(route.params.id || ''));
|
const entityId = computed(() => String(route.params.id || ''));
|
||||||
const isRequestMode = computed(() => route.query.tab === 'requests');
|
const entityMode = computed(() => String(route.params.mode || 'clients'));
|
||||||
const backTarget = computed(() => '/clients');
|
const isRequestMode = computed(() => entityMode.value === 'requests');
|
||||||
|
const backTarget = computed(() => '/admin/orders/clients');
|
||||||
|
|
||||||
const usersQuery = useQuery(ManagerUsersDetailDocument);
|
const usersQuery = useQuery(ManagerUsersDetailDocument);
|
||||||
const requestsQuery = useQuery(RegistrationRequestsDocument, {
|
const requestsQuery = useQuery(RegistrationRequestsDocument, {
|
||||||
@@ -104,8 +107,6 @@ async function rejectRequest() {
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<section class="space-y-6">
|
<section class="space-y-6">
|
||||||
<NuxtLink :to="backTarget" class="text-sm font-semibold text-[#0d854a]">← Назад к пользователям</NuxtLink>
|
|
||||||
|
|
||||||
<template v-if="isRequestMode">
|
<template v-if="isRequestMode">
|
||||||
<div v-if="requestsQuery.loading.value" class="manager-empty-state">
|
<div v-if="requestsQuery.loading.value" class="manager-empty-state">
|
||||||
Загружаем карточку клиента...
|
Загружаем карточку клиента...
|
||||||
@@ -116,18 +117,19 @@ async function rejectRequest() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<div class="flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
|
<UiBackHeader
|
||||||
<div class="manager-hero">
|
:to="backTarget"
|
||||||
<p class="manager-eyebrow">Заявка</p>
|
back-label="Назад к пользователям"
|
||||||
<h1 class="manager-title">{{ currentRequest.companyName }}</h1>
|
:title="`Заявка ${currentRequest.companyName}`"
|
||||||
<p class="manager-copy">Контакт: {{ currentRequest.contactName }} · {{ currentRequest.email }}</p>
|
:subtitle="`Контакт: ${currentRequest.contactName} · ${currentRequest.email}`"
|
||||||
</div>
|
>
|
||||||
|
<template #actions>
|
||||||
<div v-if="currentRequest.status === 'PENDING'" class="flex flex-wrap gap-2">
|
<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-success border-0" @click="approveRequest">Одобрить</button>
|
||||||
<button class="btn btn-error border-0" @click="rejectRequest">Отклонить</button>
|
<button class="btn btn-error border-0" @click="rejectRequest">Отклонить</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</template>
|
||||||
|
</UiBackHeader>
|
||||||
|
|
||||||
<div class="grid gap-4 lg:grid-cols-3">
|
<div class="grid gap-4 lg:grid-cols-3">
|
||||||
<div class="manager-stat-card">
|
<div class="manager-stat-card">
|
||||||
@@ -158,6 +160,13 @@ async function rejectRequest() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<template v-else>
|
<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="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 flex-col gap-6 md:flex-row md:items-start">
|
||||||
<div class="flex shrink-0 justify-center md:block">
|
<div class="flex shrink-0 justify-center md:block">
|
||||||
@@ -176,11 +185,6 @@ async function rejectRequest() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="min-w-0 flex-1 space-y-5">
|
<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="grid gap-3 md:grid-cols-2 xl:grid-cols-4">
|
||||||
<div class="rounded-[24px] bg-white/70 px-4 py-3">
|
<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>
|
<p class="text-[11px] font-semibold uppercase tracking-[0.18em] text-[#6a8a76]">Email</p>
|
||||||
@@ -221,7 +225,7 @@ async function rejectRequest() {
|
|||||||
<OrdersOrderSummaryCard
|
<OrdersOrderSummaryCard
|
||||||
v-for="order in visibleUserOrders"
|
v-for="order in visibleUserOrders"
|
||||||
:key="order.id"
|
:key="order.id"
|
||||||
:to="`/client-orders/${order.id}`"
|
:to="`/admin/orders/${order.id}`"
|
||||||
:code="order.code"
|
:code="order.code"
|
||||||
:status="order.status"
|
:status="order.status"
|
||||||
:created-at="order.createdAt"
|
:created-at="order.createdAt"
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import { messengerConnectionAvatarSrc } from '~/composables/useMessengerConnecti
|
|||||||
|
|
||||||
definePageMeta({
|
definePageMeta({
|
||||||
middleware: ['manager-only'],
|
middleware: ['manager-only'],
|
||||||
|
path: '/admin/orders/clients',
|
||||||
|
alias: ['/clients'],
|
||||||
});
|
});
|
||||||
|
|
||||||
const search = ref('');
|
const search = ref('');
|
||||||
@@ -64,7 +66,7 @@ function userInitials(fullName: string) {
|
|||||||
search-placeholder="Имя, компания или email"
|
search-placeholder="Имя, компания или email"
|
||||||
>
|
>
|
||||||
<template #controls>
|
<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>
|
</NuxtLink>
|
||||||
</template>
|
</template>
|
||||||
@@ -81,7 +83,7 @@ function userInitials(fullName: string) {
|
|||||||
<UsersGridCard
|
<UsersGridCard
|
||||||
v-for="user in visibleUsers"
|
v-for="user in visibleUsers"
|
||||||
:key="user.id"
|
:key="user.id"
|
||||||
:to="`/clients/${user.id}`"
|
:to="`/admin/orders/clients/${user.id}`"
|
||||||
:full-name="user.fullName"
|
:full-name="user.fullName"
|
||||||
:avatar-src="messengerConnectionAvatarSrc(user.telegramConnection)"
|
:avatar-src="messengerConnectionAvatarSrc(user.telegramConnection)"
|
||||||
:initials="userInitials(user.fullName)"
|
:initials="userInitials(user.fullName)"
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import { CreateInvitationDocument } from '~/composables/graphql/generated';
|
|||||||
|
|
||||||
definePageMeta({
|
definePageMeta({
|
||||||
middleware: ['manager-only'],
|
middleware: ['manager-only'],
|
||||||
|
path: '/admin/orders/clients/invite',
|
||||||
|
alias: ['/clients/invite'],
|
||||||
});
|
});
|
||||||
|
|
||||||
const email = ref('');
|
const email = ref('');
|
||||||
@@ -37,13 +39,12 @@ async function createInvitation() {
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<section class="space-y-6 max-w-3xl">
|
<section class="space-y-6 max-w-3xl">
|
||||||
<NuxtLink to="/clients" class="text-sm font-semibold text-[#0d854a]">← Назад к пользователям</NuxtLink>
|
<UiBackHeader
|
||||||
|
to="/admin/orders/clients"
|
||||||
<div class="manager-hero">
|
back-label="Назад к пользователям"
|
||||||
<p class="manager-eyebrow">Приглашение</p>
|
title="Пригласить нового клиента"
|
||||||
<h1 class="manager-title">Пригласить нового клиента</h1>
|
subtitle="Форма вынесена отдельно, чтобы список клиентов оставался чистым и спокойным."
|
||||||
<p class="manager-copy">Форма вынесена отдельно, чтобы список клиентов оставался чистым и спокойным.</p>
|
/>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="surface-card rounded-3xl p-5">
|
<div class="surface-card rounded-3xl p-5">
|
||||||
<div class="grid gap-3">
|
<div class="grid gap-3">
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import CatalogConfigurator from '~/components/catalog/CatalogConfigurator.vue';
|
import CatalogProductTypeList from '~/components/catalog/CatalogProductTypeList.vue';
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<CatalogConfigurator />
|
<CatalogProductTypeList />
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -24,8 +24,6 @@ const expiresAt = ref('');
|
|||||||
const code = ref('');
|
const code = ref('');
|
||||||
const feedback = ref('');
|
const feedback = ref('');
|
||||||
const feedbackTone = ref<'success' | 'error'>('success');
|
const feedbackTone = ref<'success' | 'error'>('success');
|
||||||
const autoRequestTimer = ref<ReturnType<typeof setTimeout> | null>(null);
|
|
||||||
const lastRequestedEmail = ref('');
|
|
||||||
|
|
||||||
const requestCodeMutation = useMutation(RequestLoginCodeDocument, { throws: 'never' });
|
const requestCodeMutation = useMutation(RequestLoginCodeDocument, { throws: 'never' });
|
||||||
const verifyCodeMutation = useMutation(VerifyLoginCodeDocument, { 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 normalizedEmail = computed(() => email.value.trim().toLowerCase());
|
||||||
const isEmailReady = computed(() => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(normalizedEmail.value));
|
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(() =>
|
const nextPath = computed(() =>
|
||||||
typeof route.query.next === 'string' && route.query.next.startsWith('/')
|
typeof route.query.next === 'string' && route.query.next.startsWith('/')
|
||||||
? route.query.next
|
? route.query.next
|
||||||
@@ -69,15 +70,6 @@ async function navigateAfterLogin(user: { company?: { id: string } | null; compa
|
|||||||
await navigateTo('/');
|
await navigateTo('/');
|
||||||
}
|
}
|
||||||
|
|
||||||
function clearAutoRequestTimer() {
|
|
||||||
if (!autoRequestTimer.value) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
clearTimeout(autoRequestTimer.value);
|
|
||||||
autoRequestTimer.value = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeApolloErrorMessage(message: string) {
|
function normalizeApolloErrorMessage(message: string) {
|
||||||
if (message.includes('User for this destination was not found.')) {
|
if (message.includes('User for this destination was not found.')) {
|
||||||
return 'Пользователь с таким e-mail не найден. Вход доступен только для созданных аккаунтов.';
|
return 'Пользователь с таким e-mail не найден. Вход доступен только для созданных аккаунтов.';
|
||||||
@@ -118,7 +110,6 @@ async function requestCode() {
|
|||||||
if (requestCodeMutation.loading.value) {
|
if (requestCodeMutation.loading.value) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
clearAutoRequestTimer();
|
|
||||||
|
|
||||||
feedback.value = '';
|
feedback.value = '';
|
||||||
const result = await requestCodeMutation.mutate({
|
const result = await requestCodeMutation.mutate({
|
||||||
@@ -135,7 +126,6 @@ async function requestCode() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
lastRequestedEmail.value = normalizedEmail.value;
|
|
||||||
challengeToken.value = payload.challengeToken;
|
challengeToken.value = payload.challengeToken;
|
||||||
maskedEmail.value = payload.destination;
|
maskedEmail.value = payload.destination;
|
||||||
expiresAt.value = new Date(payload.expiresAt).toLocaleString();
|
expiresAt.value = new Date(payload.expiresAt).toLocaleString();
|
||||||
@@ -172,6 +162,15 @@ async function verifyCode() {
|
|||||||
await navigateAfterLogin(payload.user);
|
await navigateAfterLogin(payload.user);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function returnToRequestStep() {
|
||||||
|
step.value = 'request';
|
||||||
|
code.value = '';
|
||||||
|
feedback.value = '';
|
||||||
|
challengeToken.value = '';
|
||||||
|
maskedEmail.value = '';
|
||||||
|
expiresAt.value = '';
|
||||||
|
}
|
||||||
|
|
||||||
async function consumeLoginToken(loginToken: string) {
|
async function consumeLoginToken(loginToken: string) {
|
||||||
feedback.value = '';
|
feedback.value = '';
|
||||||
const result = await consumeLoginTokenMutation.mutate({
|
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 () => {
|
onMounted(async () => {
|
||||||
const loginToken = typeof route.query.login_token === 'string' ? route.query.login_token : '';
|
const loginToken = typeof route.query.login_token === 'string' ? route.query.login_token : '';
|
||||||
if (loginToken) {
|
if (loginToken) {
|
||||||
@@ -344,112 +302,133 @@ onMounted(async () => {
|
|||||||
|
|
||||||
await tryMessengerMiniAppLogin();
|
await tryMessengerMiniAppLogin();
|
||||||
});
|
});
|
||||||
|
|
||||||
onBeforeUnmount(() => {
|
|
||||||
clearAutoRequestTimer();
|
|
||||||
});
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<section class="mx-auto flex min-h-[calc(100vh-4rem)] w-full max-w-3xl items-center py-8">
|
<section class="mx-auto flex w-full max-w-[540px] items-center justify-center py-6 md:py-10">
|
||||||
<div class="card w-full border border-base-300/60 bg-base-100 shadow-xl">
|
<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="card-body p-5 md:p-8">
|
<div class="space-y-6">
|
||||||
<div class="mb-4 text-center">
|
<div class="space-y-3 text-center">
|
||||||
<h1 class="text-3xl font-extrabold">Вход в личный кабинет</h1>
|
<p class="text-[11px] font-bold uppercase tracking-[0.22em] text-[#6a8a76]">Фрегат</p>
|
||||||
<p
|
<div class="space-y-2">
|
||||||
v-if="telegramMiniAppMode === 'checking'"
|
<h1 class="text-3xl font-black tracking-[-0.04em] text-[#123824] md:text-4xl">Вход</h1>
|
||||||
class="mt-2 text-sm text-base-content/70"
|
<p class="text-sm leading-6 text-[#5c7b69]">
|
||||||
>
|
Войдите по рабочему e-mail и коду из письма.
|
||||||
|
</p>
|
||||||
|
</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()}…` }}
|
{{ `Проверяем аккаунт ${resolveMessengerMiniAppLabel()}…` }}
|
||||||
</p>
|
</template>
|
||||||
<p
|
<template v-else>
|
||||||
v-else-if="isMessengerMiniApp"
|
|
||||||
class="mt-2 text-sm text-base-content/70"
|
|
||||||
>
|
|
||||||
{{
|
{{
|
||||||
messengerMiniAppDisplayName
|
messengerMiniAppDisplayName
|
||||||
? `Вы вошли из ${resolveMessengerMiniAppLabel()} как ${messengerMiniAppDisplayName}.`
|
? `Вы вошли из ${resolveMessengerMiniAppLabel()} как ${messengerMiniAppDisplayName}.`
|
||||||
: `Вы открыли кабинет внутри ${resolveMessengerMiniAppLabel()}.`
|
: `Вы открыли кабинет внутри ${resolveMessengerMiniAppLabel()}.`
|
||||||
}}
|
}}
|
||||||
</p>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="step === 'request'" class="space-y-4">
|
<div v-if="step === 'request'" class="space-y-5">
|
||||||
<fieldset class="fieldset">
|
<label class="block space-y-2">
|
||||||
<legend class="fieldset-legend text-base font-semibold">E-mail</legend>
|
<span class="text-sm font-semibold text-[#355947]">E-mail</span>
|
||||||
<input
|
<input
|
||||||
v-model="email"
|
v-model="email"
|
||||||
type="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"
|
placeholder="name@company.com"
|
||||||
@keydown.enter.prevent="requestCode"
|
@keydown.enter.prevent="requestCode"
|
||||||
@blur="onEmailBlur"
|
|
||||||
>
|
>
|
||||||
</fieldset>
|
</label>
|
||||||
|
|
||||||
<div class="grid gap-2 sm:grid-cols-2">
|
<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]"
|
||||||
v-if="messengerMiniAppChannel !== 'TELEGRAM'"
|
:disabled="requestCodeMutation.loading.value || !isEmailReady"
|
||||||
class="btn btn-secondary"
|
@click="requestCode"
|
||||||
:class="{ 'btn-disabled pointer-events-none': !telegramBotUrl || !isEmailReady }"
|
>
|
||||||
:disabled="pendingChannel === 'TELEGRAM' || !telegramBotUrl || !isEmailReady"
|
{{ requestCodeMutation.loading.value ? 'Отправляем код…' : 'Получить код' }}
|
||||||
@click="startMessengerLogin('TELEGRAM')"
|
</button>
|
||||||
>
|
|
||||||
{{ pendingChannel === 'TELEGRAM' ? 'Открываем Telegram…' : 'Войти через Telegram' }}
|
<div v-if="hasMessengerButtons" class="space-y-3">
|
||||||
</button>
|
<div class="flex items-center gap-3">
|
||||||
<button
|
<span class="h-px flex-1 bg-[#e2ece6]" />
|
||||||
v-if="messengerMiniAppChannel !== 'MAX'"
|
<span class="text-[11px] font-bold uppercase tracking-[0.18em] text-[#7a9386]">или войти через</span>
|
||||||
class="btn btn-accent"
|
<span class="h-px flex-1 bg-[#e2ece6]" />
|
||||||
:class="{ 'btn-disabled pointer-events-none': !maxBotUrl || !isEmailReady }"
|
</div>
|
||||||
:disabled="pendingChannel === 'MAX' || !maxBotUrl || !isEmailReady"
|
|
||||||
@click="startMessengerLogin('MAX')"
|
<div class="grid gap-3 sm:grid-cols-2">
|
||||||
>
|
<button
|
||||||
{{ pendingChannel === 'MAX' ? 'Открываем Max…' : 'Войти через Max' }}
|
v-if="canUseTelegramLogin"
|
||||||
</button>
|
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' }}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
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' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</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">
|
<div v-else class="space-y-5">
|
||||||
<p class="text-sm text-base-content/70">E-mail: {{ maskedEmail }}</p>
|
<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">
|
<label class="block space-y-2">
|
||||||
<legend class="fieldset-legend text-base font-semibold">Код</legend>
|
<span class="text-sm font-semibold text-[#355947]">Код из письма</span>
|
||||||
<input
|
<input
|
||||||
v-model="code"
|
v-model="code"
|
||||||
type="text"
|
type="text"
|
||||||
maxlength="6"
|
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"
|
placeholder="123456"
|
||||||
@keydown.enter.prevent="verifyCode"
|
@keydown.enter.prevent="verifyCode"
|
||||||
>
|
>
|
||||||
</fieldset>
|
</label>
|
||||||
|
|
||||||
<button
|
<div class="space-y-3">
|
||||||
class="btn btn-primary w-full"
|
<button
|
||||||
: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]"
|
||||||
@click="verifyCode"
|
:disabled="verifyCodeMutation.loading.value || !code.trim()"
|
||||||
>
|
@click="verifyCode"
|
||||||
{{ verifyCodeMutation.loading.value ? 'Проверяем…' : 'Войти' }}
|
>
|
||||||
</button>
|
{{ verifyCodeMutation.loading.value ? 'Проверяем…' : 'Войти' }}
|
||||||
|
</button>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
class="btn btn-ghost w-full"
|
class="btn h-12 w-full rounded-full border border-[#d7e6dc] bg-white text-[#123824] hover:border-[#bed6c7] hover:bg-[#f6fbf8]"
|
||||||
@click="step = 'request'; code = ''; feedback = ''; challengeToken = ''; maskedEmail = ''"
|
@click="returnToRequestStep"
|
||||||
>
|
>
|
||||||
Изменить e-mail
|
Изменить e-mail
|
||||||
</button>
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<p class="text-xs text-base-content/60">Код действует до {{ expiresAt }}</p>
|
<p class="text-xs text-[#7a9386]">Код действует до {{ expiresAt }}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
v-if="feedback"
|
v-if="feedback"
|
||||||
class="alert mt-2"
|
class="rounded-[24px] border px-4 py-3 text-sm leading-6"
|
||||||
:class="feedbackTone === 'success' ? 'alert-success' : 'alert-error'"
|
:class="feedbackTone === 'success'
|
||||||
|
? 'border-[#cbe9d6] bg-[#f1fbf5] text-[#1c6b45]'
|
||||||
|
: 'border-[#f1d1c7] bg-[#fff3ef] text-[#9d4426]'"
|
||||||
>
|
>
|
||||||
{{ feedback }}
|
{{ feedback }}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ import {
|
|||||||
|
|
||||||
definePageMeta({
|
definePageMeta({
|
||||||
middleware: ['manager-only'],
|
middleware: ['manager-only'],
|
||||||
|
path: '/admin/settings/messages',
|
||||||
|
alias: ['/messages'],
|
||||||
});
|
});
|
||||||
|
|
||||||
type TemplateItem = NotificationTemplatesQuery['notificationTemplates'][number];
|
type TemplateItem = NotificationTemplatesQuery['notificationTemplates'][number];
|
||||||
@@ -29,21 +31,7 @@ function channelLabel(channel: TemplateChannel['channel']) {
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<section class="space-y-6">
|
<section class="space-y-6">
|
||||||
<div class="manager-hero">
|
<h1 class="text-3xl font-extrabold text-[#0f2f20]">Сообщения</h1>
|
||||||
<p class="manager-eyebrow">Настройки</p>
|
|
||||||
<h1 class="manager-title">Сообщения</h1>
|
|
||||||
<p class="manager-copy">
|
|
||||||
Здесь менеджер видит реальные шаблоны из backend-кода и может быстро проверить, что именно получает клиент в каждом канале.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="surface-card rounded-3xl p-2">
|
|
||||||
<div class="flex flex-wrap gap-2">
|
|
||||||
<span class="rounded-full bg-[#123824] px-4 py-2 text-sm font-semibold text-white">
|
|
||||||
Сообщения
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-if="templatesQuery.loading.value" class="manager-empty-state">
|
<div v-if="templatesQuery.loading.value" class="manager-empty-state">
|
||||||
Загружаем шаблоны...
|
Загружаем шаблоны...
|
||||||
@@ -53,13 +41,12 @@ function channelLabel(channel: TemplateChannel['channel']) {
|
|||||||
Шаблонов пока нет.
|
Шаблонов пока нет.
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-else class="space-y-4">
|
<div v-else class="space-y-6">
|
||||||
<article
|
<section
|
||||||
v-for="template in templates"
|
v-for="template in templates"
|
||||||
:key="template.id"
|
: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 }}
|
{{ template.title }}
|
||||||
</h2>
|
</h2>
|
||||||
|
|
||||||
@@ -67,42 +54,55 @@ function channelLabel(channel: TemplateChannel['channel']) {
|
|||||||
<section
|
<section
|
||||||
v-for="channel in template.channels"
|
v-for="channel in template.channels"
|
||||||
:key="`${template.id}-${channel.channel}`"
|
: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]">
|
<h3 class="text-sm font-extrabold uppercase tracking-[0.14em] text-[#355947]">
|
||||||
{{ channelLabel(channel.channel) }}
|
{{ channelLabel(channel.channel) }}
|
||||||
</h3>
|
</h3>
|
||||||
|
|
||||||
<div class="mt-3 rounded-[20px] bg-white p-4">
|
<div class="mt-3 rounded-[20px] bg-[#f8fbf9] p-4">
|
||||||
<p
|
<div
|
||||||
v-if="channel.subject"
|
v-if="channel.subject"
|
||||||
class="text-xs font-semibold uppercase tracking-[0.12em] text-[#5c7b69]"
|
class="mb-4"
|
||||||
>
|
>
|
||||||
{{ channel.subject }}
|
<p class="text-xs font-semibold uppercase tracking-[0.12em] text-[#5c7b69]">
|
||||||
</p>
|
Тема:
|
||||||
|
</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
|
<p
|
||||||
v-for="line in channel.body"
|
v-for="line in channel.body"
|
||||||
:key="line"
|
:key="line"
|
||||||
:class="channel.implemented ? '' : 'text-[#6f8577]'"
|
|
||||||
>
|
>
|
||||||
{{ line }}
|
{{ line }}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="channel.buttonText" class="mt-4 space-y-1 text-sm leading-6 text-[#355947]">
|
<div v-if="channel.buttonText" class="mt-4">
|
||||||
<p class="font-semibold text-[#123824]">
|
<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 }}
|
{{ channel.buttonText }}
|
||||||
</p>
|
</a>
|
||||||
<p v-if="channel.buttonUrl" class="break-all text-xs text-[#5c7b69]">
|
<span
|
||||||
{{ channel.buttonUrl }}
|
v-else
|
||||||
</p>
|
class="inline-flex h-11 items-center rounded-full bg-[#123824] px-5 text-sm font-semibold text-white"
|
||||||
|
>
|
||||||
|
{{ channel.buttonText }}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -32,20 +32,11 @@ const currentOrderCode = computed(() => formatOrderCode(currentOrder.value?.code
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<div class="surface-card rounded-3xl px-5 py-4">
|
<UiBackHeader
|
||||||
<div class="flex flex-wrap items-center gap-3">
|
to="/orders"
|
||||||
<NuxtLink to="/orders" class="text-sm font-semibold text-[#0d854a]">
|
back-label="Назад к моим заказам"
|
||||||
← Назад к моим заказам
|
:title="`Заказ ${currentOrderCode}`"
|
||||||
</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>
|
|
||||||
|
|
||||||
<div class="space-y-4">
|
<div class="space-y-4">
|
||||||
<OrdersOrderStatusTimelineCard
|
<OrdersOrderStatusTimelineCard
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ type OrderItem = MyOrdersQuery['myOrders'][number];
|
|||||||
const allOrders = useQuery(MyOrdersDocument);
|
const allOrders = useQuery(MyOrdersDocument);
|
||||||
const search = ref('');
|
const search = ref('');
|
||||||
const statusFilter = ref<'ALL' | 'WAITING' | 'ACTIVE' | 'CLOSED'>('ALL');
|
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 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']);
|
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);
|
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 filteredOrders = computed(() => {
|
||||||
const orders = allOrders.result.value?.myOrders ?? [];
|
const orders = allOrders.result.value?.myOrders ?? [];
|
||||||
const normalizedSearch = search.value.trim().toLowerCase();
|
const normalizedSearch = search.value.trim().toLowerCase();
|
||||||
@@ -42,7 +67,7 @@ const filteredOrders = computed(() => {
|
|||||||
.toLowerCase();
|
.toLowerCase();
|
||||||
|
|
||||||
const matchSearch = !normalizedSearch || text.includes(normalizedSearch);
|
const matchSearch = !normalizedSearch || text.includes(normalizedSearch);
|
||||||
return matchSearch && matchesFilter(order);
|
return matchSearch && matchesFilter(order) && matchesDate(order);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -54,7 +79,7 @@ const {
|
|||||||
visibleItems: visibleOrders,
|
visibleItems: visibleOrders,
|
||||||
} = useIncrementalList(filteredOrders, {
|
} = useIncrementalList(filteredOrders, {
|
||||||
pageSize: 24,
|
pageSize: 24,
|
||||||
resetKeys: [search, statusFilter],
|
resetKeys: [search, statusFilter, dateFrom, dateTo],
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -66,15 +91,52 @@ const {
|
|||||||
search-placeholder="Номер заказа или товар"
|
search-placeholder="Номер заказа или товар"
|
||||||
>
|
>
|
||||||
<template #controls>
|
<template #controls>
|
||||||
<select
|
<div class="flex w-full flex-col gap-3 md:w-auto md:flex-row md:flex-wrap md:justify-end">
|
||||||
v-model="statusFilter"
|
<label class="flex items-center gap-2 rounded-full border border-[#d7e9de] bg-white px-4 py-3 text-sm font-semibold text-[#123824]">
|
||||||
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"
|
<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" />
|
||||||
<option value="ALL">Все заказы</option>
|
<path d="M6.66666 10H13.3333" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" />
|
||||||
<option value="WAITING">Ожидают подтверждения</option>
|
<path d="M8.33334 15H11.6667" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" />
|
||||||
<option value="ACTIVE">Активные</option>
|
</svg>
|
||||||
<option value="CLOSED">Закрытые</option>
|
<select
|
||||||
</select>
|
v-model="statusFilter"
|
||||||
|
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>
|
</template>
|
||||||
</UiSectionSearchHero>
|
</UiSectionSearchHero>
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import CatalogConfigurator from '~/components/catalog/CatalogConfigurator.vue';
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<CatalogConfigurator />
|
|
||||||
</template>
|
|
||||||
11
app/pages/products/[slug].vue
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import CatalogConfigurator from '~/components/catalog/CatalogConfigurator.vue';
|
||||||
|
|
||||||
|
const route = useRoute();
|
||||||
|
|
||||||
|
const productTypeSlug = computed(() => String(route.params.slug ?? ''));
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<CatalogConfigurator :product-type-slug="productTypeSlug" />
|
||||||
|
</template>
|
||||||
7
app/pages/products/index.vue
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import CatalogProductTypeList from '~/components/catalog/CatalogProductTypeList.vue';
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<CatalogProductTypeList />
|
||||||
|
</template>
|
||||||
@@ -1,138 +1,25 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { useMutation, useQuery } from '@vue/apollo-composable';
|
import { useMutation, useQuery } from '@vue/apollo-composable';
|
||||||
import {
|
import {
|
||||||
CreateMyDeliveryAddressDocument,
|
|
||||||
DeleteMyDeliveryAddressDocument,
|
DeleteMyDeliveryAddressDocument,
|
||||||
MyDeliveryAddressesDocument,
|
MyDeliveryAddressesDocument,
|
||||||
SetMyDefaultDeliveryAddressDocument,
|
SetMyDefaultDeliveryAddressDocument,
|
||||||
type MyDeliveryAddressesQuery,
|
type MyDeliveryAddressesQuery,
|
||||||
} from '~/composables/graphql/generated';
|
} from '~/composables/graphql/generated';
|
||||||
|
|
||||||
type AddressSuggestion = {
|
|
||||||
value: string;
|
|
||||||
unrestricted_value?: string;
|
|
||||||
data?: {
|
|
||||||
fias_id?: string;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
type DeliveryAddressItem = MyDeliveryAddressesQuery['myDeliveryAddresses'][number];
|
type DeliveryAddressItem = MyDeliveryAddressesQuery['myDeliveryAddresses'][number];
|
||||||
|
|
||||||
const addressFeedback = ref('');
|
const route = useRoute();
|
||||||
const addressFeedbackTone = ref<'success' | 'error'>('success');
|
const addressFeedback = ref(route.query.created === '1' ? 'Адрес сохранён.' : '');
|
||||||
|
const addressFeedbackTone = ref<'success' | 'error'>(route.query.created === '1' ? 'success' : 'error');
|
||||||
|
|
||||||
const deliveryAddressesQuery = useQuery(MyDeliveryAddressesDocument);
|
const deliveryAddressesQuery = useQuery(MyDeliveryAddressesDocument);
|
||||||
const createAddressMutation = useMutation(CreateMyDeliveryAddressDocument, { throws: 'never' });
|
|
||||||
const setDefaultAddressMutation = useMutation(SetMyDefaultDeliveryAddressDocument, { throws: 'never' });
|
const setDefaultAddressMutation = useMutation(SetMyDefaultDeliveryAddressDocument, { throws: 'never' });
|
||||||
const deleteAddressMutation = useMutation(DeleteMyDeliveryAddressDocument, { 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 addressBusyId = ref<string | null>(null);
|
||||||
const addressDropdownRef = ref<HTMLElement | null>(null);
|
|
||||||
|
|
||||||
const deliveryAddresses = computed<DeliveryAddressItem[]>(() => deliveryAddressesQuery.result.value?.myDeliveryAddresses ?? []);
|
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) {
|
async function setDefaultAddress(addressId: string) {
|
||||||
addressFeedback.value = '';
|
addressFeedback.value = '';
|
||||||
addressBusyId.value = addressId;
|
addressBusyId.value = addressId;
|
||||||
@@ -170,113 +57,78 @@ async function deleteAddress(addressId: string) {
|
|||||||
addressFeedback.value = 'Адрес удалён.';
|
addressFeedback.value = 'Адрес удалён.';
|
||||||
await deliveryAddressesQuery.refetch();
|
await deliveryAddressesQuery.refetch();
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
|
||||||
document.addEventListener('click', closeDropdownsFromOutside);
|
|
||||||
});
|
|
||||||
|
|
||||||
onBeforeUnmount(() => {
|
|
||||||
document.removeEventListener('click', closeDropdownsFromOutside);
|
|
||||||
clearAddressTimer();
|
|
||||||
});
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<section class="space-y-6">
|
<section class="space-y-6">
|
||||||
<NuxtLink to="/profile" class="link link-hover text-sm">← Назад в профиль</NuxtLink>
|
<UiBackHeader
|
||||||
<h1 class="text-3xl font-extrabold text-[#0f2f20]">Адреса доставки</h1>
|
to="/profile"
|
||||||
|
back-label="Назад в профиль"
|
||||||
<div class="surface-card rounded-3xl p-5">
|
title="Адреса доставки"
|
||||||
<p class="text-sm text-[#355947]">
|
subtitle="Выберите основной адрес для заказов или добавьте новый."
|
||||||
Добавьте адрес через DaData и выберите основной. Этот адрес будет использоваться по умолчанию в корзине.
|
>
|
||||||
</p>
|
<template #actions>
|
||||||
|
<NuxtLink
|
||||||
<fieldset class="fieldset mt-4">
|
to="/profile/addresses/new"
|
||||||
<legend class="fieldset-legend">Название адреса (необязательно)</legend>
|
class="btn rounded-full border-0 bg-[#123824] px-6 text-white hover:bg-[#0f2f20]"
|
||||||
<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"
|
|
||||||
>
|
|
||||||
</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-box bg-base-100 p-2"
|
|
||||||
>
|
>
|
||||||
<button
|
Добавить
|
||||||
v-for="item in addressSuggestions"
|
</NuxtLink>
|
||||||
:key="`${item.value}-${item.data?.fias_id || ''}`"
|
</template>
|
||||||
type="button"
|
</UiBackHeader>
|
||||||
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">
|
<div
|
||||||
{{ createAddressMutation.loading.value ? 'Добавляем…' : 'Добавить адрес' }}
|
v-if="addressFeedback"
|
||||||
</button>
|
class="rounded-[24px] border px-4 py-3 text-sm font-medium"
|
||||||
|
:class="addressFeedbackTone === 'success'
|
||||||
<div v-if="addressFeedback" class="alert mt-3" :class="addressFeedbackTone === 'success' ? 'alert-success' : 'alert-error'">
|
? 'border-[#cbe9d6] bg-[#f1fbf5] text-[#1c6b45]'
|
||||||
{{ addressFeedback }}
|
: 'border-[#f1d1c7] bg-[#fff3ef] text-[#9d4426]'"
|
||||||
</div>
|
>
|
||||||
|
{{ addressFeedback }}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="surface-card rounded-3xl p-5">
|
<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)]">
|
||||||
<h2 class="text-xl font-bold text-[#123824]">Список адресов</h2>
|
Загружаем адреса...
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="mt-4 space-y-2">
|
<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 v-if="deliveryAddressesQuery.loading.value" class="alert surface-card">Загрузка адресов...</div>
|
Пока нет адресов доставки.
|
||||||
<div v-else-if="deliveryAddresses.length === 0" class="alert surface-card">
|
</div>
|
||||||
Пока нет адресов доставки.
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<article
|
<div v-else class="space-y-4">
|
||||||
v-for="address in deliveryAddresses"
|
<article
|
||||||
:key="address.id"
|
v-for="address in deliveryAddresses"
|
||||||
class="rounded-2xl bg-[#f8fbf9] p-3 transition hover:shadow-md"
|
:key="address.id"
|
||||||
>
|
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>
|
<div class="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
|
||||||
<p class="font-semibold text-[#123824]">{{ address.label || 'Адрес доставки' }}</p>
|
<div class="min-w-0 space-y-1">
|
||||||
<p class="text-sm text-[#355947]">{{ address.unrestrictedValue || address.address }}</p>
|
<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>
|
</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>
|
||||||
|
|
||||||
<div class="mt-3 flex flex-wrap gap-2">
|
<div class="flex flex-wrap gap-2">
|
||||||
<button
|
<button
|
||||||
v-if="!address.isDefault"
|
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"
|
:disabled="addressBusyId === address.id"
|
||||||
@click="setDefaultAddress(address.id)"
|
@click="setDefaultAddress(address.id)"
|
||||||
>
|
>
|
||||||
{{ addressBusyId === address.id ? 'Сохраняем…' : 'Сделать основным' }}
|
{{ addressBusyId === address.id ? 'Сохраняем...' : 'Сделать основным' }}
|
||||||
</button>
|
</button>
|
||||||
<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"
|
:disabled="addressBusyId === address.id"
|
||||||
@click="deleteAddress(address.id)"
|
@click="deleteAddress(address.id)"
|
||||||
>
|
>
|
||||||
{{ addressBusyId === address.id ? 'Удаляем…' : 'Удалить' }}
|
{{ addressBusyId === address.id ? 'Удаляем...' : 'Удалить' }}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</div>
|
||||||
</div>
|
</article>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
186
app/pages/profile/addresses/new.vue
Normal 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>
|
||||||
@@ -94,7 +94,6 @@ watch(
|
|||||||
{ immediate: true },
|
{ immediate: true },
|
||||||
);
|
);
|
||||||
|
|
||||||
const profileUpdatedAt = computed(() => profileQuery.result.value?.myCounterpartyProfile?.updatedAt ?? null);
|
|
||||||
const profileIsComplete = computed(() => isCounterpartyProfileComplete(counterpartyForm));
|
const profileIsComplete = computed(() => isCounterpartyProfileComplete(counterpartyForm));
|
||||||
|
|
||||||
function clearPartyTimer() {
|
function clearPartyTimer() {
|
||||||
@@ -255,179 +254,210 @@ onBeforeUnmount(() => {
|
|||||||
<template>
|
<template>
|
||||||
<section class="space-y-6">
|
<section class="space-y-6">
|
||||||
<NuxtLink to="/profile" class="link link-hover text-sm">← Назад в профиль</NuxtLink>
|
<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">
|
<div class="space-y-2">
|
||||||
<p class="text-sm text-[#355947]">
|
<h1 class="text-3xl font-extrabold text-[#0f2f20]">Карточка контрагента</h1>
|
||||||
Заполните реквизиты, чтобы оформить заявки и получить полный функционал личного кабинета.
|
<p class="text-sm leading-6 text-[#466653]">
|
||||||
|
Заполните реквизиты компании, банка, подписанта и основания.
|
||||||
</p>
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="mt-4 space-y-4">
|
<div class="space-y-4">
|
||||||
<section>
|
<section class="surface-card rounded-3xl p-5 md:p-6">
|
||||||
<h2 class="mb-3 text-base font-bold">1. Контрагент (DaData)</h2>
|
<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">
|
<div ref="partyDropdownRef" class="relative">
|
||||||
<fieldset class="fieldset">
|
<label class="block space-y-2">
|
||||||
<legend class="fieldset-legend">Поиск компании</legend>
|
<span class="text-sm font-semibold text-[#355947]">Поиск компании</span>
|
||||||
<input
|
<div class="relative">
|
||||||
v-model="companySearch"
|
<input
|
||||||
type="text"
|
v-model="companySearch"
|
||||||
class="input w-full"
|
type="text"
|
||||||
placeholder="Введите название или ИНН"
|
class="input manager-field w-full pr-11"
|
||||||
@input="schedulePartySuggest"
|
placeholder="Введите название компании или ИНН"
|
||||||
@focus="partyOpen = partySuggestions.length > 0"
|
@input="schedulePartySuggest"
|
||||||
>
|
@focus="partyOpen = partySuggestions.length > 0"
|
||||||
</fieldset>
|
>
|
||||||
|
<span v-if="partyLoading" class="loading loading-spinner loading-sm absolute right-4 top-1/2 -translate-y-1/2 text-[#5c7b69]" />
|
||||||
<span v-if="partyLoading" class="loading loading-spinner loading-sm absolute right-3 top-1/2 -translate-y-1/2" />
|
</div>
|
||||||
|
</label>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
v-if="partyOpen && partySuggestions.length > 0"
|
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
|
<button
|
||||||
v-for="item in partySuggestions"
|
v-for="item in partySuggestions"
|
||||||
:key="`${item.value}-${item.data?.inn || ''}`"
|
:key="`${item.value}-${item.data?.inn || ''}`"
|
||||||
type="button"
|
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)"
|
@click="applyPartySuggestion(item)"
|
||||||
>
|
>
|
||||||
<span>
|
<span class="block text-sm font-semibold text-[#123824]">{{ item.value }}</span>
|
||||||
<span class="block text-sm font-semibold">{{ item.value }}</span>
|
<span class="mt-1 block text-xs text-[#5c7b69]">
|
||||||
<span class="block text-xs opacity-70">ИНН: {{ item.data?.inn || '—' }} <span v-if="item.data?.kpp">• КПП: {{ item.data.kpp }}</span></span>
|
ИНН: {{ item.data?.inn || '—' }}<span v-if="item.data?.kpp"> • КПП: {{ item.data.kpp }}</span>
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<fieldset class="fieldset">
|
<div class="grid gap-4 md:grid-cols-2">
|
||||||
<legend class="fieldset-legend">Краткое наименование</legend>
|
<label class="block space-y-2">
|
||||||
<input v-model="counterpartyForm.companyName" type="text" class="input w-full" >
|
<span class="text-sm font-semibold text-[#355947]">Краткое наименование</span>
|
||||||
</fieldset>
|
<input v-model="counterpartyForm.companyName" type="text" class="input manager-field w-full">
|
||||||
|
</label>
|
||||||
|
|
||||||
<fieldset class="fieldset">
|
<label class="block space-y-2">
|
||||||
<legend class="fieldset-legend">Полное наименование</legend>
|
<span class="text-sm font-semibold text-[#355947]">Полное наименование</span>
|
||||||
<input v-model="counterpartyForm.companyFullName" type="text" class="input w-full" >
|
<input v-model="counterpartyForm.companyFullName" type="text" class="input manager-field w-full">
|
||||||
</fieldset>
|
</label>
|
||||||
|
|
||||||
<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>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<fieldset class="fieldset">
|
<div class="grid gap-4 md:grid-cols-3">
|
||||||
<legend class="fieldset-legend">Юридический адрес</legend>
|
<label class="block space-y-2">
|
||||||
<textarea v-model="counterpartyForm.legalAddress" class="textarea min-h-24 w-full" />
|
<span class="text-sm font-semibold text-[#355947]">ИНН</span>
|
||||||
</fieldset>
|
<input v-model="counterpartyForm.inn" type="text" class="input manager-field w-full">
|
||||||
</section>
|
</label>
|
||||||
|
|
||||||
<div class="divider my-0" />
|
<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>
|
||||||
|
|
||||||
<section>
|
<label class="block space-y-2">
|
||||||
<h2 class="mb-3 text-base font-bold">2. Банк (DaData)</h2>
|
<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>
|
||||||
|
|
||||||
|
<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">
|
<div ref="bankDropdownRef" class="relative">
|
||||||
<fieldset class="fieldset">
|
<label class="block space-y-2">
|
||||||
<legend class="fieldset-legend">Поиск банка</legend>
|
<span class="text-sm font-semibold text-[#355947]">Поиск банка</span>
|
||||||
<input
|
<div class="relative">
|
||||||
v-model="bankSearch"
|
<input
|
||||||
type="text"
|
v-model="bankSearch"
|
||||||
class="input w-full"
|
type="text"
|
||||||
placeholder="Введите название банка"
|
class="input manager-field w-full pr-11"
|
||||||
@input="scheduleBankSuggest"
|
placeholder="Введите название банка"
|
||||||
@focus="bankOpen = bankSuggestions.length > 0"
|
@input="scheduleBankSuggest"
|
||||||
>
|
@focus="bankOpen = bankSuggestions.length > 0"
|
||||||
</fieldset>
|
>
|
||||||
|
<span v-if="bankLoading" class="loading loading-spinner loading-sm absolute right-4 top-1/2 -translate-y-1/2 text-[#5c7b69]" />
|
||||||
<span v-if="bankLoading" class="loading loading-spinner loading-sm absolute right-3 top-1/2 -translate-y-1/2" />
|
</div>
|
||||||
|
</label>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
v-if="bankOpen && bankSuggestions.length > 0"
|
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
|
<button
|
||||||
v-for="item in bankSuggestions"
|
v-for="item in bankSuggestions"
|
||||||
:key="`${item.value}-${item.data?.bic || ''}`"
|
:key="`${item.value}-${item.data?.bic || ''}`"
|
||||||
type="button"
|
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)"
|
@click="applyBankSuggestion(item)"
|
||||||
>
|
>
|
||||||
<span>
|
<span class="block text-sm font-semibold text-[#123824]">{{ item.value }}</span>
|
||||||
<span class="block text-sm font-semibold">{{ item.value }}</span>
|
<span class="mt-1 block text-xs text-[#5c7b69]">БИК: {{ item.data?.bic || '—' }}</span>
|
||||||
<span class="block text-xs opacity-70">БИК: {{ item.data?.bic || '—' }}</span>
|
|
||||||
</span>
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<fieldset class="fieldset">
|
<div class="grid gap-4 md:grid-cols-2">
|
||||||
<legend class="fieldset-legend">Банк</legend>
|
<label class="block space-y-2">
|
||||||
<input v-model="counterpartyForm.bankName" type="text" class="input w-full" >
|
<span class="text-sm font-semibold text-[#355947]">Банк</span>
|
||||||
</fieldset>
|
<input v-model="counterpartyForm.bankName" type="text" class="input manager-field w-full">
|
||||||
|
</label>
|
||||||
|
|
||||||
<div class="grid gap-3 sm:grid-cols-2">
|
<label class="block space-y-2">
|
||||||
<fieldset class="fieldset">
|
<span class="text-sm font-semibold text-[#355947]">БИК</span>
|
||||||
<legend class="fieldset-legend">БИК</legend>
|
<input v-model="counterpartyForm.bik" type="text" class="input manager-field w-full">
|
||||||
<input v-model="counterpartyForm.bik" type="text" class="input w-full" >
|
</label>
|
||||||
</fieldset>
|
|
||||||
|
|
||||||
<fieldset class="fieldset">
|
|
||||||
<legend class="fieldset-legend">Корр. счет</legend>
|
|
||||||
<input v-model="counterpartyForm.correspondentAccount" type="text" class="input w-full" >
|
|
||||||
</fieldset>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<fieldset class="fieldset">
|
<div class="grid gap-4 md:grid-cols-2">
|
||||||
<legend class="fieldset-legend">Расчетный счет</legend>
|
<label class="block space-y-2">
|
||||||
<input v-model="counterpartyForm.checkingAccount" type="text" class="input w-full" >
|
<span class="text-sm font-semibold text-[#355947]">Корреспондентский счет</span>
|
||||||
</fieldset>
|
<input v-model="counterpartyForm.correspondentAccount" type="text" class="input manager-field w-full">
|
||||||
</section>
|
</label>
|
||||||
|
|
||||||
<div class="divider my-0" />
|
<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>
|
||||||
|
|
||||||
<section>
|
<section class="surface-card rounded-3xl p-5 md:p-6">
|
||||||
<h2 class="mb-3 text-base font-bold">3. Подписант и основание</h2>
|
<div class="mb-5">
|
||||||
|
<h2 class="text-xl font-black tracking-[-0.02em] text-[#123824]">Подписанты и основания</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
<fieldset class="fieldset">
|
<div class="grid gap-4">
|
||||||
<legend class="fieldset-legend">ФИО подписанта</legend>
|
<div class="grid gap-4 md:grid-cols-2">
|
||||||
<input v-model="counterpartyForm.signerFullName" type="text" class="input w-full" placeholder="Иванов Иван Иванович" >
|
<label class="block space-y-2">
|
||||||
</fieldset>
|
<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">
|
<label class="block space-y-2">
|
||||||
<legend class="fieldset-legend">Должность</legend>
|
<span class="text-sm font-semibold text-[#355947]">Должность</span>
|
||||||
<input v-model="counterpartyForm.signerPosition" type="text" class="input w-full" placeholder="Генеральный директор" >
|
<input
|
||||||
</fieldset>
|
v-model="counterpartyForm.signerPosition"
|
||||||
|
type="text"
|
||||||
|
class="input manager-field w-full"
|
||||||
|
placeholder="Генеральный директор"
|
||||||
|
>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
<fieldset class="fieldset">
|
<label class="block space-y-2">
|
||||||
<legend class="fieldset-legend">Основание полномочий</legend>
|
<span class="text-sm font-semibold text-[#355947]">Основание полномочий</span>
|
||||||
<textarea
|
<textarea
|
||||||
v-model="counterpartyForm.signerBasis"
|
v-model="counterpartyForm.signerBasis"
|
||||||
class="textarea min-h-24 w-full"
|
class="textarea manager-field min-h-28 w-full"
|
||||||
placeholder="Действует на основании Устава"
|
placeholder="Действует на основании Устава"
|
||||||
/>
|
/>
|
||||||
</fieldset>
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
<button class="btn btn-primary mt-4 w-full" :disabled="saveCounterpartyMutation.loading.value || !profileIsComplete" @click="saveCounterpartyProfile">
|
<div class="mt-6 border-t border-[#edf2ee] pt-5">
|
||||||
{{ saveCounterpartyMutation.loading.value ? 'Сохраняем…' : 'Сохранить' }}
|
<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>
|
</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<p v-if="profileUpdatedAt" class="mt-2 text-xs opacity-70">Обновлено: {{ new Date(profileUpdatedAt).toLocaleString() }}</p>
|
<div v-if="profileFeedback" class="alert" :class="profileFeedbackTone === 'success' ? 'alert-success' : 'alert-error'">
|
||||||
</section>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-if="profileFeedback" class="alert mt-4" :class="profileFeedbackTone === 'success' ? 'alert-success' : 'alert-error'">
|
|
||||||
{{ profileFeedback }}
|
{{ profileFeedback }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -28,8 +28,6 @@ type MessengerItem = {
|
|||||||
type MessengerOption = {
|
type MessengerOption = {
|
||||||
channel: MessengerChannel;
|
channel: MessengerChannel;
|
||||||
label: string;
|
label: string;
|
||||||
title: string;
|
|
||||||
description: string;
|
|
||||||
buttonClass: string;
|
buttonClass: string;
|
||||||
iconClass: string;
|
iconClass: string;
|
||||||
unavailableText: string;
|
unavailableText: string;
|
||||||
@@ -58,8 +56,6 @@ const messengerOptions: MessengerOption[] = [
|
|||||||
{
|
{
|
||||||
channel: 'TELEGRAM',
|
channel: 'TELEGRAM',
|
||||||
label: 'Telegram',
|
label: 'Telegram',
|
||||||
title: 'Подключить Telegram',
|
|
||||||
description: 'Получайте статусы заказов и сервисные уведомления в Telegram.',
|
|
||||||
buttonClass: 'bg-[#1a9c63] text-white hover:bg-[#148553]',
|
buttonClass: 'bg-[#1a9c63] text-white hover:bg-[#148553]',
|
||||||
iconClass: 'bg-[#123824] text-white',
|
iconClass: 'bg-[#123824] text-white',
|
||||||
unavailableText: 'Telegram пока не настроен в окружении фронта.',
|
unavailableText: 'Telegram пока не настроен в окружении фронта.',
|
||||||
@@ -67,8 +63,6 @@ const messengerOptions: MessengerOption[] = [
|
|||||||
{
|
{
|
||||||
channel: 'MAX',
|
channel: 'MAX',
|
||||||
label: 'MAX',
|
label: 'MAX',
|
||||||
title: 'Подключить MAX',
|
|
||||||
description: 'Открывает MAX-бота и привязывает аккаунт к личному кабинету.',
|
|
||||||
buttonClass: 'bg-[#2b7fff] text-white hover:bg-[#1d6df1]',
|
buttonClass: 'bg-[#2b7fff] text-white hover:bg-[#1d6df1]',
|
||||||
iconClass: 'bg-[#2b7fff] text-white',
|
iconClass: 'bg-[#2b7fff] text-white',
|
||||||
unavailableText: 'MAX пока не настроен в окружении фронта.',
|
unavailableText: 'MAX пока не настроен в окружении фронта.',
|
||||||
@@ -140,14 +134,12 @@ async function removeConnection(connectionId: string) {
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<section class="space-y-6">
|
<section class="space-y-6">
|
||||||
<NuxtLink to="/profile" class="link link-hover text-sm">← Назад в профиль</NuxtLink>
|
<UiBackHeader
|
||||||
|
to="/profile"
|
||||||
<div class="space-y-2">
|
back-label="Назад в профиль"
|
||||||
<h1 class="text-3xl font-extrabold text-[#0f2f20]">Уведомления</h1>
|
title="Уведомления"
|
||||||
<p class="max-w-3xl text-sm leading-6 text-[#466653]">
|
subtitle="Подключите мессенджер, чтобы получать уведомления по заказам."
|
||||||
Подключите удобные мессенджеры, чтобы получать статусы заказов и важные уведомления без лишних переходов в кабинет.
|
/>
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
<div
|
||||||
v-if="feedback"
|
v-if="feedback"
|
||||||
@@ -160,43 +152,82 @@ async function removeConnection(connectionId: string) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
v-if="activeConnections.length === 0"
|
v-if="activeConnections.length > 0"
|
||||||
class="rounded-[32px] bg-[#edf3ee] p-6 md:p-8"
|
class="space-y-4"
|
||||||
>
|
>
|
||||||
<div class="space-y-3">
|
<article
|
||||||
<h2 class="text-2xl font-black tracking-[-0.03em] text-[#123824]">Подключите мессенджеры</h2>
|
v-for="{ option, connection } in activeConnections"
|
||||||
<p class="max-w-3xl text-sm leading-6 text-[#557562]">
|
:key="connection!.id"
|
||||||
Вы можете подключить любой из мессенджеров ниже. После подключения уведомления о заказах и важных действиях будут приходить прямо туда.
|
class="rounded-[28px] bg-white px-5 py-4 shadow-[0_18px_38px_rgba(18,56,36,0.08)]"
|
||||||
</p>
|
>
|
||||||
</div>
|
<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 class="mt-6 grid gap-4 md:grid-cols-2">
|
<div v-if="messengerConnectionAvatarSrc(connection)" class="avatar">
|
||||||
<button
|
<div class="h-14 w-14 rounded-[20px]">
|
||||||
v-for="option in messengerOptions"
|
<img :src="messengerConnectionAvatarSrc(connection)" :alt="messengerConnectionName(connection)">
|
||||||
:key="option.channel"
|
</div>
|
||||||
class="flex min-h-[120px] flex-col items-start 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="space-y-2">
|
|
||||||
<div class="inline-flex h-11 w-11 items-center justify-center rounded-2xl bg-white/18 text-sm font-black text-white">
|
|
||||||
{{ option.channel === 'TELEGRAM' ? 'TG' : 'MX' }}
|
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div
|
||||||
<p class="text-lg font-black tracking-[-0.03em]">{{ option.title }}</p>
|
v-else
|
||||||
<p class="mt-1 text-sm text-white/82">{{ option.description }}</p>
|
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>
|
||||||
</div>
|
</div>
|
||||||
<p class="text-sm font-semibold text-white/90">
|
|
||||||
{{ pendingChannel === option.channel ? `Открываем ${option.label}...` : `Перейти в ${option.label}` }}
|
|
||||||
</p>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mt-4 space-y-2">
|
<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>
|
||||||
|
|
||||||
|
<p
|
||||||
|
v-else
|
||||||
|
class="text-sm leading-6 text-[#557562]"
|
||||||
|
>
|
||||||
|
Пока ничего не подключено.
|
||||||
|
</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
|
<p
|
||||||
v-for="option in messengerOptions.filter((item) => !connectUrl(item.channel))"
|
v-for="option in availableOptions.filter((item) => !connectUrl(item.channel))"
|
||||||
:key="`${option.channel}-hint`"
|
:key="`${option.channel}-hint`"
|
||||||
class="text-sm text-[#8b5a49]"
|
class="text-sm text-[#8b5a49]"
|
||||||
>
|
>
|
||||||
@@ -204,107 +235,5 @@ async function removeConnection(connectionId: string) {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<template v-else>
|
|
||||||
<div class="rounded-[32px] bg-[#edf3ee] p-6 md:p-8">
|
|
||||||
<div class="space-y-3">
|
|
||||||
<h2 class="text-2xl font-black tracking-[-0.03em] text-[#123824]">Подключенные аккаунты</h2>
|
|
||||||
<p class="text-sm leading-6 text-[#557562]">
|
|
||||||
Здесь показаны активные мессенджеры, привязанные к вашему кабинету.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mt-6 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="text-[11px] font-semibold uppercase tracking-[0.18em] text-[#6a8a76]">{{ option.label }}</p>
|
|
||||||
<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>
|
|
||||||
|
|
||||||
<div
|
|
||||||
v-if="availableOptions.length > 0"
|
|
||||||
class="rounded-[32px] bg-[#edf3ee] p-6 md:p-8"
|
|
||||||
>
|
|
||||||
<div class="space-y-3">
|
|
||||||
<h2 class="text-2xl font-black tracking-[-0.03em] text-[#123824]">Можно подключить еще</h2>
|
|
||||||
<p class="text-sm leading-6 text-[#557562]">
|
|
||||||
Добавьте второй канал, чтобы не потерять уведомления, если один мессенджер недоступен.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mt-6 grid gap-4 md:grid-cols-2">
|
|
||||||
<button
|
|
||||||
v-for="option in availableOptions"
|
|
||||||
:key="option.channel"
|
|
||||||
class="flex min-h-[112px] flex-col items-start 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>
|
|
||||||
<p class="text-lg font-black tracking-[-0.03em]">{{ option.title }}</p>
|
|
||||||
<p class="mt-2 text-sm text-white/82">{{ option.description }}</p>
|
|
||||||
</div>
|
|
||||||
<p class="text-sm font-semibold text-white/90">
|
|
||||||
{{ pendingChannel === option.channel ? `Открываем ${option.label}...` : `Подключить ${option.label}` }}
|
|
||||||
</p>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mt-4 space-y-2">
|
|
||||||
<p
|
|
||||||
v-for="option in availableOptions.filter((item) => !connectUrl(item.channel))"
|
|
||||||
:key="`${option.channel}-connected-hint`"
|
|
||||||
class="text-sm text-[#8b5a49]"
|
|
||||||
>
|
|
||||||
{{ option.unavailableText }}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
v-else
|
|
||||||
class="rounded-[32px] bg-[linear-gradient(135deg,#123824_0%,#1a5635_100%)] p-6 text-white md:p-8"
|
|
||||||
>
|
|
||||||
<p class="text-[11px] font-semibold uppercase tracking-[0.18em] text-white/65">Готово</p>
|
|
||||||
<h2 class="mt-3 text-2xl font-black tracking-[-0.03em]">Оба канала подключены</h2>
|
|
||||||
<p class="mt-2 max-w-3xl text-sm leading-6 text-white/78">
|
|
||||||
Теперь важные уведомления будут доступны и в Telegram, и в MAX. Если захотите сменить аккаунт, удалите текущий и подключите новый.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</section>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
117
app/pages/settings-sync.vue
Normal 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>
|
||||||
1
docs/public/diagrams/architecture-overview.svg
Normal file
|
After Width: | Height: | Size: 25 KiB |
1
docs/public/diagrams/component-map.svg
Normal file
|
After Width: | Height: | Size: 36 KiB |
1
docs/public/diagrams/infrastructure-topology.svg
Normal file
|
After Width: | Height: | Size: 44 KiB |
1
docs/public/prototypes/bonus-cabinet.svg
Normal file
|
After Width: | Height: | Size: 6.0 KiB |
1
docs/public/prototypes/bonus-manager.svg
Normal file
|
After Width: | Height: | Size: 6.1 KiB |
1
docs/public/prototypes/cart.svg
Normal file
|
After Width: | Height: | Size: 6.2 KiB |
1
docs/public/prototypes/catalog-grid.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="1440" height="900" viewBox="0 0 1440 900" fill="none"><rect width="1440" height="900" fill="#f4f4f4" /><rect x="24" y="24" width="1392" height="852" rx="28" fill="#ffffff" stroke="#d5d5d5" stroke-width="1.5" /><rect x="24" y="24" width="1392" height="56" rx="28" fill="#fafafa" stroke="#d5d5d5" stroke-width="1.5" /><rect x="24" y="52" width="1392" height="28" rx="0" fill="#fafafa" stroke="#fafafa" stroke-width="1.5" /><circle cx="58" cy="52" r="7" fill="#b7b7b7" /><circle cx="82" cy="52" r="7" fill="#d2d2d2" /><circle cx="106" cy="52" r="7" fill="#d4d4d4" /><text x="136" y="58" font-family=""Times New Roman", Times, serif" font-size="17" font-weight="700" fill="#181818" text-anchor="start">Каталог продукции</text><rect x="820" y="36" width="104" height="34" rx="17" fill="#181818" stroke="#181818" stroke-width="1.5" /><text x="872" y="58" font-family=""Times New Roman", Times, serif" font-size="13" font-weight="700" fill="#ffffff" text-anchor="middle">Каталог</text><rect x="936" y="36" width="134" height="34" rx="17" fill="#f7f7f7" stroke="#d5d5d5" stroke-width="1.5" /><text x="1003" y="58" font-family=""Times New Roman", Times, serif" font-size="13" font-weight="700" fill="#545454" text-anchor="middle">Мои заказы</text><rect x="1082" y="36" width="104" height="34" rx="17" fill="#f7f7f7" stroke="#d5d5d5" stroke-width="1.5" /><text x="1134" y="58" font-family=""Times New Roman", Times, serif" font-size="13" font-weight="700" fill="#545454" text-anchor="middle">Корзина</text><rect x="1198" y="36" width="104" height="34" rx="17" fill="#f7f7f7" stroke="#d5d5d5" stroke-width="1.5" /><text x="1250" y="58" font-family=""Times New Roman", Times, serif" font-size="13" font-weight="700" fill="#545454" text-anchor="middle">Профиль</text><text x="72" y="132" font-family=""Times New Roman", Times, serif" font-size="32" font-weight="800" fill="#181818" text-anchor="start">Каталог</text><rect x="72" y="168" width="600" height="54" rx="27" fill="#ffffff" stroke="#d5d5d5" stroke-width="1.5" /><text x="98" y="201" font-family=""Times New Roman", Times, serif" font-size="15" font-weight="500" fill="#777777" text-anchor="start">Поиск по типу товара</text><rect x="72" y="260" width="396" height="202" rx="26" fill="#ffffff" stroke="#d5d5d5" stroke-width="1.5" /><rect x="92" y="280" width="356" height="118" rx="22" fill="#f0f0f0" stroke="#d5d5d5" stroke-width="1.5" /><text x="100" y="430" font-family=""Times New Roman", Times, serif" font-size="22" font-weight="800" fill="#181818" text-anchor="start">Стретч-пленка</text><rect x="504" y="260" width="396" height="202" rx="26" fill="#ffffff" stroke="#d5d5d5" stroke-width="1.5" /><rect x="524" y="280" width="356" height="118" rx="22" fill="#f0f0f0" stroke="#d5d5d5" stroke-width="1.5" /><text x="532" y="430" font-family=""Times New Roman", Times, serif" font-size="22" font-weight="800" fill="#181818" text-anchor="start">Скотч</text><rect x="936" y="260" width="396" height="202" rx="26" fill="#ffffff" stroke="#d5d5d5" stroke-width="1.5" /><rect x="956" y="280" width="356" height="118" rx="22" fill="#f0f0f0" stroke="#d5d5d5" stroke-width="1.5" /><text x="964" y="430" font-family=""Times New Roman", Times, serif" font-size="22" font-weight="800" fill="#181818" text-anchor="start">Пакеты</text><rect x="72" y="498" width="396" height="202" rx="26" fill="#ffffff" stroke="#d5d5d5" stroke-width="1.5" /><rect x="92" y="518" width="356" height="118" rx="22" fill="#f0f0f0" stroke="#d5d5d5" stroke-width="1.5" /><text x="100" y="668" font-family=""Times New Roman", Times, serif" font-size="22" font-weight="800" fill="#181818" text-anchor="start">Пленка ПВД</text><rect x="504" y="498" width="396" height="202" rx="26" fill="#ffffff" stroke="#d5d5d5" stroke-width="1.5" /><rect x="524" y="518" width="356" height="118" rx="22" fill="#f0f0f0" stroke="#d5d5d5" stroke-width="1.5" /><text x="532" y="668" font-family=""Times New Roman", Times, serif" font-size="22" font-weight="800" fill="#181818" text-anchor="start">Воздушно-пузырьковая</text><rect x="936" y="498" width="396" height="202" rx="26" fill="#ffffff" stroke="#d5d5d5" stroke-width="1.5" /><rect x="956" y="518" width="356" height="118" rx="22" fill="#f0f0f0" stroke="#d5d5d5" stroke-width="1.5" /><text x="964" y="668" font-family=""Times New Roman", Times, serif" font-size="22" font-weight="800" fill="#181818" text-anchor="start">Картон</text></svg>
|
||||||
|
After Width: | Height: | Size: 4.6 KiB |
1
docs/public/prototypes/catalog-settings.svg
Normal file
|
After Width: | Height: | Size: 6.4 KiB |
1
docs/public/prototypes/client-card.svg
Normal file
|
After Width: | Height: | Size: 5.7 KiB |
1
docs/public/prototypes/client-list.svg
Normal file
|
After Width: | Height: | Size: 5.3 KiB |
1
docs/public/prototypes/client-order.svg
Normal file
|
After Width: | Height: | Size: 5.7 KiB |
1
docs/public/prototypes/dashboard.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="1440" height="900" viewBox="0 0 1440 900" fill="none"><rect width="1440" height="900" fill="#f4f4f4" /><rect x="24" y="24" width="1392" height="852" rx="28" fill="#ffffff" stroke="#d5d5d5" stroke-width="1.5" /><rect x="24" y="24" width="1392" height="56" rx="28" fill="#fafafa" stroke="#d5d5d5" stroke-width="1.5" /><rect x="24" y="52" width="1392" height="28" rx="0" fill="#fafafa" stroke="#fafafa" stroke-width="1.5" /><circle cx="58" cy="52" r="7" fill="#b7b7b7" /><circle cx="82" cy="52" r="7" fill="#d2d2d2" /><circle cx="106" cy="52" r="7" fill="#d4d4d4" /><text x="136" y="58" font-family=""Times New Roman", Times, serif" font-size="17" font-weight="700" fill="#181818" text-anchor="start">Главная страница клиента</text><rect x="820" y="36" width="104" height="34" rx="17" fill="#181818" stroke="#181818" stroke-width="1.5" /><text x="872" y="58" font-family=""Times New Roman", Times, serif" font-size="13" font-weight="700" fill="#ffffff" text-anchor="middle">Каталог</text><rect x="936" y="36" width="134" height="34" rx="17" fill="#f7f7f7" stroke="#d5d5d5" stroke-width="1.5" /><text x="1003" y="58" font-family=""Times New Roman", Times, serif" font-size="13" font-weight="700" fill="#545454" text-anchor="middle">Мои заказы</text><rect x="1082" y="36" width="104" height="34" rx="17" fill="#f7f7f7" stroke="#d5d5d5" stroke-width="1.5" /><text x="1134" y="58" font-family=""Times New Roman", Times, serif" font-size="13" font-weight="700" fill="#545454" text-anchor="middle">Корзина</text><rect x="1198" y="36" width="104" height="34" rx="17" fill="#f7f7f7" stroke="#d5d5d5" stroke-width="1.5" /><text x="1250" y="58" font-family=""Times New Roman", Times, serif" font-size="13" font-weight="700" fill="#545454" text-anchor="middle">Профиль</text><text x="72" y="132" font-family=""Times New Roman", Times, serif" font-size="32" font-weight="800" fill="#181818" text-anchor="start">Каталог</text><rect x="72" y="168" width="600" height="54" rx="27" fill="#ffffff" stroke="#d5d5d5" stroke-width="1.5" /><text x="98" y="201" font-family=""Times New Roman", Times, serif" font-size="15" font-weight="500" fill="#777777" text-anchor="start">Поиск по типу товара</text><rect x="72" y="260" width="396" height="202" rx="26" fill="#ffffff" stroke="#d5d5d5" stroke-width="1.5" /><rect x="92" y="280" width="356" height="118" rx="22" fill="#f0f0f0" stroke="#d5d5d5" stroke-width="1.5" /><text x="100" y="430" font-family=""Times New Roman", Times, serif" font-size="22" font-weight="800" fill="#181818" text-anchor="start">Стретч-пленка</text><rect x="504" y="260" width="396" height="202" rx="26" fill="#ffffff" stroke="#d5d5d5" stroke-width="1.5" /><rect x="524" y="280" width="356" height="118" rx="22" fill="#f0f0f0" stroke="#d5d5d5" stroke-width="1.5" /><text x="532" y="430" font-family=""Times New Roman", Times, serif" font-size="22" font-weight="800" fill="#181818" text-anchor="start">Скотч</text><rect x="936" y="260" width="396" height="202" rx="26" fill="#ffffff" stroke="#d5d5d5" stroke-width="1.5" /><rect x="956" y="280" width="356" height="118" rx="22" fill="#f0f0f0" stroke="#d5d5d5" stroke-width="1.5" /><text x="964" y="430" font-family=""Times New Roman", Times, serif" font-size="22" font-weight="800" fill="#181818" text-anchor="start">Пакеты</text><rect x="72" y="498" width="396" height="202" rx="26" fill="#ffffff" stroke="#d5d5d5" stroke-width="1.5" /><rect x="92" y="518" width="356" height="118" rx="22" fill="#f0f0f0" stroke="#d5d5d5" stroke-width="1.5" /><text x="100" y="668" font-family=""Times New Roman", Times, serif" font-size="22" font-weight="800" fill="#181818" text-anchor="start">Пленка ПВД</text><rect x="504" y="498" width="396" height="202" rx="26" fill="#ffffff" stroke="#d5d5d5" stroke-width="1.5" /><rect x="524" y="518" width="356" height="118" rx="22" fill="#f0f0f0" stroke="#d5d5d5" stroke-width="1.5" /><text x="532" y="668" font-family=""Times New Roman", Times, serif" font-size="22" font-weight="800" fill="#181818" text-anchor="start">Воздушно-пузырьковая</text><rect x="936" y="498" width="396" height="202" rx="26" fill="#ffffff" stroke="#d5d5d5" stroke-width="1.5" /><rect x="956" y="518" width="356" height="118" rx="22" fill="#f0f0f0" stroke="#d5d5d5" stroke-width="1.5" /><text x="964" y="668" font-family=""Times New Roman", Times, serif" font-size="22" font-weight="800" fill="#181818" text-anchor="start">Картон</text></svg>
|
||||||
|
After Width: | Height: | Size: 4.6 KiB |
1
docs/public/prototypes/login.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="1440" height="760" viewBox="0 0 1440 760" fill="none"><rect width="1440" height="760" fill="#f4f4f4" /><rect x="24" y="24" width="1392" height="712" rx="28" fill="#ffffff" stroke="#d5d5d5" stroke-width="1.5" /><rect x="24" y="24" width="1392" height="56" rx="28" fill="#fafafa" stroke="#d5d5d5" stroke-width="1.5" /><rect x="24" y="52" width="1392" height="28" rx="0" fill="#fafafa" stroke="#fafafa" stroke-width="1.5" /><circle cx="58" cy="52" r="7" fill="#b7b7b7" /><circle cx="82" cy="52" r="7" fill="#d2d2d2" /><circle cx="106" cy="52" r="7" fill="#d4d4d4" /><text x="136" y="58" font-family=""Times New Roman", Times, serif" font-size="17" font-weight="700" fill="#181818" text-anchor="start">Логин</text><rect x="450" y="130" width="540" height="520" rx="32" fill="#ffffff" stroke="#d5d5d5" stroke-width="1.5" /><text x="720" y="196" font-family=""Times New Roman", Times, serif" font-size="14" font-weight="800" fill="#545454" text-anchor="middle">Фрегат</text><text x="720" y="244" font-family=""Times New Roman", Times, serif" font-size="36" font-weight="800" fill="#181818" text-anchor="middle">Вход</text><text x="510" y="292" font-family=""Times New Roman", Times, serif" font-size="13" font-weight="700" fill="#545454" text-anchor="start">E-mail</text><rect x="510" y="304" width="420" height="48" rx="16" fill="#ffffff" stroke="#d5d5d5" stroke-width="1.5" /><rect x="510" y="386" width="420" height="44" rx="22" fill="#181818" stroke="#181818" stroke-width="1.5" /><text x="720" y="414" font-family=""Times New Roman", Times, serif" font-size="14" font-weight="700" fill="#ffffff" text-anchor="middle">Получить код</text><line x1="510" y1="464" x2="930" y2="464" stroke="#d5d5d5" stroke-width="1.5" /><text x="720" y="492" font-family=""Times New Roman", Times, serif" font-size="13" font-weight="700" fill="#545454" text-anchor="middle">или войти через</text><rect x="510" y="526" width="196" height="44" rx="22" fill="#e8e8e8" stroke="#d5d5d5" stroke-width="1.5" /><text x="608" y="554" font-family=""Times New Roman", Times, serif" font-size="14" font-weight="700" fill="#545454" text-anchor="middle">Telegram</text><rect x="734" y="526" width="196" height="44" rx="22" fill="#e8e8e8" stroke="#d5d5d5" stroke-width="1.5" /><text x="832" y="554" font-family=""Times New Roman", Times, serif" font-size="14" font-weight="700" fill="#545454" text-anchor="middle">Max</text></svg>
|
||||||
|
After Width: | Height: | Size: 2.5 KiB |
1
docs/public/prototypes/manager-order.svg
Normal file
|
After Width: | Height: | Size: 5.8 KiB |
1
docs/public/prototypes/manager-orders.svg
Normal file
|
After Width: | Height: | Size: 7.0 KiB |
1
docs/public/prototypes/product-card.svg
Normal file
|
After Width: | Height: | Size: 11 KiB |
1
docs/public/prototypes/profile.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="1440" height="820" viewBox="0 0 1440 820" fill="none"><rect width="1440" height="820" fill="#f4f4f4" /><rect x="24" y="24" width="1392" height="772" rx="28" fill="#ffffff" stroke="#d5d5d5" stroke-width="1.5" /><rect x="24" y="24" width="1392" height="56" rx="28" fill="#fafafa" stroke="#d5d5d5" stroke-width="1.5" /><rect x="24" y="52" width="1392" height="28" rx="0" fill="#fafafa" stroke="#fafafa" stroke-width="1.5" /><circle cx="58" cy="52" r="7" fill="#b7b7b7" /><circle cx="82" cy="52" r="7" fill="#d2d2d2" /><circle cx="106" cy="52" r="7" fill="#d4d4d4" /><text x="136" y="58" font-family=""Times New Roman", Times, serif" font-size="17" font-weight="700" fill="#181818" text-anchor="start">Профиль клиента</text><rect x="820" y="36" width="104" height="34" rx="17" fill="#f7f7f7" stroke="#d5d5d5" stroke-width="1.5" /><text x="872" y="58" font-family=""Times New Roman", Times, serif" font-size="13" font-weight="700" fill="#545454" text-anchor="middle">Каталог</text><rect x="936" y="36" width="134" height="34" rx="17" fill="#f7f7f7" stroke="#d5d5d5" stroke-width="1.5" /><text x="1003" y="58" font-family=""Times New Roman", Times, serif" font-size="13" font-weight="700" fill="#545454" text-anchor="middle">Мои заказы</text><rect x="1082" y="36" width="104" height="34" rx="17" fill="#f7f7f7" stroke="#d5d5d5" stroke-width="1.5" /><text x="1134" y="58" font-family=""Times New Roman", Times, serif" font-size="13" font-weight="700" fill="#545454" text-anchor="middle">Корзина</text><rect x="1198" y="36" width="104" height="34" rx="17" fill="#181818" stroke="#181818" stroke-width="1.5" /><text x="1250" y="58" font-family=""Times New Roman", Times, serif" font-size="13" font-weight="700" fill="#ffffff" text-anchor="middle">Профиль</text><text x="72" y="132" font-family=""Times New Roman", Times, serif" font-size="32" font-weight="800" fill="#181818" text-anchor="start">Профиль</text><rect x="72" y="210" width="388" height="104" rx="24" fill="#ffffff" stroke="#d5d5d5" stroke-width="1.5" /><circle cx="116" cy="262" r="24" fill="#f0f0f0" /><text x="154" y="258" font-family=""Times New Roman", Times, serif" font-size="17" font-weight="800" fill="#181818" text-anchor="start">Карточка контрагента</text><text x="154" y="282" font-family=""Times New Roman", Times, serif" font-size="14" font-weight="500" fill="#545454" text-anchor="start">Реквизиты и ИНН</text><rect x="484" y="210" width="388" height="104" rx="24" fill="#ffffff" stroke="#d5d5d5" stroke-width="1.5" /><circle cx="528" cy="262" r="24" fill="#f0f0f0" /><text x="566" y="258" font-family=""Times New Roman", Times, serif" font-size="17" font-weight="800" fill="#181818" text-anchor="start">Уведомления</text><text x="566" y="282" font-family=""Times New Roman", Times, serif" font-size="14" font-weight="500" fill="#545454" text-anchor="start">Telegram и Max</text><rect x="896" y="210" width="388" height="104" rx="24" fill="#ffffff" stroke="#d5d5d5" stroke-width="1.5" /><circle cx="940" cy="262" r="24" fill="#f0f0f0" /><text x="978" y="258" font-family=""Times New Roman", Times, serif" font-size="17" font-weight="800" fill="#181818" text-anchor="start">Адреса доставки</text><text x="978" y="282" font-family=""Times New Roman", Times, serif" font-size="14" font-weight="500" fill="#545454" text-anchor="start">Список адресов</text></svg>
|
||||||
|
After Width: | Height: | Size: 3.5 KiB |
1
docs/public/prototypes/sync-settings.svg
Normal file
|
After Width: | Height: | Size: 7.4 KiB |
35
docs/scripts/build-typst-tz.mjs
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
import { mkdir } from 'node:fs/promises';
|
||||||
|
import { dirname, join, relative } from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import { spawnSync } from 'node:child_process';
|
||||||
|
|
||||||
|
// Usage: node docs/scripts/build-typst-tz.mjs
|
||||||
|
// Source: docs/tz-fregat.typ
|
||||||
|
// Output: docs/export/tz-fregat.pdf
|
||||||
|
|
||||||
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||||
|
const docsDir = join(__dirname, '..');
|
||||||
|
const sourceFile = join(docsDir, 'tz-fregat.typ');
|
||||||
|
const exportDir = join(docsDir, 'export');
|
||||||
|
const pdfFile = join(exportDir, 'tz-fregat.pdf');
|
||||||
|
|
||||||
|
await mkdir(exportDir, { recursive: true });
|
||||||
|
|
||||||
|
const compileResult = spawnSync('typst', [
|
||||||
|
'compile',
|
||||||
|
'--root',
|
||||||
|
'.',
|
||||||
|
relative(docsDir, sourceFile),
|
||||||
|
relative(docsDir, pdfFile),
|
||||||
|
], {
|
||||||
|
cwd: docsDir,
|
||||||
|
encoding: 'utf8',
|
||||||
|
});
|
||||||
|
|
||||||
|
if (compileResult.status !== 0) {
|
||||||
|
process.stderr.write(compileResult.stderr);
|
||||||
|
process.stderr.write(compileResult.stdout);
|
||||||
|
throw new Error('Typst PDF build failed');
|
||||||
|
}
|
||||||
|
|
||||||
|
process.stdout.write(`Generated ${relative(docsDir, pdfFile)}\n`);
|
||||||
493
docs/scripts/generate-prototypes.mjs
Normal file
@@ -0,0 +1,493 @@
|
|||||||
|
import { mkdirSync, writeFileSync } from 'node:fs';
|
||||||
|
import { dirname, join } from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
|
||||||
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||||
|
const outDir = join(__dirname, '..', 'public', 'prototypes');
|
||||||
|
|
||||||
|
const W = 1440;
|
||||||
|
const C = {
|
||||||
|
page: '#f4f4f4',
|
||||||
|
paper: '#ffffff',
|
||||||
|
panel: '#ffffff',
|
||||||
|
soft: '#f7f7f7',
|
||||||
|
line: '#d5d5d5',
|
||||||
|
dark: '#181818',
|
||||||
|
mid: '#545454',
|
||||||
|
muted: '#777777',
|
||||||
|
fill: '#e8e8e8',
|
||||||
|
fill2: '#f0f0f0',
|
||||||
|
};
|
||||||
|
const font = '"Times New Roman", Times, serif';
|
||||||
|
|
||||||
|
function esc(value) {
|
||||||
|
return String(value)
|
||||||
|
.replaceAll('&', '&')
|
||||||
|
.replaceAll('<', '<')
|
||||||
|
.replaceAll('>', '>')
|
||||||
|
.replaceAll('"', '"');
|
||||||
|
}
|
||||||
|
|
||||||
|
function attrs(values) {
|
||||||
|
return Object.entries(values)
|
||||||
|
.filter(([, value]) => value !== undefined && value !== null)
|
||||||
|
.map(([key, value]) => `${key}="${esc(value)}"`)
|
||||||
|
.join(' ');
|
||||||
|
}
|
||||||
|
|
||||||
|
function rect(x, y, width, height, options = {}) {
|
||||||
|
const {
|
||||||
|
rx = 18,
|
||||||
|
fill = C.panel,
|
||||||
|
stroke = C.line,
|
||||||
|
sw = 1.5,
|
||||||
|
} = options;
|
||||||
|
|
||||||
|
return `<rect ${attrs({ x, y, width, height, rx, fill, stroke, 'stroke-width': sw })} />`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function line(x1, y1, x2, y2, options = {}) {
|
||||||
|
return `<line ${attrs({
|
||||||
|
x1,
|
||||||
|
y1,
|
||||||
|
x2,
|
||||||
|
y2,
|
||||||
|
stroke: options.stroke ?? C.line,
|
||||||
|
'stroke-width': options.sw ?? 1.5,
|
||||||
|
})} />`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function text(x, y, value, options = {}) {
|
||||||
|
const {
|
||||||
|
size = 16,
|
||||||
|
weight = 500,
|
||||||
|
fill = C.dark,
|
||||||
|
anchor = 'start',
|
||||||
|
} = options;
|
||||||
|
|
||||||
|
return `<text ${attrs({
|
||||||
|
x,
|
||||||
|
y,
|
||||||
|
'font-family': font,
|
||||||
|
'font-size': size,
|
||||||
|
'font-weight': weight,
|
||||||
|
fill,
|
||||||
|
'text-anchor': anchor,
|
||||||
|
})}>${esc(value)}</text>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function circle(cx, cy, r, options = {}) {
|
||||||
|
return `<circle ${attrs({
|
||||||
|
cx,
|
||||||
|
cy,
|
||||||
|
r,
|
||||||
|
fill: options.fill ?? C.fill,
|
||||||
|
stroke: options.stroke,
|
||||||
|
'stroke-width': options.sw,
|
||||||
|
})} />`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function chip(x, y, value, options = {}) {
|
||||||
|
const width = options.width ?? Math.max(76, value.length * 9 + 28);
|
||||||
|
const selected = options.selected ?? false;
|
||||||
|
return [
|
||||||
|
rect(x, y, width, 34, {
|
||||||
|
rx: 17,
|
||||||
|
fill: selected ? C.dark : C.soft,
|
||||||
|
stroke: selected ? C.dark : C.line,
|
||||||
|
}),
|
||||||
|
text(x + width / 2, y + 22, value, {
|
||||||
|
size: 13,
|
||||||
|
weight: 700,
|
||||||
|
fill: selected ? '#ffffff' : C.mid,
|
||||||
|
anchor: 'middle',
|
||||||
|
}),
|
||||||
|
].join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function input(x, y, width, label) {
|
||||||
|
return [
|
||||||
|
text(x, y - 12, label, { size: 13, weight: 700, fill: C.mid }),
|
||||||
|
rect(x, y, width, 48, { rx: 16, fill: C.paper }),
|
||||||
|
].join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function button(x, y, width, label, options = {}) {
|
||||||
|
const dark = options.dark ?? false;
|
||||||
|
return [
|
||||||
|
rect(x, y, width, 44, {
|
||||||
|
rx: 22,
|
||||||
|
fill: dark ? C.dark : C.fill,
|
||||||
|
stroke: dark ? C.dark : C.line,
|
||||||
|
}),
|
||||||
|
text(x + width / 2, y + 28, label, {
|
||||||
|
size: 14,
|
||||||
|
weight: 700,
|
||||||
|
fill: dark ? '#ffffff' : C.mid,
|
||||||
|
anchor: 'middle',
|
||||||
|
}),
|
||||||
|
].join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function topShell(label, nav = [], active = '') {
|
||||||
|
const parts = [
|
||||||
|
rect(24, 24, 1392, 56, { rx: 28, fill: '#fafafa' }),
|
||||||
|
rect(24, 52, 1392, 28, { rx: 0, fill: '#fafafa', stroke: '#fafafa' }),
|
||||||
|
circle(58, 52, 7, { fill: '#b7b7b7' }),
|
||||||
|
circle(82, 52, 7, { fill: '#d2d2d2' }),
|
||||||
|
circle(106, 52, 7, { fill: '#d4d4d4' }),
|
||||||
|
text(136, 58, label, { size: 17, weight: 700 }),
|
||||||
|
];
|
||||||
|
|
||||||
|
let x = 820;
|
||||||
|
for (const item of nav) {
|
||||||
|
parts.push(chip(x, 36, item, { width: Math.max(88, item.length * 10 + 34), selected: item === active }));
|
||||||
|
x += Math.max(88, item.length * 10 + 34) + 12;
|
||||||
|
}
|
||||||
|
|
||||||
|
return parts.join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function page(label, height, body, options = {}) {
|
||||||
|
const nav = options.nav ?? ['Каталог', 'Мои заказы', 'Корзина', 'Профиль'];
|
||||||
|
const active = options.active ?? '';
|
||||||
|
return `<svg xmlns="http://www.w3.org/2000/svg" width="${W}" height="${height}" viewBox="0 0 ${W} ${height}" fill="none"><rect width="${W}" height="${height}" fill="${C.page}" />${rect(24, 24, 1392, height - 48, { rx: 28, fill: C.paper })}${topShell(label, nav, active)}${body.join('')}</svg>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function titleBlock(title, y = 132, x = 72) {
|
||||||
|
return text(x, y, title, { size: 32, weight: 800 });
|
||||||
|
}
|
||||||
|
|
||||||
|
function searchHero(title, placeholder, controls = []) {
|
||||||
|
const parts = [
|
||||||
|
titleBlock(title),
|
||||||
|
rect(72, 168, 600, 54, { rx: 27, fill: C.paper }),
|
||||||
|
text(98, 201, placeholder, { size: 15, weight: 500, fill: C.muted }),
|
||||||
|
];
|
||||||
|
let x = 700;
|
||||||
|
for (const control of controls) {
|
||||||
|
parts.push(chip(x, 178, control, { width: Math.max(120, control.length * 9 + 34), selected: control === controls[0] }));
|
||||||
|
x += Math.max(120, control.length * 9 + 34) + 12;
|
||||||
|
}
|
||||||
|
return parts.join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function catalogCards(y = 260) {
|
||||||
|
const cards = ['Стретч-пленка', 'Скотч', 'Пакеты', 'Пленка ПВД', 'Воздушно-пузырьковая', 'Картон'];
|
||||||
|
const parts = [];
|
||||||
|
cards.forEach((name, index) => {
|
||||||
|
const col = index % 3;
|
||||||
|
const row = Math.floor(index / 3);
|
||||||
|
const x = 72 + col * 432;
|
||||||
|
const yy = y + row * 238;
|
||||||
|
parts.push(rect(x, yy, 396, 202, { rx: 26 }));
|
||||||
|
parts.push(rect(x + 20, yy + 20, 356, 118, { rx: 22, fill: C.fill2 }));
|
||||||
|
parts.push(text(x + 28, yy + 170, name, { size: 22, weight: 800 }));
|
||||||
|
});
|
||||||
|
return parts.join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function orderRows(x, y, width, rows, options = {}) {
|
||||||
|
const parts = [];
|
||||||
|
const rowH = options.rowH ?? 72;
|
||||||
|
rows.forEach((row, index) => {
|
||||||
|
const yy = y + index * (rowH + 12);
|
||||||
|
parts.push(rect(x, yy, width, rowH, { rx: 20, fill: index % 2 ? '#fbfbfb' : C.paper }));
|
||||||
|
parts.push(text(x + 24, yy + 30, row[0], { size: 17, weight: 800 }));
|
||||||
|
parts.push(text(x + 24, yy + 54, row[1], { size: 14, weight: 500, fill: C.mid }));
|
||||||
|
if (row[2]) {
|
||||||
|
parts.push(chip(x + width - 210, yy + 18, row[2], { width: 150 }));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return parts.join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function cardGrid(x, y, labels, columns = 3) {
|
||||||
|
const parts = [];
|
||||||
|
labels.forEach((label, index) => {
|
||||||
|
const col = index % columns;
|
||||||
|
const row = Math.floor(index / columns);
|
||||||
|
const w = columns === 4 ? 300 : 388;
|
||||||
|
const xx = x + col * (w + 24);
|
||||||
|
const yy = y + row * 128;
|
||||||
|
parts.push(rect(xx, yy, w, 104, { rx: 24 }));
|
||||||
|
parts.push(circle(xx + 44, yy + 52, 24, { fill: C.fill2 }));
|
||||||
|
parts.push(text(xx + 82, yy + 48, label[0], { size: 17, weight: 800 }));
|
||||||
|
if (label[1]) {
|
||||||
|
parts.push(text(xx + 82, yy + 72, label[1], { size: 14, weight: 500, fill: C.mid }));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return parts.join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
const pages = {
|
||||||
|
'dashboard.svg': page('Главная страница клиента', 900, [
|
||||||
|
searchHero('Каталог', 'Поиск по типу товара', []),
|
||||||
|
catalogCards(260),
|
||||||
|
], { active: 'Каталог' }),
|
||||||
|
|
||||||
|
'catalog-grid.svg': page('Каталог продукции', 900, [
|
||||||
|
searchHero('Каталог', 'Поиск по типу товара', []),
|
||||||
|
catalogCards(260),
|
||||||
|
], { active: 'Каталог' }),
|
||||||
|
|
||||||
|
'product-card.svg': page('Карточка товара', 1040, [
|
||||||
|
button(72, 116, 110, 'Назад'),
|
||||||
|
titleBlock('Алюминиевый скотч', 166),
|
||||||
|
rect(72, 220, 400, 330, { rx: 32 }),
|
||||||
|
rect(102, 252, 340, 228, { rx: 26, fill: C.fill2 }),
|
||||||
|
text(272, 510, 'Изображение товара', { size: 16, weight: 700, fill: C.mid, anchor: 'middle' }),
|
||||||
|
rect(504, 220, 536, 330, { rx: 32 }),
|
||||||
|
text(536, 258, 'Параметры', { size: 22, weight: 800 }),
|
||||||
|
text(536, 304, 'Ширина', { size: 14, weight: 700, fill: C.mid }),
|
||||||
|
chip(536, 320, '48 мм', { selected: true }),
|
||||||
|
chip(628, 320, '75 мм'),
|
||||||
|
text(780, 304, 'Длина', { size: 14, weight: 700, fill: C.mid }),
|
||||||
|
chip(780, 320, '25 м', { selected: true }),
|
||||||
|
chip(862, 320, '50 м'),
|
||||||
|
chip(944, 320, '100 м'),
|
||||||
|
text(536, 386, 'Толщина', { size: 14, weight: 700, fill: C.mid }),
|
||||||
|
chip(536, 402, '43 мкм', { selected: true }),
|
||||||
|
chip(638, 402, '45 мкм'),
|
||||||
|
text(780, 386, 'Втулка', { size: 14, weight: 700, fill: C.mid }),
|
||||||
|
chip(780, 402, 'Стандарт', { selected: true, width: 112 }),
|
||||||
|
chip(904, 402, 'Логотип', { width: 104 }),
|
||||||
|
text(536, 468, 'Цвет', { size: 14, weight: 700, fill: C.mid }),
|
||||||
|
chip(536, 484, 'Серебристый', { selected: true, width: 126 }),
|
||||||
|
text(780, 468, 'Надпись', { size: 14, weight: 700, fill: C.mid }),
|
||||||
|
chip(780, 484, 'Без надписи', { selected: true, width: 136 }),
|
||||||
|
rect(1072, 220, 296, 330, { rx: 32 }),
|
||||||
|
text(1100, 262, 'FRG-ALU-48-50', { size: 20, weight: 800 }),
|
||||||
|
text(1100, 310, 'В наличии', { size: 16, weight: 700, fill: C.mid }),
|
||||||
|
text(1100, 342, '2 140', { size: 38, weight: 800 }),
|
||||||
|
button(1100, 394, 220, 'В корзину', { dark: true }),
|
||||||
|
text(72, 624, 'Доступные варианты', { size: 24, weight: 800 }),
|
||||||
|
rect(72, 652, 1296, 258, { rx: 24 }),
|
||||||
|
text(104, 698, 'SKU', { size: 14, weight: 800, fill: C.mid }),
|
||||||
|
text(312, 698, 'Ширина', { size: 14, weight: 800, fill: C.mid }),
|
||||||
|
text(470, 698, 'Длина', { size: 14, weight: 800, fill: C.mid }),
|
||||||
|
text(620, 698, 'Толщина', { size: 14, weight: 800, fill: C.mid }),
|
||||||
|
text(790, 698, 'Втулка', { size: 14, weight: 800, fill: C.mid }),
|
||||||
|
text(970, 698, 'Остаток', { size: 14, weight: 800, fill: C.mid }),
|
||||||
|
text(1160, 698, 'Действие', { size: 14, weight: 800, fill: C.mid }),
|
||||||
|
line(96, 716, 1344, 716),
|
||||||
|
orderRows(96, 738, 1248, [
|
||||||
|
['FRG-ALU-48-50', '48 мм · 50 м · 43 мкм · стандарт', 'В корзину'],
|
||||||
|
['FRG-ALU-75-50', '75 мм · 50 м · 45 мкм · стандарт', 'В корзину'],
|
||||||
|
], { rowH: 64 }),
|
||||||
|
], { active: 'Каталог' }),
|
||||||
|
|
||||||
|
'cart.svg': page('Корзина', 900, [
|
||||||
|
titleBlock('Корзина'),
|
||||||
|
rect(72, 168, 1296, 68, { rx: 24, fill: C.soft }),
|
||||||
|
text(102, 210, 'Заполните карточку контрагента перед оформлением заявки', { size: 16, weight: 700, fill: C.mid }),
|
||||||
|
text(72, 284, 'Состав заказа', { size: 24, weight: 800 }),
|
||||||
|
rect(72, 312, 760, 330, { rx: 28 }),
|
||||||
|
orderRows(104, 344, 696, [
|
||||||
|
['Стретч-пленка', '48 мм · 50 м · 43 мкм', '2 шт'],
|
||||||
|
['Скотч упаковочный', '75 мм · 66 м', '4 шт'],
|
||||||
|
['Пакет ПВД', '300 x 400 мм', '1 шт'],
|
||||||
|
], { rowH: 72 }),
|
||||||
|
rect(872, 312, 496, 330, { rx: 28 }),
|
||||||
|
text(904, 354, 'Информация о доставке', { size: 22, weight: 800 }),
|
||||||
|
chip(904, 390, 'Склад клиента', { selected: true, width: 160 }),
|
||||||
|
chip(904, 444, 'Новый адрес', { width: 148 }),
|
||||||
|
input(904, 532, 380, 'Комментарий'),
|
||||||
|
button(904, 610, 260, 'Оформить заявку', { dark: true }),
|
||||||
|
], { active: 'Корзина' }),
|
||||||
|
|
||||||
|
'client-order.svg': page('Карточка заказа клиента', 860, [
|
||||||
|
button(72, 116, 190, 'Назад к моим заказам'),
|
||||||
|
titleBlock('Заказ FRG-1024', 170),
|
||||||
|
rect(72, 220, 1296, 118, { rx: 28 }),
|
||||||
|
text(104, 262, 'Статус заказа', { size: 16, weight: 700, fill: C.mid }),
|
||||||
|
chip(104, 282, 'Предложение', { selected: true, width: 148 }),
|
||||||
|
chip(282, 282, 'Подтвердить', { width: 150 }),
|
||||||
|
chip(456, 282, 'Отклонить', { width: 130 }),
|
||||||
|
text(72, 394, 'Состав заказа', { size: 24, weight: 800 }),
|
||||||
|
rect(72, 426, 1296, 260, { rx: 28 }),
|
||||||
|
orderRows(104, 458, 1232, [
|
||||||
|
['Стретч-пленка', '48 мм · 50 м · количество 2', 'Цена задана'],
|
||||||
|
['Скотч упаковочный', '75 мм · 66 м · количество 4', 'Цена задана'],
|
||||||
|
], { rowH: 76 }),
|
||||||
|
rect(72, 720, 1296, 80, { rx: 24, fill: C.soft }),
|
||||||
|
text(104, 754, 'Доставка', { size: 17, weight: 800 }),
|
||||||
|
text(260, 754, 'Адрес, срок и стоимость доставки показываются в одной строке', { size: 15, weight: 500, fill: C.mid }),
|
||||||
|
], { active: 'Мои заказы' }),
|
||||||
|
|
||||||
|
'login.svg': page('Логин', 760, [
|
||||||
|
rect(450, 130, 540, 520, { rx: 32 }),
|
||||||
|
text(720, 196, 'Фрегат', { size: 14, weight: 800, fill: C.mid, anchor: 'middle' }),
|
||||||
|
text(720, 244, 'Вход', { size: 36, weight: 800, anchor: 'middle' }),
|
||||||
|
input(510, 304, 420, 'E-mail'),
|
||||||
|
button(510, 386, 420, 'Получить код', { dark: true }),
|
||||||
|
line(510, 464, 930, 464),
|
||||||
|
text(720, 492, 'или войти через', { size: 13, weight: 700, fill: C.mid, anchor: 'middle' }),
|
||||||
|
button(510, 526, 196, 'Telegram'),
|
||||||
|
button(734, 526, 196, 'Max'),
|
||||||
|
], { nav: [] }),
|
||||||
|
|
||||||
|
'bonus-cabinet.svg': page('Бонусный кабинет', 940, [
|
||||||
|
titleBlock('Чёрный кабинет бонусной программы'),
|
||||||
|
rect(72, 178, 820, 250, { rx: 30 }),
|
||||||
|
text(112, 230, 'Аккаунт', { size: 15, weight: 700, fill: C.mid }),
|
||||||
|
text(112, 280, 'Клиент бонусной программы', { size: 32, weight: 800, fill: C.dark }),
|
||||||
|
text(112, 354, 'Доступный баланс', { size: 15, weight: 700, fill: C.mid }),
|
||||||
|
text(112, 398, '12 400', { size: 48, weight: 800, fill: C.dark }),
|
||||||
|
rect(928, 178, 440, 250, { rx: 30 }),
|
||||||
|
text(968, 230, 'Вывод бонусов', { size: 20, weight: 800, fill: C.dark }),
|
||||||
|
input(968, 292, 320, 'Сумма заявки'),
|
||||||
|
button(968, 370, 280, 'Подать заявку', { dark: false }),
|
||||||
|
rect(72, 472, 620, 300, { rx: 30 }),
|
||||||
|
text(112, 522, 'История бонусов', { size: 24, weight: 800, fill: C.dark }),
|
||||||
|
orderRows(112, 552, 540, [
|
||||||
|
['+1 500', 'Начисление по заказу', ''],
|
||||||
|
['+900', 'Реферальное начисление', ''],
|
||||||
|
], { rowH: 68 }),
|
||||||
|
rect(748, 472, 620, 300, { rx: 30 }),
|
||||||
|
text(788, 522, 'Вознаграждения', { size: 24, weight: 800, fill: C.dark }),
|
||||||
|
button(788, 566, 170, 'Ozon 3000'),
|
||||||
|
button(980, 566, 210, 'Wildberries 4000'),
|
||||||
|
button(788, 634, 190, 'М.Видео 5000'),
|
||||||
|
], { active: 'Профиль' }),
|
||||||
|
|
||||||
|
'client-list.svg': page('Клиенты', 900, [
|
||||||
|
searchHero('Клиенты', 'Имя, компания или email', ['Пригласить']),
|
||||||
|
cardGrid(72, 270, [
|
||||||
|
['Иван Петров', 'ООО Альфа'],
|
||||||
|
['Мария Соколова', 'ИП Соколова'],
|
||||||
|
['Дмитрий Иванов', 'ООО Север'],
|
||||||
|
['Анна Смирнова', 'ООО Вектор'],
|
||||||
|
['Павел Морозов', 'Завод Мир'],
|
||||||
|
['Елена Орлова', 'ТД Орлова'],
|
||||||
|
], 3),
|
||||||
|
], { nav: ['Заказы', 'Бонусы', 'Настройки'], active: 'Заказы' }),
|
||||||
|
|
||||||
|
'client-card.svg': page('Карточка клиента', 880, [
|
||||||
|
button(72, 116, 170, 'Назад к клиентам'),
|
||||||
|
titleBlock('Клиент Иван Петров', 170),
|
||||||
|
cardGrid(72, 224, [
|
||||||
|
['Email', 'client@company.ru'],
|
||||||
|
['Telegram', 'Подключен'],
|
||||||
|
['Компания', 'ООО Альфа'],
|
||||||
|
['ИНН', '7700000000'],
|
||||||
|
], 4),
|
||||||
|
text(72, 500, 'Заказы пользователя', { size: 24, weight: 800 }),
|
||||||
|
rect(72, 532, 1296, 240, { rx: 28 }),
|
||||||
|
orderRows(104, 564, 1232, [
|
||||||
|
['FRG-1024', 'Стретч-пленка · Москва', 'В работе'],
|
||||||
|
['FRG-1017', 'Скотч · Санкт-Петербург', 'Завершен'],
|
||||||
|
], { rowH: 72 }),
|
||||||
|
], { nav: ['Заказы', 'Бонусы', 'Настройки'], active: 'Заказы' }),
|
||||||
|
|
||||||
|
'manager-order.svg': page('Обработка заявки', 900, [
|
||||||
|
button(72, 116, 170, 'Назад к заказам'),
|
||||||
|
titleBlock('Заказ FRG-1024', 170),
|
||||||
|
rect(72, 220, 1296, 118, { rx: 28 }),
|
||||||
|
text(104, 262, 'Статус заказа', { size: 16, weight: 700, fill: C.mid }),
|
||||||
|
chip(104, 282, 'В обработке', { selected: true, width: 148 }),
|
||||||
|
chip(282, 282, 'Предложение', { width: 150 }),
|
||||||
|
rect(72, 382, 920, 300, { rx: 28 }),
|
||||||
|
text(104, 426, 'Состав заказа', { size: 24, weight: 800 }),
|
||||||
|
orderRows(104, 460, 856, [
|
||||||
|
['Стретч-пленка', 'Количество 2 · цена редактируется', 'Цена'],
|
||||||
|
['Скотч упаковочный', 'Количество 4 · цена редактируется', 'Цена'],
|
||||||
|
], { rowH: 76 }),
|
||||||
|
rect(1028, 382, 340, 300, { rx: 28 }),
|
||||||
|
text(1060, 426, 'Условия', { size: 24, weight: 800 }),
|
||||||
|
input(1060, 484, 250, 'Срок доставки'),
|
||||||
|
input(1060, 578, 250, 'Стоимость доставки'),
|
||||||
|
button(1060, 650, 230, 'Сохранить', { dark: true }),
|
||||||
|
], { nav: ['Заказы', 'Бонусы', 'Настройки'], active: 'Заказы' }),
|
||||||
|
|
||||||
|
'manager-orders.svg': page('Заказы менеджера', 920, [
|
||||||
|
searchHero('Заказы', 'Номер заказа, клиент, адрес или товар', ['Список', 'Календарь']),
|
||||||
|
chip(72, 250, 'Все', { selected: true, width: 88 }),
|
||||||
|
chip(174, 250, 'Заявки', { width: 112 }),
|
||||||
|
chip(300, 250, 'Предложения', { width: 150 }),
|
||||||
|
chip(464, 250, 'В работе', { width: 126 }),
|
||||||
|
chip(604, 250, 'Закрытые', { width: 126 }),
|
||||||
|
rect(72, 320, 1296, 430, { rx: 30 }),
|
||||||
|
orderRows(104, 356, 1232, [
|
||||||
|
['FRG-1024', 'Иван Петров · Стретч-пленка · Москва', 'Заявка'],
|
||||||
|
['FRG-1025', 'Мария Соколова · Скотч · Казань', 'Предложение'],
|
||||||
|
['FRG-1026', 'Дмитрий Иванов · Пакеты · СПб', 'В работе'],
|
||||||
|
['FRG-1027', 'Анна Смирнова · Пленка ПВД · Москва', 'Закрыт'],
|
||||||
|
], { rowH: 76 }),
|
||||||
|
], { nav: ['Заказы', 'Бонусы', 'Настройки'], active: 'Заказы' }),
|
||||||
|
|
||||||
|
'catalog-settings.svg': page('Настройки каталога', 980, [
|
||||||
|
titleBlock('Каталог'),
|
||||||
|
rect(72, 184, 1296, 104, { rx: 28 }),
|
||||||
|
text(104, 226, 'Стретч-пленка', { size: 22, weight: 800 }),
|
||||||
|
text(104, 256, '6 параметров, 3 кастомные возможности', { size: 15, weight: 500, fill: C.mid }),
|
||||||
|
rect(72, 318, 1296, 446, { rx: 28 }),
|
||||||
|
text(104, 362, 'Кастомные возможности', { size: 22, weight: 800 }),
|
||||||
|
chip(104, 390, 'Любая длина', { selected: true, width: 140 }),
|
||||||
|
chip(262, 390, 'Логотип на втулке', { width: 190 }),
|
||||||
|
chip(470, 390, 'Нанесение надписи', { width: 200 }),
|
||||||
|
text(104, 478, 'Диапазон длины', { size: 18, weight: 800 }),
|
||||||
|
input(104, 520, 240, 'Мин. длина, м'),
|
||||||
|
input(378, 520, 240, 'Макс. длина, м'),
|
||||||
|
input(652, 520, 240, 'Шаг, м'),
|
||||||
|
text(104, 638, 'Параметры', { size: 18, weight: 800 }),
|
||||||
|
chip(104, 664, 'Ширина', { width: 110 }),
|
||||||
|
chip(232, 664, 'Длина', { width: 100 }),
|
||||||
|
chip(350, 664, 'Толщина', { width: 120 }),
|
||||||
|
chip(488, 664, 'Втулка', { width: 108 }),
|
||||||
|
chip(614, 664, 'Цвет', { width: 96 }),
|
||||||
|
chip(728, 664, 'Надпись', { width: 120 }),
|
||||||
|
button(1100, 804, 190, 'Сохранить', { dark: true }),
|
||||||
|
], { nav: ['Заказы', 'Бонусы', 'Настройки'], active: 'Настройки' }),
|
||||||
|
|
||||||
|
'sync-settings.svg': page('Настройки синхронизации', 900, [
|
||||||
|
titleBlock('1С'),
|
||||||
|
text(72, 168, 'Статус загрузки файлов обмена', { size: 16, weight: 500, fill: C.mid }),
|
||||||
|
cardGrid(72, 230, [
|
||||||
|
['counterparties_snapshot', 'Контрагенты'],
|
||||||
|
['catalog_snapshot', 'Каталог и остатки'],
|
||||||
|
['balances_snapshot', 'Задолженность клиентов'],
|
||||||
|
['orders_snapshot', 'Заказы клиентов'],
|
||||||
|
], 4),
|
||||||
|
rect(72, 450, 1296, 250, { rx: 28 }),
|
||||||
|
text(104, 494, 'Последние загрузки', { size: 24, weight: 800 }),
|
||||||
|
orderRows(104, 530, 1232, [
|
||||||
|
['Контрагенты', 'Загружены реквизиты и признаки доступа', 'Работает'],
|
||||||
|
['Каталог и остатки', 'Загружено 2 418 записей · последний run сегодня', 'Работает'],
|
||||||
|
['Задолженность клиентов', 'Баланс по клиентам с личным кабинетом', 'Работает'],
|
||||||
|
['Заказы клиентов', 'Статусы заказов за рабочий период', 'Работает'],
|
||||||
|
], { rowH: 62 }),
|
||||||
|
], { nav: ['Заказы', 'Бонусы', 'Настройки'], active: 'Настройки' }),
|
||||||
|
|
||||||
|
'profile.svg': page('Профиль клиента', 820, [
|
||||||
|
titleBlock('Профиль'),
|
||||||
|
cardGrid(72, 210, [
|
||||||
|
['Карточка контрагента', 'Реквизиты и ИНН'],
|
||||||
|
['Уведомления', 'Telegram и Max'],
|
||||||
|
['Адреса доставки', 'Список адресов'],
|
||||||
|
], 3),
|
||||||
|
], { active: 'Профиль' }),
|
||||||
|
|
||||||
|
'bonus-manager.svg': page('Бонусная система менеджера', 920, [
|
||||||
|
searchHero('Бонусы', 'Клиент, связанный клиент или email', ['Добавить']),
|
||||||
|
chip(72, 250, 'Балансы', { selected: true, width: 120 }),
|
||||||
|
chip(208, 250, 'Заявки', { width: 110 }),
|
||||||
|
chip(334, 250, 'Награды', { width: 116 }),
|
||||||
|
cardGrid(72, 320, [
|
||||||
|
['Иван Петров', '12 400 ₽'],
|
||||||
|
['Мария Соколова', '8 250 ₽'],
|
||||||
|
['Дмитрий Иванов', '5 100 ₽'],
|
||||||
|
['Анна Смирнова', '2 900 ₽'],
|
||||||
|
], 4),
|
||||||
|
rect(72, 610, 1296, 170, { rx: 28 }),
|
||||||
|
text(104, 654, 'Заявки на выплату', { size: 24, weight: 800 }),
|
||||||
|
orderRows(104, 686, 1232, [
|
||||||
|
['WD-01A23F', 'Иван Петров · на проверке', '12 000 ₽'],
|
||||||
|
], { rowH: 68 }),
|
||||||
|
], { nav: ['Заказы', 'Бонусы', 'Настройки'], active: 'Бонусы' }),
|
||||||
|
};
|
||||||
|
|
||||||
|
mkdirSync(outDir, { recursive: true });
|
||||||
|
for (const [fileName, content] of Object.entries(pages)) {
|
||||||
|
writeFileSync(join(outDir, fileName), `${content}\n`, 'utf8');
|
||||||
|
}
|
||||||
2752
docs/tz-fregat.typ
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
mutation CreateBonusProgramLink($userId: ID!) {
|
||||||
|
createBonusProgramLink(userId: $userId) {
|
||||||
|
userId
|
||||||
|
token
|
||||||
|
url
|
||||||
|
expiresAt
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,6 +10,7 @@ query ClientProducts {
|
|||||||
thicknessMicron
|
thicknessMicron
|
||||||
sleeveBrand
|
sleeveBrand
|
||||||
quantityPerBox
|
quantityPerBox
|
||||||
|
tags
|
||||||
isCustomizable
|
isCustomizable
|
||||||
availableInWarehouses {
|
availableInWarehouses {
|
||||||
availableQty
|
availableQty
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
query CatalogProductTypeSettings {
|
||||||
|
catalogProductTypeSettings {
|
||||||
|
productType
|
||||||
|
showQuantityPerBox
|
||||||
|
allowCustomLength
|
||||||
|
customLengthMinM
|
||||||
|
customLengthMaxM
|
||||||
|
customLengthStepM
|
||||||
|
allowCustomSleeveBrand
|
||||||
|
allowCustomLabel
|
||||||
|
widthOptionsMm
|
||||||
|
lengthOptionsM
|
||||||
|
thicknessOptionsMicron
|
||||||
|
sleeveOptions
|
||||||
|
colorOptions
|
||||||
|
labelOptions
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
query IntegrationSyncDashboard {
|
||||||
|
integrationSyncDashboard {
|
||||||
|
generatedAt
|
||||||
|
lastActivityAt
|
||||||
|
totalOrders
|
||||||
|
totalProducts
|
||||||
|
totalClients
|
||||||
|
items {
|
||||||
|
id
|
||||||
|
title
|
||||||
|
description
|
||||||
|
source
|
||||||
|
syncedCount
|
||||||
|
lastSyncedAt
|
||||||
|
status
|
||||||
|
note
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -193,6 +193,33 @@ type NotificationTemplate {
|
|||||||
channels: [NotificationTemplateChannel!]!
|
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 {
|
type Warehouse {
|
||||||
id: ID!
|
id: ID!
|
||||||
code: String!
|
code: String!
|
||||||
@@ -215,11 +242,29 @@ type Product {
|
|||||||
thicknessMicron: Int
|
thicknessMicron: Int
|
||||||
sleeveBrand: String
|
sleeveBrand: String
|
||||||
quantityPerBox: String
|
quantityPerBox: String
|
||||||
|
tags: [String!]!
|
||||||
isCustomizable: Boolean!
|
isCustomizable: Boolean!
|
||||||
isActive: Boolean!
|
isActive: Boolean!
|
||||||
availableInWarehouses: [ProductWarehouseBalance!]!
|
availableInWarehouses: [ProductWarehouseBalance!]!
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type CatalogProductTypeSetting {
|
||||||
|
productType: String!
|
||||||
|
showQuantityPerBox: Boolean!
|
||||||
|
allowCustomLength: Boolean!
|
||||||
|
customLengthMinM: Int
|
||||||
|
customLengthMaxM: Int
|
||||||
|
customLengthStepM: Int
|
||||||
|
allowCustomSleeveBrand: Boolean!
|
||||||
|
allowCustomLabel: Boolean!
|
||||||
|
widthOptionsMm: [Int!]!
|
||||||
|
lengthOptionsM: [Int!]!
|
||||||
|
thicknessOptionsMicron: [Int!]!
|
||||||
|
sleeveOptions: [String!]!
|
||||||
|
colorOptions: [String!]!
|
||||||
|
labelOptions: [String!]!
|
||||||
|
}
|
||||||
|
|
||||||
type CartItem {
|
type CartItem {
|
||||||
id: ID!
|
id: ID!
|
||||||
productId: ID!
|
productId: ID!
|
||||||
@@ -380,8 +425,10 @@ type Query {
|
|||||||
myMessengerConnections: [MessengerConnection!]!
|
myMessengerConnections: [MessengerConnection!]!
|
||||||
myNotificationHistory(channel: MessengerType!, limit: Int = 50): [NotificationHistoryItem!]!
|
myNotificationHistory(channel: MessengerType!, limit: Int = 50): [NotificationHistoryItem!]!
|
||||||
notificationTemplates: [NotificationTemplate!]!
|
notificationTemplates: [NotificationTemplate!]!
|
||||||
|
integrationSyncDashboard: IntegrationSyncDashboard!
|
||||||
managerNotificationHistory(userId: ID!, channel: MessengerType!, limit: Int = 50): [NotificationHistoryItem!]!
|
managerNotificationHistory(userId: ID!, channel: MessengerType!, limit: Int = 50): [NotificationHistoryItem!]!
|
||||||
clientProducts: [Product!]!
|
clientProducts: [Product!]!
|
||||||
|
catalogProductTypeSettings: [CatalogProductTypeSetting!]!
|
||||||
order(id: ID!): Order
|
order(id: ID!): Order
|
||||||
myOrders: [Order!]!
|
myOrders: [Order!]!
|
||||||
myCurrentOrders: [Order!]!
|
myCurrentOrders: [Order!]!
|
||||||
@@ -462,6 +509,23 @@ input UpdateCartItemQuantityInput {
|
|||||||
quantity: Float!
|
quantity: Float!
|
||||||
}
|
}
|
||||||
|
|
||||||
|
input UpsertCatalogProductTypeSettingInput {
|
||||||
|
productType: String!
|
||||||
|
showQuantityPerBox: Boolean!
|
||||||
|
allowCustomLength: Boolean!
|
||||||
|
customLengthMinM: Int
|
||||||
|
customLengthMaxM: Int
|
||||||
|
customLengthStepM: Int
|
||||||
|
allowCustomSleeveBrand: Boolean!
|
||||||
|
allowCustomLabel: Boolean!
|
||||||
|
widthOptionsMm: [Int!]!
|
||||||
|
lengthOptionsM: [Int!]!
|
||||||
|
thicknessOptionsMicron: [Int!]!
|
||||||
|
sleeveOptions: [String!]!
|
||||||
|
colorOptions: [String!]!
|
||||||
|
labelOptions: [String!]!
|
||||||
|
}
|
||||||
|
|
||||||
input ReadyOrderItemInput {
|
input ReadyOrderItemInput {
|
||||||
productId: ID!
|
productId: ID!
|
||||||
quantity: Float!
|
quantity: Float!
|
||||||
@@ -525,6 +589,7 @@ type Mutation {
|
|||||||
connectMessenger(input: ConnectMessengerInput!): MessengerConnection!
|
connectMessenger(input: ConnectMessengerInput!): MessengerConnection!
|
||||||
deleteMyMessengerConnection(connectionId: ID!): Boolean!
|
deleteMyMessengerConnection(connectionId: ID!): Boolean!
|
||||||
upsertMyCounterpartyProfile(input: UpsertMyCounterpartyProfileInput!): CounterpartyProfile!
|
upsertMyCounterpartyProfile(input: UpsertMyCounterpartyProfileInput!): CounterpartyProfile!
|
||||||
|
upsertCatalogProductTypeSetting(input: UpsertCatalogProductTypeSettingInput!): CatalogProductTypeSetting!
|
||||||
addProductToCart(productId: ID!): Cart!
|
addProductToCart(productId: ID!): Cart!
|
||||||
updateCartItemQuantity(input: UpdateCartItemQuantityInput!): Cart!
|
updateCartItemQuantity(input: UpdateCartItemQuantityInput!): Cart!
|
||||||
removeCartItem(productId: ID!): Cart!
|
removeCartItem(productId: ID!): Cart!
|
||||||
@@ -542,6 +607,7 @@ type Mutation {
|
|||||||
clientReviewOrder(orderId: ID!, decision: Decision!): Order!
|
clientReviewOrder(orderId: ID!, decision: Decision!): Order!
|
||||||
|
|
||||||
createReferral(input: CreateReferralInput!): ReferralLink!
|
createReferral(input: CreateReferralInput!): ReferralLink!
|
||||||
|
createBonusProgramLink(userId: ID!): BonusProgramLink!
|
||||||
addBonusTransaction(input: AddBonusTransactionInput!): BonusTransaction!
|
addBonusTransaction(input: AddBonusTransactionInput!): BonusTransaction!
|
||||||
requestRewardWithdrawal(input: RequestRewardWithdrawalInput!): RewardWithdrawalRequest!
|
requestRewardWithdrawal(input: RequestRewardWithdrawalInput!): RewardWithdrawalRequest!
|
||||||
reviewRewardWithdrawal(input: ReviewRewardWithdrawalInput!): RewardWithdrawalRequest!
|
reviewRewardWithdrawal(input: ReviewRewardWithdrawalInput!): RewardWithdrawalRequest!
|
||||||
|
|||||||
10
package.json
@@ -10,7 +10,10 @@
|
|||||||
"postinstall": "nuxt prepare",
|
"postinstall": "nuxt prepare",
|
||||||
"codegen": "graphql-codegen --config codegen.ts",
|
"codegen": "graphql-codegen --config codegen.ts",
|
||||||
"storybook": "storybook dev -p 6006",
|
"storybook": "storybook dev -p 6006",
|
||||||
"build-storybook": "storybook build"
|
"build-storybook": "storybook build",
|
||||||
|
"docs:dev": "vitepress dev docs",
|
||||||
|
"docs:build": "vitepress build docs",
|
||||||
|
"docs:preview": "vitepress preview docs"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@apollo/client": "^3.14.1",
|
"@apollo/client": "^3.14.1",
|
||||||
@@ -24,6 +27,7 @@
|
|||||||
"@vue/apollo-composable": "^4.2.2",
|
"@vue/apollo-composable": "^4.2.2",
|
||||||
"daisyui": "^5.5.19",
|
"daisyui": "^5.5.19",
|
||||||
"graphql": "^16.13.2",
|
"graphql": "^16.13.2",
|
||||||
|
"mermaid": "^11.14.0",
|
||||||
"nuxt": "^4.4.2",
|
"nuxt": "^4.4.2",
|
||||||
"vue": "^3.5.30",
|
"vue": "^3.5.30",
|
||||||
"vue-router": "^5.0.4"
|
"vue-router": "^5.0.4"
|
||||||
@@ -34,9 +38,11 @@
|
|||||||
"@graphql-codegen/typescript": "^5.0.9",
|
"@graphql-codegen/typescript": "^5.0.9",
|
||||||
"@graphql-codegen/typescript-operations": "^5.0.9",
|
"@graphql-codegen/typescript-operations": "^5.0.9",
|
||||||
"@graphql-codegen/typescript-vue-apollo": "^5.0.0",
|
"@graphql-codegen/typescript-vue-apollo": "^5.0.0",
|
||||||
|
"@mermaid-js/mermaid-cli": "^11.14.0",
|
||||||
"@storybook/addon-essentials": "8.6.14",
|
"@storybook/addon-essentials": "8.6.14",
|
||||||
"@storybook/vue3-vite": "^8.6.14",
|
"@storybook/vue3-vite": "^8.6.14",
|
||||||
"storybook": "^8.6.14",
|
"storybook": "^8.6.14",
|
||||||
"typescript": "5.9.2"
|
"typescript": "5.9.2",
|
||||||
|
"vitepress": "1.6.4"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
2953
pnpm-lock.yaml
generated
9
pnpm-workspace.yaml
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
packages:
|
||||||
|
- .
|
||||||
|
|
||||||
|
allowBuilds:
|
||||||
|
"@parcel/watcher": true
|
||||||
|
esbuild: true
|
||||||
|
puppeteer: true
|
||||||
|
unrs-resolver: true
|
||||||
|
vue-demi: true
|
||||||