Compare commits
134 Commits
3eec898706
...
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 | ||
|
|
17b5a87699 | ||
|
|
824065f852 | ||
|
|
bed4c2f467 | ||
|
|
97d0c6fde7 | ||
|
|
b58eaf065a | ||
|
|
bb39642b67 | ||
|
|
2e3c64bc98 | ||
|
|
befec16a84 | ||
|
|
ac5ee256fd | ||
|
|
5396354962 | ||
|
|
a2ab98823d | ||
|
|
dcb595263d | ||
|
|
f506c8cf86 | ||
|
|
d119a76ae6 | ||
|
|
7c5d0967a0 | ||
|
|
a352e40b95 | ||
|
|
55b0028ddb | ||
|
|
77b98539ea | ||
|
|
6a3d69e1e8 | ||
|
|
d7dad079db | ||
|
|
821fc5d019 | ||
|
|
50a02c6593 | ||
|
|
606ed6c356 | ||
|
|
c4f9f0b6dc | ||
|
|
4a6871ecac | ||
|
|
2b6ef46e65 | ||
|
|
fa0648c71b | ||
|
|
714d0cc9b7 | ||
|
|
453092c289 | ||
|
|
ec9103a80d | ||
|
|
e20565b4ae | ||
|
|
309d0e78db | ||
|
|
540418c1dc | ||
|
|
ad1f6b8a35 | ||
|
|
685f84c428 | ||
|
|
570021a844 | ||
|
|
254fa45ced | ||
|
|
3bee6c370a | ||
|
|
9017555722 | ||
|
|
2f828cd164 | ||
|
|
2a5e38f488 | ||
|
|
4fe3f72579 | ||
|
|
8c5e95b730 | ||
|
|
7dc0f59ffb | ||
|
|
691990f992 | ||
|
|
c4ce422221 | ||
|
|
67e377fbe0 |
2
.gitignore
vendored
@@ -5,6 +5,8 @@
|
||||
.nitro
|
||||
.cache
|
||||
dist
|
||||
docs/.vitepress/cache
|
||||
docs/export
|
||||
|
||||
# Node dependencies
|
||||
node_modules
|
||||
|
||||
@@ -4,7 +4,7 @@ WORKDIR /app
|
||||
|
||||
RUN corepack enable
|
||||
|
||||
COPY package.json pnpm-lock.yaml ./
|
||||
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
|
||||
RUN pnpm install --frozen-lockfile
|
||||
|
||||
FROM deps AS build
|
||||
|
||||
161
app/app.vue
@@ -1,15 +1,168 @@
|
||||
<script setup lang="ts">
|
||||
import { useQuery } from '@vue/apollo-composable';
|
||||
import { MeDocument } from '~/composables/graphql/generated';
|
||||
import { hasManagerAccess } from '~/utils/roles';
|
||||
|
||||
const route = useRoute();
|
||||
const isLoginPage = computed(() => route.path === '/login');
|
||||
const isBonusProgramPage = computed(() => route.path === '/bonus-program');
|
||||
const meQuery = useQuery(MeDocument);
|
||||
const hasManagerDock = computed(() => (
|
||||
!isLoginPage.value && !isBonusProgramPage.value && hasManagerAccess(meQuery.result.value?.me?.role)
|
||||
));
|
||||
|
||||
const managerPageTabs = computed(() => {
|
||||
if (!hasManagerDock.value) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (
|
||||
route.path === '/admin/orders'
|
||||
|| route.path.startsWith('/admin/orders/')
|
||||
) {
|
||||
return [
|
||||
{
|
||||
key: 'orders',
|
||||
label: 'Заказы',
|
||||
active: route.path === '/admin/orders'
|
||||
|| (
|
||||
/^\/admin\/orders\/[^/]+$/.test(route.path)
|
||||
&& !route.path.startsWith('/admin/orders/clients')
|
||||
&& !route.path.startsWith('/admin/orders/requests')
|
||||
),
|
||||
to: {
|
||||
path: '/admin/orders',
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'clients',
|
||||
label: 'Клиенты',
|
||||
active: route.path === '/admin/orders/clients'
|
||||
|| route.path.startsWith('/admin/orders/clients/')
|
||||
|| route.path.startsWith('/admin/orders/requests/'),
|
||||
to: {
|
||||
path: '/admin/orders/clients',
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
if (route.path.startsWith('/admin/bonuses')) {
|
||||
return [
|
||||
{
|
||||
key: 'balances',
|
||||
label: 'Бонусные счета',
|
||||
active: route.path === '/admin/bonuses'
|
||||
|| route.path === '/admin/bonuses/balances'
|
||||
|| route.path.startsWith('/admin/bonuses/balances/')
|
||||
|| route.path.startsWith('/admin/bonuses/referrals/')
|
||||
|| route.path.startsWith('/admin/bonuses/transactions/'),
|
||||
to: {
|
||||
path: '/admin/bonuses/balances',
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'withdrawals',
|
||||
label: 'Заявки на выплату',
|
||||
active: route.path === '/admin/bonuses/requests'
|
||||
|| route.path.startsWith('/admin/bonuses/requests/'),
|
||||
to: {
|
||||
path: '/admin/bonuses/requests',
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'rewards',
|
||||
label: 'Вознаграждения',
|
||||
active: route.path === '/admin/bonuses/rewards',
|
||||
to: {
|
||||
path: '/admin/bonuses/rewards',
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
if (route.path.startsWith('/admin/settings')) {
|
||||
return [
|
||||
{
|
||||
key: 'catalog',
|
||||
label: 'Каталог',
|
||||
active: route.path === '/admin/settings/catalog',
|
||||
to: {
|
||||
path: '/admin/settings/catalog',
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'messages',
|
||||
label: 'Сообщения',
|
||||
active: route.path === '/admin/settings/messages',
|
||||
to: {
|
||||
path: '/admin/settings/messages',
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'sync',
|
||||
label: '1С',
|
||||
active: route.path === '/admin/settings/sync',
|
||||
to: {
|
||||
path: '/admin/settings/sync',
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
return [];
|
||||
});
|
||||
|
||||
const mainClass = computed(() => {
|
||||
if (isBonusProgramPage.value) {
|
||||
return 'bonus-program-main';
|
||||
}
|
||||
|
||||
if (isLoginPage.value) {
|
||||
return 'mx-auto flex min-h-screen w-full max-w-[1440px] items-center justify-center p-4 md:p-6 lg:p-8';
|
||||
}
|
||||
|
||||
return [
|
||||
'mx-auto w-full max-w-[1440px] p-4 pt-[104px] md:p-6 md:pt-[112px] lg:p-8 lg:pt-[118px]',
|
||||
hasManagerDock.value ? 'pb-[116px] md:pb-[128px]' : '',
|
||||
];
|
||||
});
|
||||
|
||||
const pageFrameClass = computed(() => {
|
||||
if (isBonusProgramPage.value) {
|
||||
return 'bonus-program-stage';
|
||||
}
|
||||
|
||||
if (isLoginPage.value) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return ['lk-content-canvas', { 'lk-content-canvas--with-tabs': managerPageTabs.value.length }];
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="lk-shell" data-theme="aqua">
|
||||
<UiAppHeader v-if="!isLoginPage" />
|
||||
<main class="mx-auto w-full max-w-[1440px] p-4 pt-[104px] md:p-6 md:pt-[112px] lg:p-8 lg:pt-[118px]">
|
||||
<div class="lk-content-canvas">
|
||||
<div :class="isBonusProgramPage ? 'bonus-program-shell' : 'lk-shell'" data-theme="aqua">
|
||||
<UiAppHeader v-if="!isLoginPage && !isBonusProgramPage" />
|
||||
<main :class="mainClass">
|
||||
<div v-if="managerPageTabs.length && !isBonusProgramPage" class="lk-page-tabs-shell">
|
||||
<nav class="manager-page-tabs" aria-label="Разделы страницы">
|
||||
<NuxtLink
|
||||
v-for="tab in managerPageTabs"
|
||||
:key="tab.key"
|
||||
:to="tab.to"
|
||||
class="manager-page-tab"
|
||||
:class="{ 'manager-page-tab--active': tab.active }"
|
||||
>
|
||||
{{ tab.label }}
|
||||
</NuxtLink>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<div :class="pageFrameClass">
|
||||
<NuxtPage />
|
||||
</div>
|
||||
</main>
|
||||
<UiAppManagerDock v-if="hasManagerDock" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -11,6 +11,13 @@
|
||||
--brand-surface: #f4fbf7;
|
||||
--brand-muted: #d8eee1;
|
||||
--brand-ink: #0f2f20;
|
||||
--lk-canvas-bg: color-mix(in oklab, #edf3ef 82%, white);
|
||||
--bonus-surface: #08090b;
|
||||
--bonus-surface-raised: #101218;
|
||||
--bonus-border: rgba(255, 255, 255, 0.08);
|
||||
--bonus-text: rgba(255, 255, 255, 0.94);
|
||||
--bonus-muted: rgba(255, 255, 255, 0.6);
|
||||
--bonus-accent: #d8ff3e;
|
||||
}
|
||||
|
||||
[data-theme='aqua'] {
|
||||
@@ -69,6 +76,17 @@ body {
|
||||
isolation: isolate;
|
||||
}
|
||||
|
||||
.bonus-program-shell {
|
||||
min-height: 100vh;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
background:
|
||||
radial-gradient(circle at 12% 16%, rgba(216, 255, 62, 0.12) 0%, rgba(216, 255, 62, 0) 28%),
|
||||
radial-gradient(circle at 86% 14%, rgba(76, 113, 255, 0.15) 0%, rgba(76, 113, 255, 0) 26%),
|
||||
linear-gradient(180deg, #07080a 0%, #0a0c10 42%, #050507 100%);
|
||||
color: var(--bonus-text);
|
||||
}
|
||||
|
||||
.lk-shell::before,
|
||||
.lk-shell::after {
|
||||
content: '';
|
||||
@@ -96,28 +114,40 @@ body {
|
||||
.lk-content-canvas {
|
||||
border: 0;
|
||||
border-radius: 2rem;
|
||||
background: color-mix(in oklab, var(--color-base-200) 78%, white);
|
||||
background: var(--lk-canvas-bg);
|
||||
box-shadow:
|
||||
0 22px 60px rgba(16, 73, 44, 0.12),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.35);
|
||||
0 18px 46px rgba(16, 73, 44, 0.08),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.32);
|
||||
padding: clamp(1rem, 1.2vw, 1.5rem);
|
||||
}
|
||||
|
||||
.lk-content-canvas--with-tabs {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.surface-card {
|
||||
border: 0;
|
||||
background: #fff;
|
||||
box-shadow: none;
|
||||
transition: box-shadow 0.2s ease;
|
||||
}
|
||||
|
||||
.surface-card:hover {
|
||||
box-shadow: 0 14px 32px rgba(24, 66, 44, 0.1);
|
||||
transition:
|
||||
box-shadow 0.2s ease,
|
||||
transform 0.2s ease;
|
||||
}
|
||||
|
||||
.surface-subcard {
|
||||
border: 0;
|
||||
border-radius: 1.5rem;
|
||||
background: color-mix(in oklab, #ffffff 78%, var(--brand-surface));
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.surface-card-interactive {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.surface-card-interactive:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 18px 38px rgba(18, 56, 36, 0.12);
|
||||
}
|
||||
|
||||
.manager-hero {
|
||||
@@ -199,7 +229,7 @@ body {
|
||||
.manager-mini-card {
|
||||
border: 0;
|
||||
border-radius: 1.2rem;
|
||||
background: color-mix(in oklab, white 72%, var(--brand-muted));
|
||||
background: #fff;
|
||||
padding: 0.9rem 1rem;
|
||||
}
|
||||
|
||||
@@ -231,6 +261,307 @@ body {
|
||||
color: #557562;
|
||||
}
|
||||
|
||||
.bonus-program-main {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
width: 100%;
|
||||
max-width: 1480px;
|
||||
margin: 0 auto;
|
||||
padding: clamp(1.25rem, 2vw, 2rem);
|
||||
}
|
||||
|
||||
.bonus-program-stage {
|
||||
min-height: calc(100vh - 2rem);
|
||||
}
|
||||
|
||||
.bonus-program-page {
|
||||
position: relative;
|
||||
min-height: calc(100vh - 4rem);
|
||||
}
|
||||
|
||||
.bonus-program-hero {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
justify-content: space-between;
|
||||
padding: clamp(1rem, 2vw, 1.5rem) 0;
|
||||
}
|
||||
|
||||
.bonus-program-kicker {
|
||||
font-size: 0.72rem;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.24em;
|
||||
text-transform: uppercase;
|
||||
color: rgba(255, 255, 255, 0.56);
|
||||
}
|
||||
|
||||
.bonus-program-title {
|
||||
max-width: 16ch;
|
||||
font-size: clamp(2.5rem, 5vw, 5.25rem);
|
||||
line-height: 0.94;
|
||||
font-weight: 800;
|
||||
letter-spacing: -0.06em;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.bonus-program-copy {
|
||||
max-width: 44rem;
|
||||
font-size: 1rem;
|
||||
line-height: 1.75;
|
||||
color: var(--bonus-muted);
|
||||
}
|
||||
|
||||
.bonus-program-panel {
|
||||
border: 1px solid var(--bonus-border);
|
||||
border-radius: 2rem;
|
||||
background:
|
||||
linear-gradient(180deg, rgba(255, 255, 255, 0.035), rgba(255, 255, 255, 0.02)),
|
||||
var(--bonus-surface-raised);
|
||||
box-shadow:
|
||||
0 24px 60px rgba(0, 0, 0, 0.28),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.05);
|
||||
padding: clamp(1rem, 1.5vw, 1.5rem);
|
||||
backdrop-filter: blur(18px);
|
||||
}
|
||||
|
||||
.bonus-program-caption {
|
||||
font-size: 0.72rem;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.2em;
|
||||
text-transform: uppercase;
|
||||
color: rgba(255, 255, 255, 0.48);
|
||||
}
|
||||
|
||||
.bonus-program-stat {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.55rem;
|
||||
border-radius: 1.45rem;
|
||||
border: 1px solid var(--bonus-border);
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.bonus-program-stat__label {
|
||||
font-size: 0.72rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.14em;
|
||||
text-transform: uppercase;
|
||||
color: rgba(255, 255, 255, 0.52);
|
||||
}
|
||||
|
||||
.bonus-program-stat__value {
|
||||
font-size: clamp(1.8rem, 2vw, 2.5rem);
|
||||
line-height: 1;
|
||||
font-weight: 800;
|
||||
letter-spacing: -0.05em;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.bonus-program-input {
|
||||
width: 100%;
|
||||
border: 1px solid var(--bonus-border);
|
||||
border-radius: 1.25rem;
|
||||
background: rgba(255, 255, 255, 0.035);
|
||||
color: #fff;
|
||||
padding: 0.95rem 1rem;
|
||||
outline: 0;
|
||||
}
|
||||
|
||||
.bonus-program-input:focus,
|
||||
.bonus-program-input:focus-visible {
|
||||
border-color: rgba(216, 255, 62, 0.3);
|
||||
box-shadow: 0 0 0 3px rgba(216, 255, 62, 0.08);
|
||||
}
|
||||
|
||||
.bonus-program-primary-button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 3rem;
|
||||
width: 100%;
|
||||
border: 0;
|
||||
border-radius: 999px;
|
||||
background: var(--bonus-accent);
|
||||
color: #060606;
|
||||
font-weight: 800;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
.bonus-program-primary-button:disabled {
|
||||
opacity: 0.45;
|
||||
}
|
||||
|
||||
.bonus-program-ghost-button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 2.9rem;
|
||||
border: 1px solid var(--bonus-border);
|
||||
border-radius: 999px;
|
||||
padding: 0 1rem;
|
||||
color: #fff;
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
}
|
||||
|
||||
.bonus-program-feed-item {
|
||||
border-radius: 1.6rem;
|
||||
border: 1px solid var(--bonus-border);
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
padding: 1rem 1.05rem;
|
||||
}
|
||||
|
||||
.bonus-program-inline-link {
|
||||
color: var(--bonus-accent);
|
||||
font-size: 0.9rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.bonus-program-empty {
|
||||
border-radius: 1.6rem;
|
||||
border: 1px dashed rgba(255, 255, 255, 0.12);
|
||||
padding: 1rem 1.1rem;
|
||||
color: rgba(255, 255, 255, 0.55);
|
||||
}
|
||||
|
||||
.bonus-program-orbit {
|
||||
position: absolute;
|
||||
border-radius: 999px;
|
||||
filter: blur(60px);
|
||||
pointer-events: none;
|
||||
opacity: 0.42;
|
||||
}
|
||||
|
||||
.bonus-program-orbit--a {
|
||||
top: 4%;
|
||||
left: -6%;
|
||||
width: 15rem;
|
||||
height: 15rem;
|
||||
background: rgba(216, 255, 62, 0.16);
|
||||
}
|
||||
|
||||
.bonus-program-orbit--b {
|
||||
right: -4%;
|
||||
bottom: 8%;
|
||||
width: 14rem;
|
||||
height: 14rem;
|
||||
background: rgba(86, 92, 255, 0.18);
|
||||
}
|
||||
|
||||
@media (min-width: 960px) {
|
||||
.bonus-program-hero {
|
||||
flex-direction: row;
|
||||
align-items: flex-end;
|
||||
}
|
||||
}
|
||||
|
||||
.lk-page-tabs-shell {
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
margin-bottom: -1px;
|
||||
padding-left: clamp(0.75rem, 1.3vw, 1.35rem);
|
||||
}
|
||||
|
||||
.manager-page-tabs {
|
||||
display: inline-flex;
|
||||
align-items: flex-end;
|
||||
gap: 0.45rem;
|
||||
}
|
||||
|
||||
.manager-page-tab {
|
||||
border: 1px solid #d7e9de;
|
||||
border-bottom: 0;
|
||||
border-radius: 1.15rem 1.15rem 0 0;
|
||||
background: transparent;
|
||||
margin-bottom: -1px;
|
||||
padding: 0.8rem 1.2rem 0.95rem;
|
||||
font-size: 0.95rem;
|
||||
font-weight: 700;
|
||||
color: #5c7b69;
|
||||
transition:
|
||||
background-color 0.18s ease,
|
||||
border-color 0.18s ease,
|
||||
color 0.18s ease;
|
||||
}
|
||||
|
||||
.manager-page-tab:hover {
|
||||
color: #123824;
|
||||
border-color: #bfd8ca;
|
||||
}
|
||||
|
||||
.manager-page-tab--active {
|
||||
background: var(--lk-canvas-bg);
|
||||
color: #123824;
|
||||
border-color: #d2e3da;
|
||||
}
|
||||
|
||||
.manager-dock-shell {
|
||||
position: fixed;
|
||||
inset-inline: 0;
|
||||
bottom: 0;
|
||||
z-index: 45;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: 0 1rem calc(env(safe-area-inset-bottom, 0px) + 1rem);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.manager-dock {
|
||||
pointer-events: auto;
|
||||
display: flex;
|
||||
width: fit-content;
|
||||
max-width: min(100%, calc(100vw - 2rem));
|
||||
gap: 0.5rem;
|
||||
border-radius: 1.75rem;
|
||||
background: rgba(255, 255, 255, 0.92);
|
||||
box-shadow:
|
||||
0 18px 40px rgba(18, 56, 36, 0.14),
|
||||
0 4px 12px rgba(18, 56, 36, 0.08);
|
||||
padding: 0.65rem;
|
||||
-webkit-backdrop-filter: blur(18px) saturate(180%);
|
||||
backdrop-filter: blur(18px) saturate(180%);
|
||||
}
|
||||
|
||||
.manager-dock__item {
|
||||
display: flex;
|
||||
min-width: 5.5rem;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.35rem;
|
||||
border-radius: 1.2rem;
|
||||
padding: 0.85rem 0.6rem;
|
||||
color: #5e7a69;
|
||||
transition:
|
||||
background-color 0.18s ease,
|
||||
color 0.18s ease,
|
||||
transform 0.18s ease;
|
||||
}
|
||||
|
||||
.manager-dock__item:hover {
|
||||
background: #f3faf6;
|
||||
color: #123824;
|
||||
}
|
||||
|
||||
.manager-dock__item--active {
|
||||
background: #123824;
|
||||
color: #fff;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.manager-dock__icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.manager-dock__label {
|
||||
font-size: 0.72rem;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.02em;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.fregat-header-glass {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
74
app/components/bonus/AccountCard.vue
Normal file
@@ -0,0 +1,74 @@
|
||||
<script setup lang="ts">
|
||||
type BonusCardStat = {
|
||||
label: string;
|
||||
value: string;
|
||||
};
|
||||
|
||||
type BonusCardLink = {
|
||||
id: string;
|
||||
refereeName: string;
|
||||
refereeEmail: string;
|
||||
refereeCompanyName?: string | null;
|
||||
bonusPercent: number;
|
||||
};
|
||||
|
||||
withDefaults(defineProps<{
|
||||
fullName: string;
|
||||
email?: string;
|
||||
companyName?: string | null;
|
||||
balance: number;
|
||||
stats?: BonusCardStat[];
|
||||
sourceLinks?: BonusCardLink[];
|
||||
}>(), {
|
||||
email: '',
|
||||
companyName: null,
|
||||
stats: () => [],
|
||||
sourceLinks: () => [],
|
||||
});
|
||||
|
||||
function formatAmount(value: number) {
|
||||
return new Intl.NumberFormat('ru-RU', {
|
||||
minimumFractionDigits: 0,
|
||||
maximumFractionDigits: 2,
|
||||
}).format(value);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<article class="surface-card rounded-[32px] p-6">
|
||||
<div class="space-y-1">
|
||||
<h1 class="text-2xl font-bold leading-tight text-[#123824]">{{ fullName }}</h1>
|
||||
<p v-if="companyName || email" class="text-sm text-[#5c7b69]">
|
||||
{{ companyName || email }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="mt-6 grid gap-3 md:grid-cols-2 xl:grid-cols-4">
|
||||
<div
|
||||
v-for="stat in stats"
|
||||
:key="stat.label"
|
||||
class="rounded-[24px] bg-[#f6fbf8] px-4 py-4"
|
||||
>
|
||||
<p class="text-[11px] font-semibold uppercase tracking-[0.18em] text-[#6a8a76]">{{ stat.label }}</p>
|
||||
<p class="mt-2 text-xl font-bold leading-none text-[#123824]">{{ stat.value }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-6 rounded-[24px] bg-[#f6fbf8] px-4 py-4">
|
||||
<p class="text-[11px] font-semibold uppercase tracking-[0.18em] text-[#6a8a76]">Доступный бонус</p>
|
||||
<p class="mt-2 text-3xl font-black leading-none text-[#123824]">{{ formatAmount(balance) }}</p>
|
||||
</div>
|
||||
|
||||
<div v-if="sourceLinks.length" class="mt-6 space-y-2">
|
||||
<div
|
||||
v-for="link in sourceLinks"
|
||||
:key="link.id"
|
||||
class="rounded-[24px] bg-[#f6fbf8] px-4 py-4 text-sm text-[#355947]"
|
||||
>
|
||||
<p class="font-semibold text-[#123824]">{{ link.refereeName }}</p>
|
||||
<p class="mt-1">{{ link.refereeCompanyName || link.refereeEmail }}</p>
|
||||
<p class="mt-2 text-xs text-[#5c7b69]">Бонус {{ link.bonusPercent }}%</p>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</template>
|
||||
@@ -1,15 +1,28 @@
|
||||
<script setup lang="ts">
|
||||
import { useQuery } from '@vue/apollo-composable';
|
||||
import { ClientProductsDocument, type ClientProductsQuery } from '~/composables/graphql/generated';
|
||||
import {
|
||||
CatalogProductTypeSettingsDocument,
|
||||
ClientProductsDocument,
|
||||
type CatalogProductTypeSettingsQuery,
|
||||
type ClientProductsQuery,
|
||||
} from '~/composables/graphql/generated';
|
||||
import { useClientCart } from '~/composables/useClientCart';
|
||||
|
||||
const props = defineProps<{
|
||||
productTypeSlug: string;
|
||||
}>();
|
||||
|
||||
type ProductNode = ClientProductsQuery['clientProducts'][number];
|
||||
type ParamFieldKey = 'widthMm' | 'lengthM' | 'thicknessMicron' | 'sleeveBrand' | 'quantityPerBox';
|
||||
type CatalogProductTypeSettingNode = CatalogProductTypeSettingsQuery['catalogProductTypeSettings'][number];
|
||||
type ParamFieldKey = 'widthMm' | 'lengthM' | 'thicknessMicron' | 'sleeveBrand' | 'quantityPerBox' | 'colorTag' | 'labelTag';
|
||||
type ParamValue = number | string;
|
||||
|
||||
type ParsedProduct = ProductNode & {
|
||||
productTypeLabel: string;
|
||||
quantityPerBoxOptions: string[];
|
||||
normalizedTags: string[];
|
||||
colorTags: string[];
|
||||
labelTags: string[];
|
||||
};
|
||||
|
||||
type ProductGroup = {
|
||||
@@ -24,16 +37,37 @@ type GroupState = {
|
||||
thicknessMicron: number | null;
|
||||
sleeveBrand: string | null;
|
||||
quantityPerBox: string | null;
|
||||
isExpanded: boolean;
|
||||
colorTag: string | null;
|
||||
labelTag: string | null;
|
||||
};
|
||||
|
||||
const PARAM_KEYS: ParamFieldKey[] = ['widthMm', 'lengthM', 'thicknessMicron', 'sleeveBrand', 'quantityPerBox'];
|
||||
const COLOR_TAGS = ['прозрачный', 'коричневый', 'белый', 'черный', 'желтый', 'зеленый', 'красный', 'синий', 'оранжевый', 'красно-белый'];
|
||||
const LABEL_TAGS = ['хрупкое', 'подарок', 'акция'];
|
||||
const PARAM_KEYS: ParamFieldKey[] = ['widthMm', 'lengthM', 'thicknessMicron', 'sleeveBrand', 'quantityPerBox', 'colorTag', 'labelTag'];
|
||||
const DEFAULT_CATALOG_PRODUCT_TYPE_SETTING: CatalogProductTypeSettingNode = {
|
||||
productType: '',
|
||||
showQuantityPerBox: false,
|
||||
allowCustomLength: false,
|
||||
customLengthMinM: null,
|
||||
customLengthMaxM: null,
|
||||
customLengthStepM: null,
|
||||
allowCustomSleeveBrand: false,
|
||||
allowCustomLabel: false,
|
||||
widthOptionsMm: [],
|
||||
lengthOptionsM: [],
|
||||
thicknessOptionsMicron: [],
|
||||
sleeveOptions: [],
|
||||
colorOptions: [],
|
||||
labelOptions: [],
|
||||
};
|
||||
const parameterFields: Array<{ key: ParamFieldKey; label: string }> = [
|
||||
{ key: 'widthMm', label: 'Ширина' },
|
||||
{ key: 'lengthM', label: 'Длина' },
|
||||
{ key: 'thicknessMicron', label: 'Толщина' },
|
||||
{ key: 'sleeveBrand', label: 'Втулка' },
|
||||
{ key: 'quantityPerBox', label: 'Короб' },
|
||||
{ key: 'colorTag', label: 'Цвет' },
|
||||
{ key: 'labelTag', label: 'Надпись' },
|
||||
];
|
||||
|
||||
const coverPresets = [
|
||||
@@ -42,11 +76,21 @@ const coverPresets = [
|
||||
['#e8f5ec', '#b2e0c6', '#7dd0a9'],
|
||||
];
|
||||
|
||||
const { result, loading, error } = useQuery(ClientProductsDocument);
|
||||
const search = ref('');
|
||||
const productsQuery = useQuery(ClientProductsDocument);
|
||||
const catalogSettingsQuery = useQuery(CatalogProductTypeSettingsDocument);
|
||||
const groupStates = reactive<Record<string, GroupState>>({});
|
||||
const { addProduct, getQuantity, incrementQuantity, decrementQuantity } = useClientCart();
|
||||
|
||||
const loading = computed(() => productsQuery.loading.value || catalogSettingsQuery.loading.value);
|
||||
const error = computed(() => productsQuery.error.value || catalogSettingsQuery.error.value);
|
||||
|
||||
const catalogSettingsByType = computed<Record<string, CatalogProductTypeSettingNode>>(() => (
|
||||
Object.fromEntries(
|
||||
(catalogSettingsQuery.result.value?.catalogProductTypeSettings ?? [])
|
||||
.map((setting) => [setting.productType, setting]),
|
||||
)
|
||||
));
|
||||
|
||||
function normalizeText(value: string | null | undefined) {
|
||||
return String(value ?? '').replaceAll(/\s+/g, ' ').trim();
|
||||
}
|
||||
@@ -85,10 +129,15 @@ function createProductCover(name: string, sku: string) {
|
||||
}
|
||||
|
||||
function hydrateProduct(product: ProductNode): ParsedProduct {
|
||||
const normalizedTags = product.tags.map((tag) => normalizeText(tag)).filter(Boolean).sort((a, b) => a.localeCompare(b, 'ru'));
|
||||
|
||||
return {
|
||||
...product,
|
||||
productTypeLabel: normalizeText(product.productType) || 'Без типа',
|
||||
quantityPerBoxOptions: splitBoxValues(product.quantityPerBox),
|
||||
normalizedTags,
|
||||
colorTags: normalizedTags.filter((tag) => COLOR_TAGS.includes(tag)),
|
||||
labelTags: normalizedTags.filter((tag) => LABEL_TAGS.includes(tag)),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -121,27 +170,10 @@ function compareProducts(a: ParsedProduct, b: ParsedProduct) {
|
||||
}
|
||||
|
||||
const parsedProducts = computed<ParsedProduct[]>(() => {
|
||||
const list = result.value?.clientProducts ?? [];
|
||||
const query = search.value.trim().toLowerCase();
|
||||
const list = productsQuery.result.value?.clientProducts ?? [];
|
||||
|
||||
return list
|
||||
.map(hydrateProduct)
|
||||
.filter((product) => {
|
||||
if (!query) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return [
|
||||
product.name,
|
||||
product.sku,
|
||||
product.productTypeLabel,
|
||||
String(product.widthMm ?? ''),
|
||||
String(product.lengthM ?? ''),
|
||||
String(product.thicknessMicron ?? ''),
|
||||
normalizeText(product.sleeveBrand),
|
||||
normalizeText(product.quantityPerBox),
|
||||
].some((part) => part.toLowerCase().includes(query));
|
||||
})
|
||||
.sort(compareProducts);
|
||||
});
|
||||
|
||||
@@ -160,7 +192,11 @@ const productGroups = computed<ProductGroup[]>(() => {
|
||||
return [...map.entries()]
|
||||
.sort((a, b) => a[0].localeCompare(b[0], 'ru'))
|
||||
.map(([typeLabel, products]) => ({
|
||||
key: typeLabel.toLowerCase().replaceAll(/\s+/g, '-'),
|
||||
key: typeLabel
|
||||
.toLowerCase()
|
||||
.replaceAll(/[^a-z0-9а-яё]+/gi, '-')
|
||||
.replaceAll(/-+/g, '-')
|
||||
.replaceAll(/^-|-$/g, ''),
|
||||
typeLabel,
|
||||
products: [...products].sort(compareProducts),
|
||||
}));
|
||||
@@ -186,6 +222,20 @@ function getAllFieldOptions(group: ProductGroup, field: ParamFieldKey) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (field === 'colorTag') {
|
||||
for (const tag of product.colorTags) {
|
||||
values.add(tag);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (field === 'labelTag') {
|
||||
for (const tag of product.labelTags) {
|
||||
values.add(tag);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
const value = product[field];
|
||||
if (value !== null && value !== undefined) {
|
||||
values.add(value);
|
||||
@@ -195,25 +245,39 @@ function getAllFieldOptions(group: ProductGroup, field: ParamFieldKey) {
|
||||
return sortParamValues([...values]);
|
||||
}
|
||||
|
||||
function groupCatalogSetting(group: ProductGroup) {
|
||||
return catalogSettingsByType.value[group.typeLabel] ?? {
|
||||
...DEFAULT_CATALOG_PRODUCT_TYPE_SETTING,
|
||||
productType: group.typeLabel,
|
||||
};
|
||||
}
|
||||
|
||||
function visibleFields(group: ProductGroup) {
|
||||
return parameterFields.filter((field) => getAllFieldOptions(group, field.key).length > 1);
|
||||
return parameterFields.filter((field) => {
|
||||
if (field.key === 'quantityPerBox') {
|
||||
return false;
|
||||
}
|
||||
|
||||
return getAllFieldOptions(group, field.key).length > 1;
|
||||
});
|
||||
}
|
||||
|
||||
function visibleFieldsByColumn(group: ProductGroup) {
|
||||
const visibleKeys = new Set(visibleFields(group).map((field) => field.key));
|
||||
const selectedGroup = computed(() => productGroups.value.find((group) => group.key === props.productTypeSlug) ?? null);
|
||||
const currentGroupIndex = computed(() => productGroups.value.findIndex((group) => group.key === props.productTypeSlug));
|
||||
const previousGroup = computed(() => {
|
||||
if (currentGroupIndex.value <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const leftColumn = parameterFields.filter((field) => (
|
||||
visibleKeys.has(field.key)
|
||||
&& ['widthMm', 'lengthM'].includes(field.key)
|
||||
));
|
||||
return productGroups.value[currentGroupIndex.value - 1] ?? null;
|
||||
});
|
||||
const nextGroup = computed(() => {
|
||||
if (currentGroupIndex.value < 0 || currentGroupIndex.value >= productGroups.value.length - 1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const rightColumn = parameterFields.filter((field) => (
|
||||
visibleKeys.has(field.key)
|
||||
&& ['thicknessMicron', 'quantityPerBox', 'sleeveBrand'].includes(field.key)
|
||||
));
|
||||
|
||||
return { leftColumn, rightColumn };
|
||||
}
|
||||
return productGroups.value[currentGroupIndex.value + 1] ?? null;
|
||||
});
|
||||
|
||||
function requiredKeys(group: ProductGroup) {
|
||||
return visibleFields(group).map((field) => field.key);
|
||||
@@ -228,7 +292,8 @@ function createGroupState(group: ProductGroup): GroupState {
|
||||
thicknessMicron: firstProduct?.thicknessMicron ?? null,
|
||||
sleeveBrand: firstProduct?.sleeveBrand ?? null,
|
||||
quantityPerBox: firstProduct?.quantityPerBoxOptions[0] ?? null,
|
||||
isExpanded: false,
|
||||
colorTag: firstProduct?.colorTags[0] ?? null,
|
||||
labelTag: firstProduct?.labelTags[0] ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -265,6 +330,14 @@ function matchesProductState(product: ParsedProduct, state: GroupState, keys: Pa
|
||||
return product.quantityPerBoxOptions.includes(String(state[key]));
|
||||
}
|
||||
|
||||
if (key === 'colorTag') {
|
||||
return product.colorTags.includes(String(state[key]));
|
||||
}
|
||||
|
||||
if (key === 'labelTag') {
|
||||
return product.labelTags.includes(String(state[key]));
|
||||
}
|
||||
|
||||
return product[key] === state[key];
|
||||
});
|
||||
}
|
||||
@@ -274,7 +347,7 @@ function selectedProduct(group: ProductGroup) {
|
||||
const state = getGroupState(group);
|
||||
|
||||
if (keys.length === 0) {
|
||||
return group.products.length === 1 ? group.products[0] : null;
|
||||
return group.products[0] ?? null;
|
||||
}
|
||||
|
||||
if (keys.some((key) => state[key] === null)) {
|
||||
@@ -282,7 +355,7 @@ function selectedProduct(group: ProductGroup) {
|
||||
}
|
||||
|
||||
const matches = group.products.filter((product) => matchesProductState(product, state, keys));
|
||||
return matches.length === 1 ? matches[0] : null;
|
||||
return matches[0] ?? null;
|
||||
}
|
||||
|
||||
function formatOptionLabel(field: ParamFieldKey, value: ParamValue) {
|
||||
@@ -303,6 +376,14 @@ function productHasOption(product: ParsedProduct, field: ParamFieldKey, option:
|
||||
return product.quantityPerBoxOptions.includes(String(option));
|
||||
}
|
||||
|
||||
if (field === 'colorTag') {
|
||||
return product.colorTags.includes(String(option));
|
||||
}
|
||||
|
||||
if (field === 'labelTag') {
|
||||
return product.labelTags.includes(String(option));
|
||||
}
|
||||
|
||||
return product[field] === option;
|
||||
}
|
||||
|
||||
@@ -359,6 +440,8 @@ function applyProductToState(state: GroupState, product: ParsedProduct, preferre
|
||||
state.lengthM = product.lengthM ?? null;
|
||||
state.thicknessMicron = product.thicknessMicron ?? null;
|
||||
state.sleeveBrand = product.sleeveBrand ?? null;
|
||||
state.colorTag = product.colorTags[0] ?? null;
|
||||
state.labelTag = product.labelTags[0] ?? null;
|
||||
|
||||
if (preferredBoxOption !== null && product.quantityPerBoxOptions.includes(String(preferredBoxOption))) {
|
||||
state.quantityPerBox = String(preferredBoxOption);
|
||||
@@ -389,8 +472,112 @@ function articleLabel(group: ProductGroup) {
|
||||
return selectedProduct(group)?.sku ?? '—';
|
||||
}
|
||||
|
||||
function toggleExpanded(group: ProductGroup) {
|
||||
getGroupState(group).isExpanded = !getGroupState(group).isExpanded;
|
||||
function formatLengthRange(setting: CatalogProductTypeSettingNode) {
|
||||
if (!setting.customLengthMinM || !setting.customLengthMaxM || !setting.customLengthStepM) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return `${setting.customLengthMinM}-${setting.customLengthMaxM} м, шаг ${setting.customLengthStepM} м`;
|
||||
}
|
||||
|
||||
function fieldHelperText(group: ProductGroup, field: ParamFieldKey) {
|
||||
const setting = groupCatalogSetting(group);
|
||||
|
||||
if (field === 'widthMm') {
|
||||
return 'Ширина определяет, насколько широкой будет полоса материала в работе и при намотке.';
|
||||
}
|
||||
|
||||
if (field === 'lengthM') {
|
||||
const customRange = formatLengthRange(setting);
|
||||
if (setting.allowCustomLength && customRange) {
|
||||
return `Можно выбрать стандартный метраж из наличия или заказать свой вариант. Доступный диапазон: ${customRange}.`;
|
||||
}
|
||||
|
||||
return 'Длина показывает, сколько метров материала будет в одном рулоне.';
|
||||
}
|
||||
|
||||
if (field === 'thicknessMicron') {
|
||||
return 'Толщина влияет на плотность, прочность и общее ощущение материала в работе.';
|
||||
}
|
||||
|
||||
if (field === 'sleeveBrand') {
|
||||
if (setting.allowCustomSleeveBrand) {
|
||||
return 'Можно выбрать стандартную втулку или сделать свою с логотипом под заказ.';
|
||||
}
|
||||
|
||||
return 'Втулка находится внутри рулона и влияет на совместимость с вашим оборудованием.';
|
||||
}
|
||||
|
||||
if (field === 'colorTag') {
|
||||
return 'Цвет нужен для визуального отличия, маркировки и внешнего вида готового рулона.';
|
||||
}
|
||||
|
||||
if (field === 'labelTag') {
|
||||
if (setting.allowCustomLabel) {
|
||||
return 'Можно взять стандартную маркировку из каталога или нанести свою надпись.';
|
||||
}
|
||||
|
||||
return 'Надпись или маркировка помогает сразу выбрать нужный готовый вариант.';
|
||||
}
|
||||
|
||||
return 'Параметр товара.';
|
||||
}
|
||||
|
||||
function customizationDetails(group: ProductGroup) {
|
||||
const setting = groupCatalogSetting(group);
|
||||
const details: string[] = [];
|
||||
const customRange = formatLengthRange(setting);
|
||||
|
||||
if (setting.allowCustomLength && customRange) {
|
||||
details.push(`Своя длина: ${customRange}.`);
|
||||
}
|
||||
|
||||
if (setting.allowCustomSleeveBrand) {
|
||||
details.push('Втулка с логотипом под заказ.');
|
||||
}
|
||||
|
||||
if (setting.allowCustomLabel) {
|
||||
details.push('Можно нанести свою надпись.');
|
||||
}
|
||||
|
||||
return details;
|
||||
}
|
||||
|
||||
function totalAvailableQty(product: ParsedProduct) {
|
||||
return product.availableInWarehouses.reduce((sum, balance) => sum + Number(balance.availableQty || 0), 0);
|
||||
}
|
||||
|
||||
function warehouseAvailability(product: ParsedProduct) {
|
||||
return product.availableInWarehouses
|
||||
.filter((balance) => Number(balance.availableQty || 0) > 0)
|
||||
.map((balance) => `${balance.warehouse.code}: ${balance.availableQty}`)
|
||||
.join(' · ');
|
||||
}
|
||||
|
||||
function availabilityTone(product: ParsedProduct) {
|
||||
const qty = totalAvailableQty(product);
|
||||
|
||||
if (qty <= 0) {
|
||||
return 'bg-[#d95c5c]';
|
||||
}
|
||||
if (qty < 20) {
|
||||
return 'bg-[#e2b534]';
|
||||
}
|
||||
|
||||
return 'bg-[#2aa36b]';
|
||||
}
|
||||
|
||||
function availabilityLabel(product: ParsedProduct) {
|
||||
const qty = totalAvailableQty(product);
|
||||
|
||||
if (qty <= 0) {
|
||||
return 'Нет в наличии';
|
||||
}
|
||||
if (qty < 20) {
|
||||
return 'Остаток ограничен';
|
||||
}
|
||||
|
||||
return 'В наличии';
|
||||
}
|
||||
|
||||
function incrementProduct(product: ProductNode) {
|
||||
@@ -429,222 +616,259 @@ function decrementSelected(group: ProductGroup) {
|
||||
decrementProduct(product.id);
|
||||
}
|
||||
}
|
||||
|
||||
function productDetailPath(group: ProductGroup) {
|
||||
return `/products/${group.key}`;
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="space-y-5">
|
||||
<UiSectionSearchHero
|
||||
v-model="search"
|
||||
title="Каталог"
|
||||
search-placeholder="Поиск по артикулу, типу товара или параметрам"
|
||||
/>
|
||||
|
||||
<div v-if="loading" class="alert surface-card border-0">Загрузка каталога...</div>
|
||||
<div v-else-if="error" class="alert alert-error">{{ error.message }}</div>
|
||||
|
||||
<div v-else-if="productGroups.length" class="space-y-4">
|
||||
<article
|
||||
v-for="group in productGroups"
|
||||
:key="group.key"
|
||||
class="surface-card rounded-3xl p-4 md:p-5"
|
||||
<div v-else-if="selectedGroup" class="relative pb-10">
|
||||
<NuxtLink
|
||||
v-if="previousGroup"
|
||||
:to="productDetailPath(previousGroup)"
|
||||
class="absolute left-[-212px] top-28 z-10 hidden w-44 rounded-[28px] border border-[#e6efe9] bg-white p-3 shadow-[0_20px_40px_rgba(18,56,36,0.08)] transition hover:-translate-x-2 hover:shadow-[0_28px_48px_rgba(18,56,36,0.12)] 2xl:block"
|
||||
>
|
||||
<div class="grid gap-4 xl:grid-cols-6 xl:items-start">
|
||||
<div class="p-3 xl:col-span-1">
|
||||
<img
|
||||
:src="createProductCover(previousGroup.typeLabel, previousGroup.key)"
|
||||
:alt="`Перейти к товару ${previousGroup.typeLabel}`"
|
||||
class="aspect-square w-full rounded-[20px] object-cover"
|
||||
loading="lazy"
|
||||
>
|
||||
<p class="mt-3 text-sm font-semibold leading-5 text-[#163624]">{{ previousGroup.typeLabel }}</p>
|
||||
</NuxtLink>
|
||||
|
||||
<NuxtLink
|
||||
v-if="nextGroup"
|
||||
:to="productDetailPath(nextGroup)"
|
||||
class="absolute right-[-212px] top-28 z-10 hidden w-44 rounded-[28px] border border-[#e6efe9] bg-white p-3 shadow-[0_20px_40px_rgba(18,56,36,0.08)] transition hover:translate-x-2 hover:shadow-[0_28px_48px_rgba(18,56,36,0.12)] 2xl:block"
|
||||
>
|
||||
<img
|
||||
:src="createProductCover(nextGroup.typeLabel, nextGroup.key)"
|
||||
:alt="`Перейти к товару ${nextGroup.typeLabel}`"
|
||||
class="aspect-square w-full rounded-[20px] object-cover"
|
||||
loading="lazy"
|
||||
>
|
||||
<p class="mt-3 text-sm font-semibold leading-5 text-[#163624]">{{ nextGroup.typeLabel }}</p>
|
||||
</NuxtLink>
|
||||
|
||||
<header class="mb-5 flex items-center gap-4">
|
||||
<NuxtLink
|
||||
to="/products"
|
||||
class="inline-flex h-11 w-11 shrink-0 items-center justify-center rounded-full border border-[#dce9e1] bg-white text-xl text-[#163624] shadow-[0_10px_24px_rgba(18,56,36,0.06)] transition hover:-translate-y-0.5"
|
||||
aria-label="Назад к списку товаров"
|
||||
>
|
||||
←
|
||||
</NuxtLink>
|
||||
|
||||
<div class="min-w-0">
|
||||
<h1 class="text-3xl font-bold leading-tight text-[#163624] md:text-[2.5rem]">{{ selectedGroup.typeLabel }}</h1>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="mb-5 grid gap-3 2xl:hidden">
|
||||
<NuxtLink
|
||||
v-if="previousGroup"
|
||||
:to="productDetailPath(previousGroup)"
|
||||
class="flex items-center gap-3 rounded-[24px] border border-[#e6efe9] bg-white p-3 shadow-[0_14px_30px_rgba(18,56,36,0.06)]"
|
||||
>
|
||||
<img
|
||||
:src="createProductCover(previousGroup.typeLabel, previousGroup.key)"
|
||||
:alt="`Перейти к товару ${previousGroup.typeLabel}`"
|
||||
class="h-16 w-16 rounded-2xl object-cover"
|
||||
loading="lazy"
|
||||
>
|
||||
<span class="text-sm font-semibold text-[#163624]">{{ previousGroup.typeLabel }}</span>
|
||||
</NuxtLink>
|
||||
|
||||
<NuxtLink
|
||||
v-if="nextGroup"
|
||||
:to="productDetailPath(nextGroup)"
|
||||
class="flex items-center gap-3 rounded-[24px] border border-[#e6efe9] bg-white p-3 shadow-[0_14px_30px_rgba(18,56,36,0.06)]"
|
||||
>
|
||||
<img
|
||||
:src="createProductCover(nextGroup.typeLabel, nextGroup.key)"
|
||||
:alt="`Перейти к товару ${nextGroup.typeLabel}`"
|
||||
class="h-16 w-16 rounded-2xl object-cover"
|
||||
loading="lazy"
|
||||
>
|
||||
<span class="text-sm font-semibold text-[#163624]">{{ nextGroup.typeLabel }}</span>
|
||||
</NuxtLink>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-5 xl:grid-cols-[minmax(0,0.92fr)_minmax(0,1.15fr)_320px]">
|
||||
<div class="space-y-3">
|
||||
<div class="overflow-hidden rounded-[32px] border border-[#e6efe9] bg-white p-4 shadow-[0_20px_40px_rgba(18,56,36,0.06)]">
|
||||
<img
|
||||
:src="createProductCover(group.typeLabel, group.key)"
|
||||
:alt="`Превью группы ${group.typeLabel}`"
|
||||
class="aspect-square w-full rounded-[24px] object-cover"
|
||||
:src="createProductCover(selectedGroup.typeLabel, articleLabel(selectedGroup))"
|
||||
:alt="selectedGroup.typeLabel"
|
||||
class="aspect-[5/4] w-full rounded-[26px] object-cover"
|
||||
loading="lazy"
|
||||
>
|
||||
</div>
|
||||
|
||||
<div class="p-4 md:p-5 xl:col-span-4">
|
||||
<div class="mb-4">
|
||||
<h2 class="text-2xl font-bold text-[#163624]">{{ group.typeLabel }}</h2>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-8 md:grid-cols-2">
|
||||
<div class="space-y-4">
|
||||
<div
|
||||
v-for="field in visibleFieldsByColumn(group).leftColumn"
|
||||
:key="`${group.key}-${field.key}`"
|
||||
class="border-b border-base-200 pb-4 last:border-b-0 last:pb-0"
|
||||
>
|
||||
<p class="text-sm font-semibold text-[#163624]">{{ field.label }}</p>
|
||||
|
||||
<div class="mt-3 flex flex-wrap gap-2">
|
||||
<label
|
||||
v-for="option in getAllFieldOptions(group, field.key)"
|
||||
:key="`${group.key}-${field.key}-${option}`"
|
||||
class="btn btn-sm rounded-full text-sm normal-case shadow-none transition"
|
||||
:class="[
|
||||
getGroupState(group)[field.key] === option
|
||||
? 'bg-neutral text-neutral-content'
|
||||
: isOptionAvailable(group, field.key, option)
|
||||
? 'bg-base-100 text-base-content hover:bg-base-200'
|
||||
: 'bg-[#eef1f4] text-[#7b8591] hover:bg-[#e3e7eb]',
|
||||
'cursor-pointer',
|
||||
]"
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
class="sr-only"
|
||||
:name="`${group.key}-${field.key}`"
|
||||
:checked="getGroupState(group)[field.key] === option"
|
||||
@change="updateField(group, field.key, option)"
|
||||
>
|
||||
<span>{{ formatOptionLabel(field.key, option) }}</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-4">
|
||||
<div
|
||||
v-for="field in visibleFieldsByColumn(group).rightColumn"
|
||||
:key="`${group.key}-${field.key}`"
|
||||
class="border-b border-base-200 pb-4 last:border-b-0 last:pb-0"
|
||||
>
|
||||
<p class="text-sm font-semibold text-[#163624]">{{ field.label }}</p>
|
||||
|
||||
<div class="mt-3 flex flex-wrap gap-2">
|
||||
<label
|
||||
v-for="option in getAllFieldOptions(group, field.key)"
|
||||
:key="`${group.key}-${field.key}-${option}`"
|
||||
class="btn btn-sm rounded-full text-sm normal-case shadow-none transition"
|
||||
:class="[
|
||||
getGroupState(group)[field.key] === option
|
||||
? 'bg-neutral text-neutral-content'
|
||||
: isOptionAvailable(group, field.key, option)
|
||||
? 'bg-base-100 text-base-content hover:bg-base-200'
|
||||
: 'bg-[#eef1f4] text-[#7b8591] hover:bg-[#e3e7eb]',
|
||||
'cursor-pointer',
|
||||
]"
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
class="sr-only"
|
||||
:name="`${group.key}-${field.key}`"
|
||||
:checked="getGroupState(group)[field.key] === option"
|
||||
@change="updateField(group, field.key, option)"
|
||||
>
|
||||
<span>{{ formatOptionLabel(field.key, option) }}</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="customizationDetails(selectedGroup).length"
|
||||
class="rounded-[28px] border border-[#dce9e1] bg-[#f7fbf8] p-4"
|
||||
>
|
||||
<div class="space-y-2">
|
||||
<p
|
||||
v-for="note in customizationDetails(selectedGroup)"
|
||||
:key="`${selectedGroup.key}-${note}`"
|
||||
class="text-sm leading-6 text-[#456555]"
|
||||
>
|
||||
{{ note }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<aside class="p-4 md:p-5 xl:col-span-1">
|
||||
<div class="flex h-full flex-col justify-between gap-4">
|
||||
<div />
|
||||
|
||||
<div class="space-y-3">
|
||||
<button
|
||||
v-if="selectedQty(group) === 0"
|
||||
class="btn h-11 w-full rounded-full border-0 bg-[#139957] text-sm font-semibold text-white hover:bg-[#0d854a]"
|
||||
:disabled="!selectedProduct(group)"
|
||||
@click="incrementSelected(group)"
|
||||
>
|
||||
В корзину
|
||||
</button>
|
||||
|
||||
<div v-else class="rounded-[22px] border border-base-300 bg-base-100 px-2 py-1">
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<button
|
||||
class="btn btn-square btn-sm"
|
||||
:disabled="selectedQty(group) === 0"
|
||||
@click="decrementSelected(group)"
|
||||
>
|
||||
-
|
||||
</button>
|
||||
<div class="min-w-10 text-center text-lg font-semibold text-[#163624]">{{ selectedQty(group) }}</div>
|
||||
<button class="btn btn-square btn-sm" :disabled="!selectedProduct(group)" @click="incrementSelected(group)">
|
||||
+
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="text-center text-sm font-medium text-base-content/55">{{ articleLabel(group) }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="getGroupState(group).isExpanded"
|
||||
class="mt-4 overflow-x-auto rounded-[28px] bg-white"
|
||||
>
|
||||
<table class="table border-separate border-spacing-0 bg-white [&_tbody_tr:hover]:bg-white [&_tbody_tr]:bg-white [&_td]:bg-white [&_th]:bg-white [&_thead_tr]:bg-white">
|
||||
<div class="space-y-4">
|
||||
<article
|
||||
v-for="field in visibleFields(selectedGroup)"
|
||||
:key="`${selectedGroup.key}-${field.key}`"
|
||||
class="rounded-[28px] border border-[#e6efe9] bg-white p-4 shadow-[0_18px_36px_rgba(18,56,36,0.05)]"
|
||||
>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<label
|
||||
v-for="option in getAllFieldOptions(selectedGroup, field.key)"
|
||||
:key="`${selectedGroup.key}-${field.key}-${option}`"
|
||||
class="cursor-pointer rounded-2xl border px-4 py-2 text-sm font-medium transition"
|
||||
:class="[
|
||||
getGroupState(selectedGroup)[field.key] === option
|
||||
? 'border-[#163624] bg-[#163624] text-white'
|
||||
: isOptionAvailable(selectedGroup, field.key, option)
|
||||
? 'border-[#dce9e1] bg-white text-[#163624] hover:border-[#163624]'
|
||||
: 'border-[#e6eaee] bg-[#f3f5f7] text-[#8a949d]',
|
||||
]"
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
class="sr-only"
|
||||
:name="`${selectedGroup.key}-${field.key}`"
|
||||
:checked="getGroupState(selectedGroup)[field.key] === option"
|
||||
@change="updateField(selectedGroup, field.key, option)"
|
||||
>
|
||||
<span>{{ formatOptionLabel(field.key, option) }}</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<details class="mt-3 rounded-[20px] bg-[#f7fbf8] px-4 py-3 text-sm text-[#587064]">
|
||||
<summary class="cursor-pointer font-medium text-[#355947]">Подробнее</summary>
|
||||
<p class="mt-2 leading-6">{{ fieldHelperText(selectedGroup, field.key) }}</p>
|
||||
</details>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<aside class="self-start xl:sticky xl:top-24">
|
||||
<div class="rounded-[30px] border border-[#e6efe9] bg-white p-5 shadow-[0_24px_48px_rgba(18,56,36,0.08)]">
|
||||
<p class="mt-1 text-lg font-medium leading-tight text-[#163624]">{{ articleLabel(selectedGroup) }}</p>
|
||||
|
||||
<button
|
||||
v-if="selectedQty(selectedGroup) === 0"
|
||||
class="btn mt-4 h-12 w-full rounded-full border-0 bg-[#139957] px-6 text-base font-semibold text-white hover:bg-[#0d854a]"
|
||||
:disabled="!selectedProduct(selectedGroup)"
|
||||
@click="incrementSelected(selectedGroup)"
|
||||
>
|
||||
В корзину
|
||||
</button>
|
||||
|
||||
<div
|
||||
v-else
|
||||
class="mt-4 flex items-center justify-between rounded-[24px] border border-[#dce9e1] bg-[#f8fbf9] px-2 py-2"
|
||||
>
|
||||
<button
|
||||
class="btn btn-square border-0 bg-white text-[#163624] shadow-none hover:bg-white"
|
||||
:disabled="selectedQty(selectedGroup) === 0"
|
||||
@click="decrementSelected(selectedGroup)"
|
||||
>
|
||||
-
|
||||
</button>
|
||||
<div class="min-w-12 text-center text-lg font-semibold text-[#163624]">{{ selectedQty(selectedGroup) }}</div>
|
||||
<button
|
||||
class="btn btn-square border-0 bg-white text-[#163624] shadow-none hover:bg-white"
|
||||
:disabled="!selectedProduct(selectedGroup)"
|
||||
@click="incrementSelected(selectedGroup)"
|
||||
>
|
||||
+
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
<div class="mt-8">
|
||||
<p class="mb-4 text-base font-semibold text-[#163624]">Доступные варианты</p>
|
||||
|
||||
<div class="overflow-x-auto rounded-[24px] border border-[#edf4ef] bg-white">
|
||||
<table class="table bg-white">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="border-b border-base-300">Артикул</th>
|
||||
<th class="border-b border-base-300">Ширина</th>
|
||||
<th class="border-b border-base-300">Длина</th>
|
||||
<th class="border-b border-base-300">Толщина</th>
|
||||
<th class="border-b border-base-300">Втулка</th>
|
||||
<th class="border-b border-base-300">Короб</th>
|
||||
<th class="border-b border-base-300 text-right">Действие</th>
|
||||
<tr class="text-[#587064]">
|
||||
<th>SKU</th>
|
||||
<th>Ширина</th>
|
||||
<th>Длина</th>
|
||||
<th>Толщина</th>
|
||||
<th>Втулка</th>
|
||||
<th>Цвет</th>
|
||||
<th>Надпись</th>
|
||||
<th>Остаток</th>
|
||||
<th class="text-right">Действие</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="product in group.products" :key="`${group.key}-${product.id}`">
|
||||
<td class="border-b border-base-200">{{ product.sku }}</td>
|
||||
<td class="border-b border-base-200">{{ product.widthMm ?? '—' }}</td>
|
||||
<td class="border-b border-base-200">{{ product.lengthM ?? '—' }}</td>
|
||||
<td class="border-b border-base-200">{{ product.thicknessMicron ?? '—' }}</td>
|
||||
<td class="border-b border-base-200">{{ product.sleeveBrand ?? '—' }}</td>
|
||||
<td class="border-b border-base-200">{{ product.quantityPerBox ?? '—' }}</td>
|
||||
<td class="border-b border-base-200 text-right">
|
||||
<tr v-for="product in selectedGroup.products" :key="`${selectedGroup.key}-${product.id}`" class="align-middle">
|
||||
<td class="font-semibold text-[#163624]">{{ product.sku }}</td>
|
||||
<td>{{ product.widthMm ?? '—' }}</td>
|
||||
<td>{{ product.lengthM ?? '—' }}</td>
|
||||
<td>{{ product.thicknessMicron ?? '—' }}</td>
|
||||
<td>{{ product.sleeveBrand ?? '—' }}</td>
|
||||
<td>{{ product.colorTags.join(', ') || '—' }}</td>
|
||||
<td>{{ product.labelTags.join(', ') || '—' }}</td>
|
||||
<td>
|
||||
<div class="flex min-w-[180px] items-center gap-3">
|
||||
<span class="h-3 w-3 rounded-sm" :class="availabilityTone(product)" />
|
||||
<div class="min-w-0">
|
||||
<p class="text-sm font-medium text-[#163624]">{{ availabilityLabel(product) }}</p>
|
||||
<p class="text-xs text-[#607569]">
|
||||
{{ totalAvailableQty(product) }}<span v-if="warehouseAvailability(product)"> · {{ warehouseAvailability(product) }}</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td class="text-right">
|
||||
<button
|
||||
v-if="getQuantity(product.id) === 0"
|
||||
class="btn h-9 rounded-full border-0 bg-[#139957] px-4 text-xs font-semibold text-white hover:bg-[#0d854a]"
|
||||
class="btn h-10 rounded-full border-0 bg-[#139957] px-5 text-sm font-semibold text-white hover:bg-[#0d854a]"
|
||||
@click="incrementProduct(product)"
|
||||
>
|
||||
В корзину
|
||||
</button>
|
||||
<div v-else class="ml-auto flex w-28 items-center justify-between rounded-xl border border-base-300 px-1 py-1">
|
||||
<div v-else class="ml-auto flex w-32 items-center justify-between rounded-[20px] border border-[#dce9e1] bg-white px-2 py-2">
|
||||
<button
|
||||
class="btn btn-xs btn-square"
|
||||
class="btn btn-xs btn-square border-0 bg-[#f3f7f4] text-[#163624] shadow-none hover:bg-[#eaf2ec]"
|
||||
:disabled="getQuantity(product.id) === 0"
|
||||
@click="decrementProduct(product.id)"
|
||||
>
|
||||
-
|
||||
</button>
|
||||
<span class="min-w-8 text-center text-sm font-semibold">{{ getQuantity(product.id) }}</span>
|
||||
<button class="btn btn-xs btn-square" @click="incrementProduct(product)">+</button>
|
||||
<span class="min-w-8 text-center text-sm font-semibold text-[#163624]">{{ getQuantity(product.id) }}</span>
|
||||
<button
|
||||
class="btn btn-xs btn-square border-0 bg-[#f3f7f4] text-[#163624] shadow-none hover:bg-[#eaf2ec]"
|
||||
@click="incrementProduct(product)"
|
||||
>
|
||||
+
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<button
|
||||
class="btn btn-ghost mt-3 w-full justify-center gap-2"
|
||||
@click="toggleExpanded(group)"
|
||||
>
|
||||
<svg
|
||||
class="h-4 w-4 transition-transform"
|
||||
:class="{ 'rotate-180': getGroupState(group).isExpanded }"
|
||||
viewBox="0 0 20 20"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M5 7.5L10 12.5L15 7.5"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.8"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
<span>{{ getGroupState(group).isExpanded ? 'Свернуть все варианты' : 'Развернуть все варианты' }}</span>
|
||||
</button>
|
||||
</article>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="alert surface-card border-0">По текущему запросу товары не найдены.</div>
|
||||
<div v-else class="alert surface-card border-0">Такой тип товара не найден.</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
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>
|
||||
31
app/components/orders/OrderDeliveryLine.stories.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import type { Meta, StoryObj } from '@storybook/vue3-vite';
|
||||
import OrderDeliveryLine from './OrderDeliveryLine.vue';
|
||||
|
||||
const meta: Meta<typeof OrderDeliveryLine> = {
|
||||
title: 'Orders/OrderDeliveryLine',
|
||||
component: OrderDeliveryLine,
|
||||
};
|
||||
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof OrderDeliveryLine>;
|
||||
|
||||
export const Readonly: Story = {
|
||||
args: {
|
||||
deliveryAddress: 'г. Казань, ул. Магистральная, 14',
|
||||
deliveryTerms: 'Доставка до склада за 2-3 рабочих дня',
|
||||
deliveryFee: 3200,
|
||||
},
|
||||
};
|
||||
|
||||
export const ManagerEditing: Story = {
|
||||
args: {
|
||||
mode: 'manager',
|
||||
canEdit: true,
|
||||
editingDeliveryTerms: true,
|
||||
editingDeliveryFee: true,
|
||||
deliveryAddress: 'г. Казань, ул. Магистральная, 14',
|
||||
deliveryTermsDraft: 'Доставка до склада за 2-3 рабочих дня',
|
||||
deliveryFeeDraft: '3200',
|
||||
},
|
||||
};
|
||||
162
app/components/orders/OrderDeliveryLine.vue
Normal file
@@ -0,0 +1,162 @@
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
orderDeliveryStateText,
|
||||
orderLogisticsStateText,
|
||||
} from '~/composables/useOrderDetailPresentation';
|
||||
|
||||
const props = defineProps<{
|
||||
deliveryAddress?: string | null;
|
||||
deliveryTerms?: string | null;
|
||||
deliveryFee?: number | null;
|
||||
mode?: 'readonly' | 'manager';
|
||||
canEdit?: boolean;
|
||||
editingDeliveryTerms?: boolean;
|
||||
editingDeliveryFee?: boolean;
|
||||
deliveryTermsDraft?: string;
|
||||
deliveryFeeDraft?: string;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:delivery-terms': [value: string];
|
||||
'update:delivery-fee': [value: string];
|
||||
'activate:delivery-terms': [];
|
||||
'finish:delivery-terms': [];
|
||||
'activate:delivery-fee': [];
|
||||
'finish:delivery-fee': [];
|
||||
}>();
|
||||
|
||||
const isManagerMode = computed(() => props.mode === 'manager');
|
||||
const isEditable = computed(() => isManagerMode.value && Boolean(props.canEdit));
|
||||
const addressLabel = computed(() => props.deliveryAddress?.trim() || 'Адрес пока не указан');
|
||||
const deliveryTermsLabel = computed(() => orderDeliveryStateText(props.deliveryTerms));
|
||||
const deliveryFeeLabel = computed(() => orderLogisticsStateText(props.deliveryFee));
|
||||
|
||||
function updateDeliveryTerms(event: Event) {
|
||||
emit('update:delivery-terms', (event.target as HTMLInputElement).value);
|
||||
}
|
||||
|
||||
function updateDeliveryFee(event: Event) {
|
||||
emit('update:delivery-fee', (event.target as HTMLInputElement).value);
|
||||
}
|
||||
|
||||
function activateDeliveryTerms() {
|
||||
if (!isEditable.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
emit('activate:delivery-terms');
|
||||
}
|
||||
|
||||
function finishDeliveryTerms() {
|
||||
emit('finish:delivery-terms');
|
||||
}
|
||||
|
||||
function activateDeliveryFee() {
|
||||
if (!isEditable.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
emit('activate:delivery-fee');
|
||||
}
|
||||
|
||||
function finishDeliveryFee() {
|
||||
emit('finish:delivery-fee');
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<article class="surface-card rounded-[28px] p-4 md:grid md:grid-cols-[minmax(0,1.8fr)_minmax(0,1fr)_160px] md:gap-4 md:p-5">
|
||||
<div class="flex min-w-0 gap-4">
|
||||
<div class="flex h-20 w-20 shrink-0 items-center justify-center rounded-[24px] bg-[#edf3ef] text-[#123824]">
|
||||
<svg class="h-8 w-8" viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
||||
<path d="M3 7.5H14V14.5H3V7.5Z" stroke="currentColor" stroke-width="1.8" stroke-linejoin="round" />
|
||||
<path d="M14 10H18.5L21 12.5V14.5H14V10Z" stroke="currentColor" stroke-width="1.8" stroke-linejoin="round" />
|
||||
<path d="M7 17.5C7.82843 17.5 8.5 16.8284 8.5 16C8.5 15.1716 7.82843 14.5 7 14.5C6.17157 14.5 5.5 15.1716 5.5 16C5.5 16.8284 6.17157 17.5 7 17.5Z" stroke="currentColor" stroke-width="1.8" />
|
||||
<path d="M17 17.5C17.8284 17.5 18.5 16.8284 18.5 16C18.5 15.1716 17.8284 14.5 17 14.5C16.1716 14.5 15.5 15.1716 15.5 16C15.5 16.8284 16.1716 17.5 17 17.5Z" stroke="currentColor" stroke-width="1.8" />
|
||||
<path d="M3 14.5H5.5" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" />
|
||||
<path d="M8.5 16H15.5" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" />
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<div class="min-w-0 space-y-2">
|
||||
<p class="text-base font-bold text-[#123824]">Доставка</p>
|
||||
<p class="text-xs font-semibold uppercase tracking-[0.14em] text-[#6a8a76]">
|
||||
Адрес доставки
|
||||
</p>
|
||||
<p class="text-sm leading-6 text-[#123824]">
|
||||
{{ addressLabel }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 md:mt-0">
|
||||
<p class="mb-1 text-[11px] font-semibold uppercase tracking-[0.18em] text-[#6a8a76]">
|
||||
Условия
|
||||
</p>
|
||||
|
||||
<label v-if="isManagerMode && editingDeliveryTerms" class="form-control">
|
||||
<input
|
||||
:value="deliveryTermsDraft ?? ''"
|
||||
data-delivery-terms-input
|
||||
type="text"
|
||||
placeholder="Например, доставка до склада 2-3 дня"
|
||||
class="input input-bordered manager-field w-full rounded-2xl bg-white"
|
||||
:disabled="!canEdit"
|
||||
@input="updateDeliveryTerms"
|
||||
@blur="finishDeliveryTerms"
|
||||
@keydown.enter="finishDeliveryTerms"
|
||||
>
|
||||
</label>
|
||||
|
||||
<button
|
||||
v-else-if="isManagerMode"
|
||||
type="button"
|
||||
class="w-full rounded-2xl px-0 py-0 text-left text-sm leading-6 text-[#123824]"
|
||||
:class="isEditable ? 'cursor-pointer transition hover:text-[#0d854a]' : 'cursor-default'"
|
||||
@dblclick="activateDeliveryTerms"
|
||||
>
|
||||
{{ deliveryTermsLabel }}
|
||||
</button>
|
||||
|
||||
<p v-else class="text-sm leading-6 text-[#123824]">
|
||||
{{ deliveryTermsLabel }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 md:mt-0">
|
||||
<p class="mb-1 text-[11px] font-semibold uppercase tracking-[0.18em] text-[#6a8a76]">
|
||||
Стоимость
|
||||
</p>
|
||||
|
||||
<label v-if="isManagerMode && editingDeliveryFee" class="form-control">
|
||||
<input
|
||||
:value="deliveryFeeDraft ?? ''"
|
||||
data-delivery-fee-input
|
||||
type="number"
|
||||
min="0"
|
||||
step="0.01"
|
||||
placeholder="Например, 3000"
|
||||
class="input input-bordered manager-field w-full rounded-2xl bg-white"
|
||||
:disabled="!canEdit"
|
||||
@input="updateDeliveryFee"
|
||||
@blur="finishDeliveryFee"
|
||||
@keydown.enter="finishDeliveryFee"
|
||||
>
|
||||
</label>
|
||||
|
||||
<button
|
||||
v-else-if="isManagerMode"
|
||||
type="button"
|
||||
class="w-full rounded-2xl px-0 py-0 text-left text-sm font-semibold text-[#123824]"
|
||||
:class="isEditable ? 'cursor-pointer transition hover:text-[#0d854a]' : 'cursor-default'"
|
||||
@dblclick="activateDeliveryFee"
|
||||
>
|
||||
{{ deliveryFeeLabel }}
|
||||
</button>
|
||||
|
||||
<p v-else class="text-sm font-semibold text-[#123824]">
|
||||
{{ deliveryFeeLabel }}
|
||||
</p>
|
||||
</div>
|
||||
</article>
|
||||
</template>
|
||||
269
app/components/orders/OrderItemsTable.vue
Normal file
@@ -0,0 +1,269 @@
|
||||
<script setup lang="ts">
|
||||
import { formatPrice } from '~/composables/useOrderDetailPresentation';
|
||||
|
||||
type OrderItemView = {
|
||||
id: string;
|
||||
productName: string;
|
||||
quantity: number;
|
||||
sku?: string | null;
|
||||
unitPrice?: number | null;
|
||||
lineTotal?: number | null;
|
||||
parameters?: Record<string, unknown> | null;
|
||||
};
|
||||
|
||||
type CalculationPayload = Record<string, unknown> | null | undefined;
|
||||
type ItemParameter = { label: string; value: string };
|
||||
|
||||
const props = defineProps<{
|
||||
items: OrderItemView[];
|
||||
calculationPayload?: CalculationPayload;
|
||||
mode?: 'readonly' | 'manager-pricing' | 'cart';
|
||||
editable?: boolean;
|
||||
unitPriceDrafts?: Record<string, string>;
|
||||
editablePriceItemIds?: string[];
|
||||
disabled?: boolean;
|
||||
framed?: boolean;
|
||||
pricePlaceholder?: string;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:unit-price': [payload: { itemId: string; value: string }];
|
||||
'activate:unit-price': [itemId: string];
|
||||
'finish:unit-price': [itemId: string];
|
||||
increment: [itemId: string];
|
||||
decrement: [itemId: string];
|
||||
remove: [itemId: string];
|
||||
}>();
|
||||
|
||||
const coverPresets = ['#edf3ef', '#f1f4ee', '#edf2f4'];
|
||||
|
||||
function createProductCover(name: string, seedKey: string) {
|
||||
const seed = `${name}${seedKey}`.split('').reduce((acc, char) => acc + char.charCodeAt(0), 0);
|
||||
const background = 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 120 120">
|
||||
<rect width="120" height="120" fill="${background}" rx="28" />
|
||||
<text x="50%" y="57%" text-anchor="middle" fill="#11412c" font-family="Manrope, sans-serif" font-size="44" font-weight="700">${firstLetter}</text>
|
||||
</svg>
|
||||
`.trim();
|
||||
|
||||
return `data:image/svg+xml;utf8,${encodeURIComponent(svg)}`;
|
||||
}
|
||||
|
||||
function parseMoneyDraft(value: string | undefined, fallback?: number | null) {
|
||||
const trimmed = String(value ?? '').replace(',', '.').trim();
|
||||
if (!trimmed) {
|
||||
return fallback ?? null;
|
||||
}
|
||||
|
||||
const normalized = Number(trimmed);
|
||||
if (!Number.isFinite(normalized) || normalized < 0) {
|
||||
return fallback ?? null;
|
||||
}
|
||||
|
||||
return Math.round((normalized + Number.EPSILON) * 100) / 100;
|
||||
}
|
||||
|
||||
function currentUnitPrice(item: OrderItemView) {
|
||||
if (!isPricingMode.value) {
|
||||
return item.unitPrice ?? null;
|
||||
}
|
||||
|
||||
return parseMoneyDraft(props.unitPriceDrafts?.[item.id]);
|
||||
}
|
||||
|
||||
function currentLineTotal(item: OrderItemView) {
|
||||
const unitPrice = currentUnitPrice(item);
|
||||
if (unitPrice == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return Math.round((unitPrice * item.quantity + Number.EPSILON) * 100) / 100;
|
||||
}
|
||||
|
||||
function updateUnitPrice(itemId: string, event: Event) {
|
||||
emit('update:unit-price', {
|
||||
itemId,
|
||||
value: (event.target as HTMLInputElement).value,
|
||||
});
|
||||
}
|
||||
|
||||
function activateUnitPrice(itemId: string) {
|
||||
if (!isPricingMode.value || props.disabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
emit('activate:unit-price', itemId);
|
||||
}
|
||||
|
||||
function finishUnitPrice(itemId: string) {
|
||||
emit('finish:unit-price', itemId);
|
||||
}
|
||||
|
||||
function incrementItem(itemId: string) {
|
||||
emit('increment', itemId);
|
||||
}
|
||||
|
||||
function decrementItem(itemId: string) {
|
||||
emit('decrement', itemId);
|
||||
}
|
||||
|
||||
function removeItem(itemId: string) {
|
||||
emit('remove', itemId);
|
||||
}
|
||||
|
||||
function formatParameterValue(value: unknown) {
|
||||
if (typeof value === 'number') {
|
||||
return String(value);
|
||||
}
|
||||
|
||||
return String(value ?? '').trim();
|
||||
}
|
||||
|
||||
const mode = computed(() => props.mode ?? (props.editable ? 'manager-pricing' : 'readonly'));
|
||||
const isPricingMode = computed(() => mode.value === 'manager-pricing');
|
||||
const isCartMode = computed(() => mode.value === 'cart');
|
||||
const isFramed = computed(() => props.framed ?? true);
|
||||
const editablePriceItemIds = computed(() => new Set(props.editablePriceItemIds ?? []));
|
||||
const displayPricePlaceholder = computed(() => props.pricePlaceholder ?? '—');
|
||||
|
||||
function mapParameterEntries(source: Record<string, unknown> | null | undefined): ItemParameter[] {
|
||||
if (!source || typeof source !== 'object') {
|
||||
return [];
|
||||
}
|
||||
|
||||
const variants = [
|
||||
{ key: 'width', label: 'Ширина', suffix: ' мм' },
|
||||
{ key: 'thickness', label: 'Толщина', suffix: ' мкм' },
|
||||
{ key: 'color', label: 'Цвет', suffix: '' },
|
||||
];
|
||||
|
||||
return variants
|
||||
.map((variant) => {
|
||||
const rawValue = source[variant.key];
|
||||
const value = formatParameterValue(rawValue);
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
label: variant.label,
|
||||
value: `${value}${variant.suffix}`,
|
||||
};
|
||||
})
|
||||
.filter(Boolean) as ItemParameter[];
|
||||
}
|
||||
|
||||
function itemParameters(item: OrderItemView) {
|
||||
if (item.parameters && typeof item.parameters === 'object') {
|
||||
return mapParameterEntries(item.parameters);
|
||||
}
|
||||
|
||||
if (props.items.length !== 1 || !props.calculationPayload || typeof props.calculationPayload !== 'object') {
|
||||
return [];
|
||||
}
|
||||
|
||||
return mapParameterEntries(props.calculationPayload);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="isFramed ? 'surface-card rounded-3xl p-5' : ''">
|
||||
<ul class="space-y-3">
|
||||
<li
|
||||
v-for="item in items"
|
||||
:key="item.id"
|
||||
class="surface-card rounded-[28px] p-4 md:grid md:grid-cols-[minmax(0,1.8fr)_140px_160px_140px] md:gap-4 md:p-5"
|
||||
>
|
||||
<div class="flex min-w-0 gap-4">
|
||||
<img
|
||||
:src="createProductCover(item.productName, item.id)"
|
||||
:alt="item.productName"
|
||||
class="h-20 w-20 shrink-0 rounded-[24px] bg-[#edf3ef] object-cover"
|
||||
>
|
||||
|
||||
<div class="min-w-0 space-y-2">
|
||||
<p class="text-base font-bold text-[#123824]">{{ item.productName }}</p>
|
||||
<p v-if="item.sku" class="text-xs font-semibold uppercase tracking-[0.14em] text-[#6a8a76]">
|
||||
SKU: {{ item.sku }}
|
||||
</p>
|
||||
|
||||
<div v-if="itemParameters(item).length > 0" class="flex flex-wrap gap-2">
|
||||
<span
|
||||
v-for="parameter in itemParameters(item)"
|
||||
:key="parameter.label"
|
||||
class="rounded-full bg-[#f3f5f4] px-3 py-1 text-xs font-semibold text-[#355947]"
|
||||
>
|
||||
{{ parameter.label }}: {{ parameter.value }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 md:mt-0">
|
||||
<p class="mb-1 text-[11px] font-semibold uppercase tracking-[0.18em] text-[#6a8a76] md:hidden">Цена</p>
|
||||
<input
|
||||
v-if="isPricingMode && editablePriceItemIds.has(item.id)"
|
||||
:value="unitPriceDrafts?.[item.id] ?? ''"
|
||||
type="number"
|
||||
min="0"
|
||||
step="0.01"
|
||||
placeholder="Например, 125.50"
|
||||
:data-unit-price-input="item.id"
|
||||
class="w-full rounded-2xl bg-[#f3f5f4] px-4 py-3 text-sm text-[#123824] outline-none transition focus:shadow-[0_0_0_3px_rgba(19,153,87,0.12)] disabled:cursor-not-allowed disabled:bg-[#f4f8f5]"
|
||||
:disabled="disabled"
|
||||
@input="updateUnitPrice(item.id, $event)"
|
||||
@blur="finishUnitPrice(item.id)"
|
||||
@keydown.enter="finishUnitPrice(item.id)"
|
||||
>
|
||||
<p
|
||||
v-else
|
||||
class="text-sm font-semibold text-[#123824]"
|
||||
:class="isPricingMode && !disabled ? 'cursor-pointer transition hover:text-[#0d854a]' : ''"
|
||||
@dblclick="activateUnitPrice(item.id)"
|
||||
>
|
||||
{{ formatPrice(currentUnitPrice(item)) || displayPricePlaceholder }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 md:mt-0">
|
||||
<p class="mb-1 text-[11px] font-semibold uppercase tracking-[0.18em] text-[#6a8a76] md:hidden">Количество</p>
|
||||
<div v-if="isCartMode" class="flex flex-wrap items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
class="flex h-9 w-9 items-center justify-center rounded-full bg-[#f3f5f4] text-lg font-semibold text-[#123824] transition hover:bg-[#e8efea] hover:text-[#139957]"
|
||||
@click="decrementItem(item.id)"
|
||||
>
|
||||
-
|
||||
</button>
|
||||
<span class="min-w-8 text-center text-sm font-semibold text-[#123824]">{{ item.quantity }}</span>
|
||||
<button
|
||||
type="button"
|
||||
class="flex h-9 w-9 items-center justify-center rounded-full bg-[#f3f5f4] text-lg font-semibold text-[#123824] transition hover:bg-[#e8efea] hover:text-[#139957]"
|
||||
@click="incrementItem(item.id)"
|
||||
>
|
||||
+
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="ml-1 text-sm font-semibold text-[#d94b55] transition hover:text-[#b73742]"
|
||||
@click="removeItem(item.id)"
|
||||
>
|
||||
Удалить
|
||||
</button>
|
||||
</div>
|
||||
<p v-else class="text-sm font-semibold text-[#123824]">{{ item.quantity }}</p>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 md:mt-0">
|
||||
<p class="mb-1 text-[11px] font-semibold uppercase tracking-[0.18em] text-[#6a8a76] md:hidden">Итог</p>
|
||||
<p class="text-sm font-bold text-[#123824]">
|
||||
{{ formatPrice(currentLineTotal(item)) || '—' }}
|
||||
</p>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,30 +1,26 @@
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
getOrderStatusBadgePresentation,
|
||||
type OrderStatusTone,
|
||||
} from '~/composables/useOrderStatusPresentation';
|
||||
|
||||
const props = defineProps<{
|
||||
status: string;
|
||||
}>();
|
||||
|
||||
const statusLabel = computed(() => {
|
||||
if (props.status === 'NEW') return 'Уточнение цены';
|
||||
if (props.status === 'MANAGER_PROCESSING') return 'В работе у менеджера';
|
||||
if (props.status === 'WAITING_DOUBLE_CONFIRM') return 'Ожидает подтверждения';
|
||||
if (props.status === 'CONFIRMED') return 'Подтвержден';
|
||||
if (props.status === 'IN_PROGRESS') return 'Выполняется';
|
||||
if (props.status === 'COMPLETED') return 'Завершен';
|
||||
if (props.status === 'CLIENT_REJECTED') return 'Отклонен клиентом';
|
||||
if (props.status === 'MANAGER_REJECTED') return 'Отклонен менеджером';
|
||||
if (props.status === 'MANAGER_BLOCKED') return 'Заблокирован';
|
||||
return props.status;
|
||||
});
|
||||
const badgePresentation = computed(() => getOrderStatusBadgePresentation(props.status));
|
||||
|
||||
const className = computed(() => {
|
||||
if (props.status === 'COMPLETED') return 'badge badge-success';
|
||||
if (props.status === 'CLIENT_REJECTED' || props.status === 'MANAGER_REJECTED') return 'badge badge-error';
|
||||
if (props.status === 'MANAGER_BLOCKED') return 'badge badge-warning';
|
||||
if (props.status === 'NEW') return 'badge badge-warning';
|
||||
return 'badge badge-info';
|
||||
});
|
||||
function dotClass(tone: OrderStatusTone) {
|
||||
if (tone === 'success') return 'bg-[#139957]';
|
||||
if (tone === 'danger') return 'bg-[#d94b55]';
|
||||
if (tone === 'warning') return 'bg-[#f1a43a]';
|
||||
return 'bg-[#2e8de4]';
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span :class="className">{{ statusLabel }}</span>
|
||||
<span class="inline-flex items-center gap-2 text-sm font-semibold leading-tight text-[#123824]">
|
||||
<span class="h-2.5 w-2.5 rounded-full" :class="dotClass(badgePresentation.tone)" />
|
||||
<span>{{ badgePresentation.label }}</span>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
195
app/components/orders/OrderStatusTimelineCard.vue
Normal file
@@ -0,0 +1,195 @@
|
||||
<script setup lang="ts">
|
||||
import OrderStatusBadge from '~/components/orders/OrderStatusBadge.vue';
|
||||
import {
|
||||
getOrderStatusBadgePresentation,
|
||||
getOrderStatusPresentation,
|
||||
type OrderStatusTone,
|
||||
} from '~/composables/useOrderStatusPresentation';
|
||||
|
||||
const props = defineProps<{
|
||||
status: string;
|
||||
createdAt: string | Date;
|
||||
audience?: 'client' | 'manager';
|
||||
}>();
|
||||
|
||||
const isExpanded = ref(false);
|
||||
|
||||
const presentation = computed(() => getOrderStatusPresentation(props.status, props.createdAt, props.audience ?? 'client'));
|
||||
const currentBadge = computed(() => getOrderStatusBadgePresentation(props.status));
|
||||
|
||||
function currentToneClass(tone: OrderStatusTone) {
|
||||
if (tone === 'success') {
|
||||
return {
|
||||
marker: 'bg-[#139957] ring-4 ring-[#dff4e8]',
|
||||
panel: 'bg-[#eef8f2]',
|
||||
title: 'text-[#123824]',
|
||||
note: 'text-[#355947]',
|
||||
date: 'text-[#139957]',
|
||||
connector: 'bg-[#bfe0cb]',
|
||||
};
|
||||
}
|
||||
if (tone === 'danger') {
|
||||
return {
|
||||
marker: 'bg-[#d94b55] ring-4 ring-[#fbe5e7]',
|
||||
panel: 'bg-[#fff1f2]',
|
||||
title: 'text-[#7e2130]',
|
||||
note: 'text-[#9b4150]',
|
||||
date: 'text-[#d94b55]',
|
||||
connector: 'bg-[#f1c6cb]',
|
||||
};
|
||||
}
|
||||
if (tone === 'warning') {
|
||||
return {
|
||||
marker: 'bg-[#f1a43a] ring-4 ring-[#fff0d9]',
|
||||
panel: 'bg-[#fff7eb]',
|
||||
title: 'text-[#6c4303]',
|
||||
note: 'text-[#8f6420]',
|
||||
date: 'text-[#c67d11]',
|
||||
connector: 'bg-[#efd1a2]',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
marker: 'bg-[#2e8de4] ring-4 ring-[#e3effb]',
|
||||
panel: 'bg-[#eef5fc]',
|
||||
title: 'text-[#174b7e]',
|
||||
note: 'text-[#436b92]',
|
||||
date: 'text-[#2e8de4]',
|
||||
connector: 'bg-[#c7dbef]',
|
||||
};
|
||||
}
|
||||
|
||||
function markerClass(state: 'done' | 'current' | 'upcoming') {
|
||||
if (state === 'current') {
|
||||
return currentToneClass(currentBadge.value.tone).marker;
|
||||
}
|
||||
if (state === 'done') {
|
||||
return 'bg-[#9dcfb0]';
|
||||
}
|
||||
return 'bg-[#d8e4dd]';
|
||||
}
|
||||
|
||||
function connectorClass(state: 'done' | 'current' | 'upcoming') {
|
||||
if (state === 'done' || state === 'current') {
|
||||
return state === 'current'
|
||||
? currentToneClass(currentBadge.value.tone).connector
|
||||
: 'bg-[#cfe5d7]';
|
||||
}
|
||||
|
||||
return 'bg-[#e4ece7]';
|
||||
}
|
||||
|
||||
function titleClass(state: 'done' | 'current' | 'upcoming') {
|
||||
if (state === 'current') {
|
||||
return currentToneClass(currentBadge.value.tone).title;
|
||||
}
|
||||
if (state === 'done') {
|
||||
return 'text-[#355947]';
|
||||
}
|
||||
|
||||
return 'text-[#6a8a76]';
|
||||
}
|
||||
|
||||
function noteClass(state: 'done' | 'current' | 'upcoming') {
|
||||
if (state === 'current') {
|
||||
return currentToneClass(currentBadge.value.tone).note;
|
||||
}
|
||||
if (state === 'done') {
|
||||
return 'text-[#557562]';
|
||||
}
|
||||
|
||||
return 'text-[#7d9688]';
|
||||
}
|
||||
|
||||
function stagePanelClass(state: 'done' | 'current' | 'upcoming') {
|
||||
if (state === 'current') {
|
||||
return currentToneClass(currentBadge.value.tone).panel;
|
||||
}
|
||||
if (state === 'done') {
|
||||
return 'bg-[#f3f7f4]';
|
||||
}
|
||||
|
||||
return 'bg-[#f7faf8]';
|
||||
}
|
||||
|
||||
function dateClass(state: 'done' | 'current' | 'upcoming') {
|
||||
if (state === 'current') {
|
||||
return currentToneClass(currentBadge.value.tone).date;
|
||||
}
|
||||
if (state === 'done') {
|
||||
return 'text-[#5c7b69]';
|
||||
}
|
||||
|
||||
return 'text-[#86a091]';
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="surface-card rounded-3xl p-5">
|
||||
<button
|
||||
type="button"
|
||||
class="flex w-full items-start justify-between gap-4 text-left"
|
||||
@click="isExpanded = !isExpanded"
|
||||
>
|
||||
<div class="space-y-3">
|
||||
<div class="flex flex-wrap items-center gap-3">
|
||||
<h2 class="text-2xl font-black leading-tight text-[#123824]">
|
||||
{{ presentation.title }}
|
||||
</h2>
|
||||
<OrderStatusBadge :status="status" />
|
||||
</div>
|
||||
<p class="max-w-2xl text-sm leading-6 text-[#355947]">
|
||||
{{ presentation.summary }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-3 pt-1">
|
||||
<span
|
||||
class="flex h-10 w-10 items-center justify-center rounded-full bg-[#f2f5f3] text-[#123824] transition-transform"
|
||||
:class="{ 'rotate-180': isExpanded }"
|
||||
>
|
||||
<svg class="h-4 w-4" viewBox="0 0 20 20" fill="none">
|
||||
<path d="M5 7.5L10 12.5L15 7.5" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" />
|
||||
</svg>
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<div v-if="isExpanded" class="mt-5 pt-1">
|
||||
<div
|
||||
v-for="(stage, index) in presentation.stages"
|
||||
:key="stage.code"
|
||||
class="flex gap-4"
|
||||
>
|
||||
<div class="flex w-4 shrink-0 flex-col items-center">
|
||||
<span class="mt-1 h-3 w-3 rounded-full" :class="markerClass(stage.state)" />
|
||||
<span
|
||||
v-if="index < presentation.stages.length - 1"
|
||||
class="mt-2 w-px flex-1"
|
||||
:class="connectorClass(stage.state)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="min-w-0 flex-1 pb-5">
|
||||
<div
|
||||
class="rounded-[22px] px-4 py-4 transition-colors"
|
||||
:class="stagePanelClass(stage.state)"
|
||||
>
|
||||
<div class="flex flex-wrap items-center justify-between gap-2">
|
||||
<p class="text-sm font-semibold" :class="titleClass(stage.state)">
|
||||
{{ stage.label }}
|
||||
</p>
|
||||
<p class="text-xs font-semibold uppercase tracking-[0.12em]" :class="dateClass(stage.state)">
|
||||
{{ stage.dateLabel }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<p class="mt-2 text-sm leading-6" :class="noteClass(stage.state)">
|
||||
{{ stage.note }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
100
app/components/orders/OrderSummaryCard.vue
Normal file
@@ -0,0 +1,100 @@
|
||||
<script setup lang="ts">
|
||||
import OrderStatusBadge from '~/components/orders/OrderStatusBadge.vue';
|
||||
import { formatOrderCode } from '~/composables/useOrderCodePresentation';
|
||||
import { formatPrice } from '~/composables/useOrderDetailPresentation';
|
||||
|
||||
type OrderCardItem = {
|
||||
id: string;
|
||||
productName: string;
|
||||
quantity: number;
|
||||
};
|
||||
|
||||
const props = defineProps<{
|
||||
to: string;
|
||||
code: string;
|
||||
status: string;
|
||||
createdAt: string | Date;
|
||||
totalPrice?: number | null;
|
||||
items: OrderCardItem[];
|
||||
}>();
|
||||
|
||||
const DATE_FORMATTER = new Intl.DateTimeFormat('ru-RU', {
|
||||
day: '2-digit',
|
||||
month: 'short',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
|
||||
const coverPresets = ['#edf3ef', '#f1f4ee', '#edf2f4'];
|
||||
|
||||
function formatCreatedAt(value: string | Date) {
|
||||
return DATE_FORMATTER.format(new Date(value));
|
||||
}
|
||||
|
||||
function createProductCover(name: string, seedKey: string) {
|
||||
const seed = `${name}${seedKey}`.split('').reduce((acc, char) => acc + char.charCodeAt(0), 0);
|
||||
const background = 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 88 88">
|
||||
<rect width="88" height="88" fill="${background}" rx="24" />
|
||||
<text x="50%" y="56%" text-anchor="middle" fill="#11412c" font-family="Manrope, sans-serif" font-size="34" font-weight="700">${firstLetter}</text>
|
||||
</svg>
|
||||
`.trim();
|
||||
|
||||
return `data:image/svg+xml;utf8,${encodeURIComponent(svg)}`;
|
||||
}
|
||||
|
||||
const visibleItems = computed(() => props.items.slice(0, 4));
|
||||
const hiddenCount = computed(() => Math.max(props.items.length - visibleItems.value.length, 0));
|
||||
const totalPriceLabel = computed(() => formatPrice(props.totalPrice) ?? '—');
|
||||
const codeLabel = computed(() => formatOrderCode(props.code));
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<NuxtLink
|
||||
:to="to"
|
||||
class="surface-card surface-card-interactive group block rounded-[30px] bg-white px-4 py-4 md:px-5"
|
||||
>
|
||||
<div class="grid gap-4 md:grid-cols-4 md:items-center md:gap-6">
|
||||
<div class="min-w-0">
|
||||
<h2 class="truncate text-lg font-bold text-[#123824]">{{ codeLabel }}</h2>
|
||||
<p class="mt-1 text-sm text-[#688676]">{{ formatCreatedAt(createdAt) }}</p>
|
||||
</div>
|
||||
|
||||
<div class="min-w-0">
|
||||
<div class="flex flex-wrap items-center gap-2 md:gap-1">
|
||||
<div class="flex -space-x-3">
|
||||
<div
|
||||
v-for="item in visibleItems"
|
||||
:key="item.id"
|
||||
class="h-11 w-11 overflow-hidden rounded-[16px] bg-[#edf3ef]"
|
||||
:title="`${item.productName} × ${item.quantity}`"
|
||||
>
|
||||
<img
|
||||
:src="createProductCover(item.productName, item.id)"
|
||||
:alt="item.productName"
|
||||
class="h-full w-full object-cover"
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="hiddenCount > 0"
|
||||
class="flex h-11 w-11 items-center justify-center rounded-[16px] bg-[#edf3ef] text-xs font-bold text-[#123824]"
|
||||
>
|
||||
+{{ hiddenCount }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-start">
|
||||
<OrderStatusBadge :status="status" />
|
||||
</div>
|
||||
|
||||
<div class="text-left md:text-right">
|
||||
<p class="text-base font-bold text-[#123824]">{{ totalPriceLabel }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</NuxtLink>
|
||||
</template>
|
||||
@@ -1,8 +1,4 @@
|
||||
<script setup lang="ts">
|
||||
import { useQuery } from '@vue/apollo-composable';
|
||||
import { MeDocument } from '~/composables/graphql/generated';
|
||||
import { hasManagerAccess } from '~/utils/roles';
|
||||
|
||||
type NavItem = {
|
||||
to: string;
|
||||
label: string;
|
||||
@@ -10,24 +6,10 @@ type NavItem = {
|
||||
|
||||
const route = useRoute();
|
||||
const { totalItems } = useClientCart();
|
||||
const meQuery = useQuery(MeDocument);
|
||||
|
||||
const centerCapsule = computed<NavItem[]>(() => {
|
||||
const items: NavItem[] = [
|
||||
{ to: '/', label: 'Каталог' },
|
||||
{ to: '/orders', label: 'Мои заказы' },
|
||||
];
|
||||
|
||||
if (hasManagerAccess(meQuery.result.value?.me?.role)) {
|
||||
items.push(
|
||||
{ to: '/clients', label: 'Пользователи' },
|
||||
{ to: '/client-orders', label: 'Заказы' },
|
||||
{ to: '/bonus-system', label: 'Бонусы' },
|
||||
);
|
||||
}
|
||||
|
||||
return items;
|
||||
});
|
||||
const centerCapsule: NavItem[] = [
|
||||
{ to: '/', label: 'Каталог' },
|
||||
{ to: '/orders', label: 'Мои заказы' },
|
||||
];
|
||||
|
||||
const rightCapsule: NavItem[] = [
|
||||
{ to: '/cart', label: 'Корзина' },
|
||||
|
||||
59
app/components/ui/AppManagerDock.vue
Normal file
@@ -0,0 +1,59 @@
|
||||
<script setup lang="ts">
|
||||
type DockItem = {
|
||||
to: string;
|
||||
label: string;
|
||||
icon: 'orders' | 'bonus' | 'settings';
|
||||
};
|
||||
|
||||
const route = useRoute();
|
||||
|
||||
const dockItems: DockItem[] = [
|
||||
{ to: '/admin/orders', label: 'Заказы', icon: 'orders' },
|
||||
{ to: '/admin/bonuses/balances', label: 'Бонусы', icon: 'bonus' },
|
||||
{ to: '/admin/settings/messages', label: 'Настройки', icon: 'settings' },
|
||||
];
|
||||
|
||||
function isActive(path: string) {
|
||||
if (path === '/admin/orders') {
|
||||
return route.path === '/admin/orders' || route.path.startsWith('/admin/orders/');
|
||||
}
|
||||
if (path === '/admin/bonuses/balances') {
|
||||
return route.path.startsWith('/admin/bonuses');
|
||||
}
|
||||
if (path === '/admin/settings/messages') {
|
||||
return route.path.startsWith('/admin/settings');
|
||||
}
|
||||
return route.path === path;
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<nav class="manager-dock-shell" aria-label="Навигация менеджера">
|
||||
<div class="manager-dock">
|
||||
<NuxtLink
|
||||
v-for="item in dockItems"
|
||||
:key="item.to"
|
||||
:to="item.to"
|
||||
class="manager-dock__item"
|
||||
:class="{ 'manager-dock__item--active': isActive(item.to) }"
|
||||
>
|
||||
<span class="manager-dock__icon" aria-hidden="true">
|
||||
<svg v-if="item.icon === 'orders'" viewBox="0 0 24 24" fill="none" class="h-5 w-5">
|
||||
<path d="M7 6.75H17" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" />
|
||||
<path d="M7 12H17" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" />
|
||||
<path d="M7 17.25H13" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" />
|
||||
<rect x="4" y="4" width="16" height="16" rx="4" stroke="currentColor" stroke-width="1.8" />
|
||||
</svg>
|
||||
<svg v-else-if="item.icon === 'bonus'" viewBox="0 0 24 24" fill="none" class="h-5 w-5">
|
||||
<path d="M12 4.75L14.2401 9.28984L19.25 10.0172L15.625 13.5504L16.4802 18.5398L12 16.1848L7.51983 18.5398L8.375 13.5504L4.75 10.0172L9.75987 9.28984L12 4.75Z" stroke="currentColor" stroke-width="1.8" stroke-linejoin="round" />
|
||||
</svg>
|
||||
<svg v-else viewBox="0 0 24 24" fill="none" class="h-5 w-5">
|
||||
<path d="M12 8.25C9.92893 8.25 8.25 9.92893 8.25 12C8.25 14.0711 9.92893 15.75 12 15.75C14.0711 15.75 15.75 14.0711 15.75 12C15.75 9.92893 14.0711 8.25 12 8.25Z" stroke="currentColor" stroke-width="1.8" />
|
||||
<path d="M19.25 13.25V10.75L17.4539 10.2018C17.3255 9.82617 17.1745 9.46304 17.0023 9.11134L17.875 7.45833L16.0417 5.625L14.3887 6.4977C14.037 6.32552 13.6738 6.17449 13.2982 6.04607L12.75 4.25H10.25L9.70183 6.04607C9.32617 6.17449 8.96304 6.32552 8.61134 6.4977L6.95833 5.625L5.125 7.45833L5.9977 9.11134C5.82552 9.46304 5.67449 9.82617 5.54607 10.2018L3.75 10.75V13.25L5.54607 13.7982C5.67449 14.1738 5.82552 14.537 5.9977 14.8887L5.125 16.5417L6.95833 18.375L8.61134 17.5023C8.96304 17.6745 9.32617 17.8255 9.70183 17.9539L10.25 19.75H12.75L13.2982 17.9539C13.6738 17.8255 14.037 17.6745 14.3887 17.5023L16.0417 18.375L17.875 16.5417L17.0023 14.8887C17.1745 14.537 17.3255 14.1738 17.4539 13.7982L19.25 13.25Z" stroke="currentColor" stroke-width="1.8" stroke-linejoin="round" />
|
||||
</svg>
|
||||
</span>
|
||||
<span class="manager-dock__label">{{ item.label }}</span>
|
||||
</NuxtLink>
|
||||
</div>
|
||||
</nav>
|
||||
</template>
|
||||
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>
|
||||
@@ -21,7 +21,7 @@ function updateValue(event: Event) {
|
||||
<slot name="tabs" />
|
||||
|
||||
<div class="flex flex-col gap-3 md:flex-row md:items-center">
|
||||
<label class="input input-bordered flex w-full flex-1 items-center gap-3 rounded-full bg-white">
|
||||
<label class="flex w-full flex-1 items-center gap-3 rounded-full border border-[#d7e9de] bg-white px-4 py-3 transition focus-within:border-[#139957] focus-within:shadow-[0_0_0_3px_rgba(19,153,87,0.12)]">
|
||||
<svg
|
||||
class="h-4 w-4 shrink-0 text-base-content/45"
|
||||
viewBox="0 0 20 20"
|
||||
@@ -46,7 +46,7 @@ function updateValue(event: Event) {
|
||||
<input
|
||||
:value="props.modelValue"
|
||||
type="text"
|
||||
class="grow"
|
||||
class="min-w-0 grow bg-transparent text-sm text-[#123824] outline-none placeholder:text-[#7b9487]"
|
||||
:placeholder="props.searchPlaceholder"
|
||||
@input="updateValue"
|
||||
>
|
||||
|
||||
45
app/components/users/GridCard.vue
Normal file
@@ -0,0 +1,45 @@
|
||||
<script setup lang="ts">
|
||||
withDefaults(defineProps<{
|
||||
to: string;
|
||||
fullName: string;
|
||||
avatarSrc?: string;
|
||||
initials?: string;
|
||||
metaLabel?: string;
|
||||
metaValue?: string;
|
||||
}>(), {
|
||||
avatarSrc: '',
|
||||
initials: 'FR',
|
||||
metaLabel: '',
|
||||
metaValue: '',
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<NuxtLink
|
||||
:to="to"
|
||||
class="surface-card surface-card-interactive flex flex-col items-center rounded-[32px] p-6 text-center"
|
||||
>
|
||||
<img
|
||||
v-if="avatarSrc"
|
||||
:src="avatarSrc"
|
||||
:alt="fullName"
|
||||
class="h-24 w-24 rounded-[32px] object-cover shadow-[0_12px_30px_rgba(18,56,36,0.14)]"
|
||||
>
|
||||
<div
|
||||
v-else
|
||||
class="flex h-24 w-24 items-center justify-center rounded-[32px] bg-[linear-gradient(135deg,#dff7e9_0%,#c2ead3_100%)] text-3xl font-black text-[#123824] shadow-[inset_0_1px_0_rgba(255,255,255,0.65)]"
|
||||
>
|
||||
{{ initials }}
|
||||
</div>
|
||||
|
||||
<h2 class="mt-8 text-lg font-bold leading-tight text-[#123824]">{{ fullName }}</h2>
|
||||
|
||||
<div
|
||||
v-if="metaValue"
|
||||
class="mt-4 inline-flex flex-wrap items-center justify-center gap-2 rounded-full border border-[#e1c15a] bg-[#fff8dc] px-4 py-2 text-sm text-[#7a5b00]"
|
||||
>
|
||||
<span v-if="metaLabel" class="font-semibold">{{ metaLabel }}</span>
|
||||
<span class="font-bold text-[#123824]">{{ metaValue }}</span>
|
||||
</div>
|
||||
</NuxtLink>
|
||||
</template>
|
||||
93
app/composables/useIncrementalList.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
import { computed, onBeforeUnmount, onMounted, ref, unref, watch, type ComputedRef, type Ref } from 'vue';
|
||||
|
||||
type ReactiveValue<T> = Ref<T> | ComputedRef<T>;
|
||||
|
||||
type UseIncrementalListOptions = {
|
||||
pageSize?: number;
|
||||
enabled?: ReactiveValue<boolean>;
|
||||
resetKeys?: Array<ReactiveValue<unknown> | unknown>;
|
||||
};
|
||||
|
||||
export function useIncrementalList<T>(
|
||||
items: ReactiveValue<T[]>,
|
||||
options: UseIncrementalListOptions = {},
|
||||
) {
|
||||
const pageSize = options.pageSize ?? 24;
|
||||
const enabled = computed(() => unref(options.enabled ?? true));
|
||||
const visibleCount = ref(pageSize);
|
||||
const loadMoreSentinel = ref<HTMLElement | null>(null);
|
||||
let observer: IntersectionObserver | null = null;
|
||||
|
||||
const visibleItems = computed(() => (
|
||||
enabled.value
|
||||
? items.value.slice(0, visibleCount.value)
|
||||
: items.value
|
||||
));
|
||||
|
||||
const canLoadMore = computed(() => (
|
||||
enabled.value && visibleCount.value < items.value.length
|
||||
));
|
||||
|
||||
const remainingCount = computed(() => Math.max(items.value.length - visibleCount.value, 0));
|
||||
|
||||
function loadMore() {
|
||||
visibleCount.value = Math.min(visibleCount.value + pageSize, items.value.length);
|
||||
}
|
||||
|
||||
function resetVisibleCount() {
|
||||
visibleCount.value = pageSize;
|
||||
}
|
||||
|
||||
function disconnectObserver() {
|
||||
observer?.disconnect();
|
||||
observer = null;
|
||||
}
|
||||
|
||||
function connectObserver() {
|
||||
disconnectObserver();
|
||||
|
||||
if (!canLoadMore.value || !loadMoreSentinel.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
observer = new IntersectionObserver((entries) => {
|
||||
if (!entries.some((entry) => entry.isIntersecting)) {
|
||||
return;
|
||||
}
|
||||
|
||||
loadMore();
|
||||
}, {
|
||||
rootMargin: '240px 0px',
|
||||
});
|
||||
|
||||
observer.observe(loadMoreSentinel.value);
|
||||
}
|
||||
|
||||
const resetSignature = computed(() => JSON.stringify([
|
||||
enabled.value,
|
||||
items.value.length,
|
||||
...((options.resetKeys ?? []).map((entry) => unref(entry as ReactiveValue<unknown>))),
|
||||
]));
|
||||
|
||||
watch(resetSignature, resetVisibleCount, { immediate: true });
|
||||
watch(canLoadMore, () => {
|
||||
if (!canLoadMore.value) {
|
||||
disconnectObserver();
|
||||
}
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
watch([loadMoreSentinel, canLoadMore], connectObserver, { immediate: true });
|
||||
});
|
||||
|
||||
onBeforeUnmount(disconnectObserver);
|
||||
|
||||
return {
|
||||
canLoadMore,
|
||||
loadMore,
|
||||
loadMoreSentinel,
|
||||
remainingCount,
|
||||
visibleCount,
|
||||
visibleItems,
|
||||
};
|
||||
}
|
||||
63
app/composables/useMaxMiniApp.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
type MaxMiniAppUser = {
|
||||
id: number | string;
|
||||
first_name?: string;
|
||||
last_name?: string;
|
||||
username?: string;
|
||||
language_code?: string;
|
||||
photo_url?: string;
|
||||
};
|
||||
|
||||
type MaxWebApp = {
|
||||
initData?: string;
|
||||
initDataUnsafe?: {
|
||||
user?: MaxMiniAppUser;
|
||||
start_param?: string;
|
||||
};
|
||||
ready?: () => void;
|
||||
close?: () => void;
|
||||
openLink?: (url: string) => void;
|
||||
openMaxLink?: (url: string) => void;
|
||||
platform?: string;
|
||||
version?: string;
|
||||
};
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
WebApp?: MaxWebApp;
|
||||
}
|
||||
}
|
||||
|
||||
function buildDisplayName(user: MaxMiniAppUser | null) {
|
||||
if (!user) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const firstName = String(user.first_name || '').trim();
|
||||
const lastName = String(user.last_name || '').trim();
|
||||
return `${firstName} ${lastName}`.trim() || firstName || String(user.username || '').trim();
|
||||
}
|
||||
|
||||
export function useMaxMiniApp() {
|
||||
const webApp = computed<MaxWebApp | null>(() => {
|
||||
if (!import.meta.client) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return window.WebApp ?? null;
|
||||
});
|
||||
|
||||
const initData = computed(() => String(webApp.value?.initData || '').trim());
|
||||
const user = computed<MaxMiniAppUser | null>(() => webApp.value?.initDataUnsafe?.user ?? null);
|
||||
const startParam = computed(() => String(webApp.value?.initDataUnsafe?.start_param || '').trim());
|
||||
const isAvailable = computed(() => Boolean(initData.value));
|
||||
const displayName = computed(() => buildDisplayName(user.value));
|
||||
|
||||
return {
|
||||
webApp,
|
||||
initData,
|
||||
user,
|
||||
startParam,
|
||||
isAvailable,
|
||||
displayName,
|
||||
};
|
||||
}
|
||||
45
app/composables/useMessengerMiniApp.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import { decodeMessengerMiniAppStartParam } from '~/composables/useMessengerMiniAppStart';
|
||||
|
||||
export function useMessengerMiniApp() {
|
||||
const telegramMiniApp = useTelegramMiniApp();
|
||||
const maxMiniApp = useMaxMiniApp();
|
||||
|
||||
const channel = computed<'TELEGRAM' | 'MAX' | null>(() => {
|
||||
if (maxMiniApp.isAvailable.value) {
|
||||
return 'MAX';
|
||||
}
|
||||
|
||||
if (telegramMiniApp.isAvailable.value) {
|
||||
return 'TELEGRAM';
|
||||
}
|
||||
|
||||
return null;
|
||||
});
|
||||
|
||||
const isAvailable = computed(() => channel.value !== null);
|
||||
const initData = computed(() =>
|
||||
channel.value === 'MAX' ? maxMiniApp.initData.value : telegramMiniApp.initData.value,
|
||||
);
|
||||
const startParam = computed(() =>
|
||||
channel.value === 'MAX' ? maxMiniApp.startParam.value : telegramMiniApp.startParam.value,
|
||||
);
|
||||
const startPath = computed(() => decodeMessengerMiniAppStartParam(startParam.value));
|
||||
const displayName = computed(() =>
|
||||
channel.value === 'MAX' ? maxMiniApp.displayName.value : telegramMiniApp.displayName.value,
|
||||
);
|
||||
const channelLabel = computed(() =>
|
||||
channel.value === 'MAX' ? 'MAX' : channel.value === 'TELEGRAM' ? 'Telegram' : '',
|
||||
);
|
||||
|
||||
return {
|
||||
channel,
|
||||
channelLabel,
|
||||
isAvailable,
|
||||
initData,
|
||||
startParam,
|
||||
startPath,
|
||||
displayName,
|
||||
telegramMiniApp,
|
||||
maxMiniApp,
|
||||
};
|
||||
}
|
||||
125
app/composables/useMessengerMiniAppStart.ts
Normal file
@@ -0,0 +1,125 @@
|
||||
const MESSENGER_MINI_APP_PATH_PREFIX = 'path_';
|
||||
|
||||
function getBuffer() {
|
||||
return (globalThis as typeof globalThis & {
|
||||
Buffer?: {
|
||||
from(input: string | Uint8Array, encoding?: string): {
|
||||
toString(encoding?: string): string;
|
||||
};
|
||||
};
|
||||
}).Buffer;
|
||||
}
|
||||
|
||||
function encodeUtf8(value: string) {
|
||||
if (typeof TextEncoder !== 'undefined') {
|
||||
return new TextEncoder().encode(value);
|
||||
}
|
||||
|
||||
const buffer = getBuffer();
|
||||
if (buffer) {
|
||||
return Uint8Array.from(buffer.from(value, 'utf8'));
|
||||
}
|
||||
|
||||
return Uint8Array.from(value.split('').map((char) => char.charCodeAt(0)));
|
||||
}
|
||||
|
||||
function decodeUtf8(bytes: Uint8Array) {
|
||||
if (typeof TextDecoder !== 'undefined') {
|
||||
return new TextDecoder().decode(bytes);
|
||||
}
|
||||
|
||||
const buffer = getBuffer();
|
||||
if (buffer) {
|
||||
return buffer.from(bytes).toString('utf8');
|
||||
}
|
||||
|
||||
return String.fromCharCode(...bytes);
|
||||
}
|
||||
|
||||
function toBase64(bytes: Uint8Array) {
|
||||
const buffer = getBuffer();
|
||||
if (buffer) {
|
||||
return buffer.from(bytes).toString('base64');
|
||||
}
|
||||
|
||||
let binary = '';
|
||||
for (const byte of bytes) {
|
||||
binary += String.fromCharCode(byte);
|
||||
}
|
||||
return btoa(binary);
|
||||
}
|
||||
|
||||
function fromBase64(value: string) {
|
||||
const buffer = getBuffer();
|
||||
if (buffer) {
|
||||
return Uint8Array.from(buffer.from(value, 'base64'));
|
||||
}
|
||||
|
||||
const binary = atob(value);
|
||||
return Uint8Array.from(binary, (char) => char.charCodeAt(0));
|
||||
}
|
||||
|
||||
function normalizePath(value: string) {
|
||||
const normalizedPath = String(value || '').trim();
|
||||
return normalizedPath.startsWith('/') ? normalizedPath : '';
|
||||
}
|
||||
|
||||
function toBase64Url(value: string) {
|
||||
return toBase64(encodeUtf8(value))
|
||||
.replace(/\+/g, '-')
|
||||
.replace(/\//g, '_')
|
||||
.replace(/=+$/g, '');
|
||||
}
|
||||
|
||||
function fromBase64Url(value: string) {
|
||||
const normalizedValue = value.replace(/-/g, '+').replace(/_/g, '/');
|
||||
const padding = normalizedValue.length % 4 === 0 ? '' : '='.repeat(4 - (normalizedValue.length % 4));
|
||||
return decodeUtf8(fromBase64(`${normalizedValue}${padding}`));
|
||||
}
|
||||
|
||||
function readWindowStartParam() {
|
||||
if (!import.meta.client) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const telegramStartParam = String(window.Telegram?.WebApp?.initDataUnsafe?.start_param || '').trim();
|
||||
if (telegramStartParam) {
|
||||
return telegramStartParam;
|
||||
}
|
||||
|
||||
return String(window.WebApp?.initDataUnsafe?.start_param || '').trim();
|
||||
}
|
||||
|
||||
export function encodeMessengerMiniAppPath(path: string) {
|
||||
const normalizedPath = normalizePath(path);
|
||||
if (!normalizedPath) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return `${MESSENGER_MINI_APP_PATH_PREFIX}${toBase64Url(normalizedPath)}`;
|
||||
}
|
||||
|
||||
export function decodeMessengerMiniAppStartParam(startParam: string) {
|
||||
const normalizedStartParam = String(startParam || '').trim();
|
||||
if (!normalizedStartParam) {
|
||||
return '';
|
||||
}
|
||||
|
||||
if (normalizedStartParam.startsWith('/')) {
|
||||
return normalizePath(normalizedStartParam);
|
||||
}
|
||||
|
||||
if (!normalizedStartParam.startsWith(MESSENGER_MINI_APP_PATH_PREFIX)) {
|
||||
return '';
|
||||
}
|
||||
|
||||
try {
|
||||
return normalizePath(fromBase64Url(normalizedStartParam.slice(MESSENGER_MINI_APP_PATH_PREFIX.length)));
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
export function readMessengerMiniAppStartPath() {
|
||||
return decodeMessengerMiniAppStartParam(readWindowStartParam());
|
||||
}
|
||||
@@ -18,6 +18,7 @@ type MessengerStartInput = {
|
||||
|
||||
export function useMessengerStart() {
|
||||
const pendingChannel = ref<MessengerChannel | null>(null);
|
||||
const maxMiniApp = useMaxMiniApp();
|
||||
|
||||
async function openMessengerBot({ channel, baseUrl, email, redirectPath }: MessengerStartInput) {
|
||||
pendingChannel.value = channel;
|
||||
@@ -38,7 +39,17 @@ export function useMessengerStart() {
|
||||
|
||||
const startUrl = buildMessengerBotStartUrl(baseUrl, payload.startToken);
|
||||
if (import.meta.client) {
|
||||
window.open(startUrl, '_blank', 'noopener,noreferrer');
|
||||
if (
|
||||
channel === 'MAX'
|
||||
&& maxMiniApp.isAvailable.value
|
||||
&& startUrl.startsWith('https://max.ru/')
|
||||
&& typeof maxMiniApp.webApp.value?.openMaxLink === 'function'
|
||||
) {
|
||||
maxMiniApp.webApp.value.openMaxLink(startUrl);
|
||||
}
|
||||
else {
|
||||
window.open(startUrl, '_blank', 'noopener,noreferrer');
|
||||
}
|
||||
}
|
||||
|
||||
return payload;
|
||||
|
||||
17
app/composables/useOrderCodePresentation.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
export function extractOrderCodeShort(code?: string | null) {
|
||||
const normalized = String(code ?? '').trim();
|
||||
if (!normalized) {
|
||||
return '0000';
|
||||
}
|
||||
|
||||
const digits = normalized.replace(/\D/g, '');
|
||||
if (digits.length >= 4) {
|
||||
return digits.slice(-4);
|
||||
}
|
||||
|
||||
return normalized.slice(-4).toUpperCase().padStart(4, '0');
|
||||
}
|
||||
|
||||
export function formatOrderCode(code?: string | null) {
|
||||
return `#${extractOrderCodeShort(code)}`;
|
||||
}
|
||||
34
app/composables/useOrderDetailPresentation.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
const PRICE_FORMATTER = new Intl.NumberFormat('ru-RU', {
|
||||
minimumFractionDigits: 0,
|
||||
maximumFractionDigits: 2,
|
||||
});
|
||||
|
||||
export function formatPrice(value?: number | null) {
|
||||
if (value == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return `${PRICE_FORMATTER.format(value)} ₽`;
|
||||
}
|
||||
|
||||
export function orderLineStateText(unitPrice?: number | null, lineTotal?: number | null) {
|
||||
if (unitPrice == null) {
|
||||
return 'Цена за единицу уточняется менеджером';
|
||||
}
|
||||
|
||||
const unitPriceLabel = formatPrice(unitPrice);
|
||||
const lineTotalLabel = formatPrice(lineTotal);
|
||||
|
||||
return lineTotalLabel
|
||||
? `Цена за единицу: ${unitPriceLabel} · Сумма позиции: ${lineTotalLabel}`
|
||||
: `Цена за единицу: ${unitPriceLabel}`;
|
||||
}
|
||||
|
||||
export function orderDeliveryStateText(deliveryTerms?: string | null) {
|
||||
return deliveryTerms?.trim() || 'Условия доставки уточняются менеджером';
|
||||
}
|
||||
|
||||
export function orderLogisticsStateText(deliveryFee?: number | null) {
|
||||
const deliveryFeeLabel = formatPrice(deliveryFee);
|
||||
return deliveryFeeLabel || 'Стоимость логистики уточняется менеджером';
|
||||
}
|
||||
319
app/composables/useOrderStatusPresentation.ts
Normal file
@@ -0,0 +1,319 @@
|
||||
type OrderStatusCode =
|
||||
| 'NEW'
|
||||
| 'MANAGER_PROCESSING'
|
||||
| 'WAITING_DOUBLE_CONFIRM'
|
||||
| 'CLIENT_REJECTED'
|
||||
| 'MANAGER_REJECTED'
|
||||
| 'MANAGER_BLOCKED'
|
||||
| 'CONFIRMED'
|
||||
| 'IN_PROGRESS'
|
||||
| 'COMPLETED';
|
||||
|
||||
export type OrderStatusTone = 'warning' | 'info' | 'success' | 'danger';
|
||||
|
||||
type TimelineStage = {
|
||||
code: string;
|
||||
label: string;
|
||||
note: string;
|
||||
dateLabel: string;
|
||||
state: 'done' | 'current' | 'upcoming';
|
||||
};
|
||||
|
||||
type StatusBadgePresentation = {
|
||||
label: string;
|
||||
tone: OrderStatusTone;
|
||||
};
|
||||
|
||||
type StatusPresentation = {
|
||||
title: string;
|
||||
summary: string;
|
||||
stages: TimelineStage[];
|
||||
};
|
||||
|
||||
const STAGE_ORDER: OrderStatusCode[] = [
|
||||
'NEW',
|
||||
'MANAGER_PROCESSING',
|
||||
'WAITING_DOUBLE_CONFIRM',
|
||||
'CONFIRMED',
|
||||
'IN_PROGRESS',
|
||||
'COMPLETED',
|
||||
];
|
||||
|
||||
const DAY_FORMATTER = new Intl.DateTimeFormat('ru-RU', {
|
||||
day: 'numeric',
|
||||
month: 'long',
|
||||
});
|
||||
|
||||
const STATUS_BADGE_MAP: Record<string, StatusBadgePresentation> = {
|
||||
NEW: { label: 'Заявка', tone: 'warning' },
|
||||
MANAGER_PROCESSING: { label: 'Готовим предложение', tone: 'warning' },
|
||||
WAITING_DOUBLE_CONFIRM: { label: 'Предложение', tone: 'info' },
|
||||
CLIENT_REJECTED: { label: 'Отклонен', tone: 'danger' },
|
||||
MANAGER_REJECTED: { label: 'Отклонен', tone: 'danger' },
|
||||
MANAGER_BLOCKED: { label: 'Пауза', tone: 'warning' },
|
||||
CONFIRMED: { label: 'Производство', tone: 'info' },
|
||||
IN_PROGRESS: { label: 'Отгрузка', tone: 'success' },
|
||||
COMPLETED: { label: 'Доставка', tone: 'success' },
|
||||
};
|
||||
|
||||
function addDays(date: Date, days: number) {
|
||||
const next = new Date(date);
|
||||
next.setDate(next.getDate() + days);
|
||||
return next;
|
||||
}
|
||||
|
||||
function formatDay(date: Date) {
|
||||
return DAY_FORMATTER.format(date);
|
||||
}
|
||||
|
||||
function stageIndex(status: string) {
|
||||
const index = STAGE_ORDER.indexOf(status as OrderStatusCode);
|
||||
return index >= 0 ? index : 0;
|
||||
}
|
||||
|
||||
function buildDates(createdAt: string | Date) {
|
||||
const base = new Date(createdAt);
|
||||
|
||||
return {
|
||||
created: base,
|
||||
offer: addDays(base, 1),
|
||||
approval: addDays(base, 2),
|
||||
production: addDays(base, 4),
|
||||
shipment: addDays(base, 6),
|
||||
delivered: addDays(base, 8),
|
||||
};
|
||||
}
|
||||
|
||||
function buildManagerStages(status: string, createdAt: string | Date): StatusPresentation {
|
||||
const dates = buildDates(createdAt);
|
||||
|
||||
const isOfferStage = ['WAITING_DOUBLE_CONFIRM', 'CONFIRMED'].includes(status);
|
||||
const isWorkStage = ['IN_PROGRESS', 'COMPLETED'].includes(status);
|
||||
const isStopped = ['CLIENT_REJECTED', 'MANAGER_REJECTED', 'MANAGER_BLOCKED'].includes(status);
|
||||
|
||||
const stages: TimelineStage[] = [
|
||||
{
|
||||
code: 'NEW',
|
||||
label: 'Заявка',
|
||||
note: 'Заказ создан и ждёт расчёта.',
|
||||
dateLabel: formatDay(dates.created),
|
||||
state: isOfferStage || isWorkStage || isStopped ? 'done' : 'current',
|
||||
},
|
||||
{
|
||||
code: 'WAITING_DOUBLE_CONFIRM',
|
||||
label: 'Предложение',
|
||||
note: 'Цена и условия опубликованы клиенту.',
|
||||
dateLabel: formatDay(dates.offer),
|
||||
state: isWorkStage ? 'done' : isOfferStage ? 'current' : 'upcoming',
|
||||
},
|
||||
{
|
||||
code: 'IN_PROGRESS',
|
||||
label: status === 'COMPLETED' ? 'Исполнение завершено' : 'В работе',
|
||||
note: status === 'COMPLETED'
|
||||
? 'Заказ передан в работу и закрыт.'
|
||||
: 'Заказ передан в производство или исполнение.',
|
||||
dateLabel: formatDay(dates.production),
|
||||
state: isWorkStage ? 'current' : 'upcoming',
|
||||
},
|
||||
];
|
||||
|
||||
if (status === 'MANAGER_BLOCKED') {
|
||||
return {
|
||||
title: 'Заказ на паузе',
|
||||
summary: 'Нужно уточнение по заказу перед публикацией предложения.',
|
||||
stages,
|
||||
};
|
||||
}
|
||||
|
||||
if (status === 'CLIENT_REJECTED' || status === 'MANAGER_REJECTED') {
|
||||
return {
|
||||
title: 'Заказ остановлен',
|
||||
summary: 'Текущий заказ завершён без запуска в работу.',
|
||||
stages,
|
||||
};
|
||||
}
|
||||
|
||||
if (isWorkStage) {
|
||||
return {
|
||||
title: status === 'COMPLETED' ? 'Заказ завершён' : 'Заказ в работе',
|
||||
summary: status === 'COMPLETED'
|
||||
? 'Исполнение завершено, история этапов сохранена.'
|
||||
: 'Предложение согласовано, заказ уже в работе.',
|
||||
stages,
|
||||
};
|
||||
}
|
||||
|
||||
if (isOfferStage) {
|
||||
return {
|
||||
title: 'Предложение отправлено',
|
||||
summary: 'Клиент уже видит цену и условия. Следующий шаг для менеджера: пустить заказ в работу.',
|
||||
stages,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
title: 'Ждём расчёт по заявке',
|
||||
summary: 'Заполните цену по позициям и логистике, после этого предложение уйдёт клиенту автоматически.',
|
||||
stages,
|
||||
};
|
||||
}
|
||||
|
||||
function buildClientStages(status: string, createdAt: string | Date): StatusPresentation {
|
||||
const dates = buildDates(createdAt);
|
||||
|
||||
if (status === 'CLIENT_REJECTED' || status === 'MANAGER_REJECTED') {
|
||||
return {
|
||||
title: 'Заказ остановлен',
|
||||
summary: 'Текущий заказ закрыт. При необходимости можно оформить новый заказ.',
|
||||
stages: [
|
||||
{
|
||||
code: 'NEW',
|
||||
label: 'Заявка',
|
||||
note: 'Заказ принят в обработку.',
|
||||
dateLabel: formatDay(dates.created),
|
||||
state: 'done',
|
||||
},
|
||||
{
|
||||
code: status,
|
||||
label: 'Отклонен',
|
||||
note: 'Дальнейшее исполнение не планируется.',
|
||||
dateLabel: formatDay(dates.approval),
|
||||
state: 'current',
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
if (status === 'MANAGER_BLOCKED') {
|
||||
return {
|
||||
title: 'Заказ ждёт уточнения',
|
||||
summary: 'Менеджер уточняет параметры заказа перед расчётом.',
|
||||
stages: [
|
||||
{
|
||||
code: 'NEW',
|
||||
label: 'Заявка',
|
||||
note: 'Заказ принят в обработку.',
|
||||
dateLabel: formatDay(dates.created),
|
||||
state: 'done',
|
||||
},
|
||||
{
|
||||
code: status,
|
||||
label: 'Пауза',
|
||||
note: 'После уточнения покажем плановые даты по исполнению.',
|
||||
dateLabel: formatDay(dates.approval),
|
||||
state: 'current',
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
const currentIndex = stageIndex(status);
|
||||
|
||||
const stages: TimelineStage[] = [
|
||||
{
|
||||
code: 'NEW',
|
||||
label: 'Заявка',
|
||||
note: 'Приняли заказ и начали обработку.',
|
||||
dateLabel: formatDay(dates.created),
|
||||
state: currentIndex > 0 ? 'done' : currentIndex === 0 ? 'current' : 'upcoming',
|
||||
},
|
||||
{
|
||||
code: 'MANAGER_PROCESSING',
|
||||
label: 'Готовим предложение',
|
||||
note: 'Собираем стоимость и плановые сроки по заказу.',
|
||||
dateLabel: formatDay(dates.offer),
|
||||
state: currentIndex > 1 ? 'done' : currentIndex === 1 ? 'current' : 'upcoming',
|
||||
},
|
||||
{
|
||||
code: 'WAITING_DOUBLE_CONFIRM',
|
||||
label: 'Предложение',
|
||||
note: 'Цена и условия готовы, ждём подтверждения.',
|
||||
dateLabel: formatDay(dates.approval),
|
||||
state: currentIndex > 2 ? 'done' : currentIndex === 2 ? 'current' : 'upcoming',
|
||||
},
|
||||
{
|
||||
code: 'CONFIRMED',
|
||||
label: 'Производство',
|
||||
note: 'Плановая дата запуска или выхода из производства.',
|
||||
dateLabel: formatDay(dates.production),
|
||||
state: currentIndex > 3 ? 'done' : currentIndex === 3 ? 'current' : 'upcoming',
|
||||
},
|
||||
{
|
||||
code: 'IN_PROGRESS',
|
||||
label: 'Отгрузка',
|
||||
note: 'Плановая дата передачи в логистику.',
|
||||
dateLabel: formatDay(dates.shipment),
|
||||
state: currentIndex > 4 ? 'done' : currentIndex === 4 ? 'current' : 'upcoming',
|
||||
},
|
||||
{
|
||||
code: 'COMPLETED',
|
||||
label: 'Доставка',
|
||||
note: 'Плановая дата получения заказа.',
|
||||
dateLabel: formatDay(dates.delivered),
|
||||
state: currentIndex === 5 ? 'current' : 'upcoming',
|
||||
},
|
||||
];
|
||||
|
||||
if (status === 'NEW') {
|
||||
return {
|
||||
title: 'Заказ создан',
|
||||
summary: 'Менеджер рассчитывает стоимость и готовит план по исполнению.',
|
||||
stages,
|
||||
};
|
||||
}
|
||||
|
||||
if (status === 'MANAGER_PROCESSING') {
|
||||
return {
|
||||
title: 'Готовим предложение',
|
||||
summary: 'Собираем итоговые условия и скоро покажем плановые даты.',
|
||||
stages,
|
||||
};
|
||||
}
|
||||
|
||||
if (status === 'WAITING_DOUBLE_CONFIRM') {
|
||||
return {
|
||||
title: 'Предложение готово',
|
||||
summary: 'Стоимость и условия уже рассчитаны. Следующий этап после запуска: производство.',
|
||||
stages,
|
||||
};
|
||||
}
|
||||
|
||||
if (status === 'CONFIRMED') {
|
||||
return {
|
||||
title: 'Производство запланировано',
|
||||
summary: `Ориентируемся на производство ${formatDay(dates.production)}, затем отгрузку и доставку по плану.`,
|
||||
stages,
|
||||
};
|
||||
}
|
||||
|
||||
if (status === 'IN_PROGRESS') {
|
||||
return {
|
||||
title: 'Готовим отгрузку',
|
||||
summary: `Производство идёт. Следующая плановая дата: отгрузка около ${formatDay(dates.shipment)}.`,
|
||||
stages,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
title: 'Доставка по плану',
|
||||
summary: `Финальный ориентир по заказу: доставка ${formatDay(dates.delivered)}.`,
|
||||
stages,
|
||||
};
|
||||
}
|
||||
|
||||
export function getOrderStatusBadgePresentation(status: string): StatusBadgePresentation {
|
||||
return STATUS_BADGE_MAP[status] ?? {
|
||||
label: status,
|
||||
tone: 'info',
|
||||
};
|
||||
}
|
||||
|
||||
export function getOrderStatusPresentation(
|
||||
status: string,
|
||||
createdAt: string | Date,
|
||||
audience: 'client' | 'manager' = 'client',
|
||||
): StatusPresentation {
|
||||
return audience === 'manager'
|
||||
? buildManagerStages(status, createdAt)
|
||||
: buildClientStages(status, createdAt);
|
||||
}
|
||||
61
app/composables/useTelegramMiniApp.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
type TelegramMiniAppUser = {
|
||||
id: number | string;
|
||||
first_name?: string;
|
||||
last_name?: string;
|
||||
username?: string;
|
||||
language_code?: string;
|
||||
photo_url?: string;
|
||||
};
|
||||
|
||||
type TelegramWebApp = {
|
||||
initData?: string;
|
||||
initDataUnsafe?: {
|
||||
user?: TelegramMiniAppUser;
|
||||
start_param?: string;
|
||||
};
|
||||
ready?: () => void;
|
||||
expand?: () => void;
|
||||
};
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
Telegram?: {
|
||||
WebApp?: TelegramWebApp;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function buildDisplayName(user: TelegramMiniAppUser | null) {
|
||||
if (!user) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const firstName = String(user.first_name || '').trim();
|
||||
const lastName = String(user.last_name || '').trim();
|
||||
return `${firstName} ${lastName}`.trim() || firstName || String(user.username || '').trim();
|
||||
}
|
||||
|
||||
export function useTelegramMiniApp() {
|
||||
const webApp = computed<TelegramWebApp | null>(() => {
|
||||
if (!import.meta.client) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return window.Telegram?.WebApp ?? null;
|
||||
});
|
||||
|
||||
const initData = computed(() => String(webApp.value?.initData || '').trim());
|
||||
const user = computed<TelegramMiniAppUser | null>(() => webApp.value?.initDataUnsafe?.user ?? null);
|
||||
const startParam = computed(() => String(webApp.value?.initDataUnsafe?.start_param || '').trim());
|
||||
const isAvailable = computed(() => Boolean(initData.value));
|
||||
const displayName = computed(() => buildDisplayName(user.value));
|
||||
|
||||
return {
|
||||
webApp,
|
||||
initData,
|
||||
user,
|
||||
startParam,
|
||||
isAvailable,
|
||||
displayName,
|
||||
};
|
||||
}
|
||||
@@ -1,14 +1,32 @@
|
||||
import { readMessengerMiniAppStartPath } from '~/composables/useMessengerMiniAppStart';
|
||||
|
||||
export default defineNuxtRouteMiddleware((to) => {
|
||||
const config = useRuntimeConfig();
|
||||
const authCookieName = config.public.authCookieName || 'fregat_auth_token';
|
||||
const authToken = useCookie<string | null>(authCookieName);
|
||||
|
||||
const isLoginPage = to.path === '/login';
|
||||
const loginToken = typeof to.query.login_token === 'string' ? to.query.login_token.trim() : '';
|
||||
const miniAppStartPath = readMessengerMiniAppStartPath();
|
||||
const nextPath = miniAppStartPath || (to.fullPath.startsWith('/') ? to.fullPath : '/');
|
||||
|
||||
if (!authToken.value && !isLoginPage) {
|
||||
return navigateTo('/login');
|
||||
return navigateTo({
|
||||
path: '/login',
|
||||
query: {
|
||||
next: nextPath,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (authToken.value && isLoginPage) {
|
||||
return navigateTo('/');
|
||||
if (authToken.value && isLoginPage && !loginToken) {
|
||||
const requestedNextPath = typeof to.query.next === 'string' && to.query.next.startsWith('/')
|
||||
? to.query.next
|
||||
: miniAppStartPath || '/';
|
||||
return navigateTo(requestedNextPath);
|
||||
}
|
||||
|
||||
if (authToken.value && to.path === '/' && miniAppStartPath && miniAppStartPath !== '/') {
|
||||
return navigateTo(miniAppStartPath);
|
||||
}
|
||||
});
|
||||
|
||||
331
app/pages/bonus-program.vue
Normal file
@@ -0,0 +1,331 @@
|
||||
<script setup lang="ts">
|
||||
import { useMutation, useQuery } from '@vue/apollo-composable';
|
||||
import {
|
||||
MeDocument,
|
||||
ReferralStatsDocument,
|
||||
RequestRewardWithdrawalDocument,
|
||||
} from '~/composables/graphql/generated';
|
||||
|
||||
const route = useRoute();
|
||||
|
||||
const meQuery = useQuery(MeDocument);
|
||||
const referralStatsQuery = useQuery(ReferralStatsDocument);
|
||||
const requestWithdrawalMutation = useMutation(RequestRewardWithdrawalDocument, { throws: 'never' });
|
||||
|
||||
const withdrawalAmount = ref('');
|
||||
const withdrawalFeedback = ref('');
|
||||
const withdrawalFeedbackTone = ref<'success' | 'error'>('success');
|
||||
|
||||
const bonusAccount = computed(() => referralStatsQuery.result.value?.referralStats ?? null);
|
||||
const me = computed(() => meQuery.result.value?.me ?? null);
|
||||
const transactions = computed(() => bonusAccount.value?.transactions ?? []);
|
||||
const pendingWithdrawals = computed(() => bonusAccount.value?.pendingWithdrawals ?? []);
|
||||
const availableBalance = computed(() => bonusAccount.value?.availableBalance ?? 0);
|
||||
const canWithdraw = computed(() => availableBalance.value >= 100);
|
||||
const selectedEntry = computed(() => String(route.query.entry || '').trim());
|
||||
|
||||
const rewardCards = [
|
||||
{ id: 'ozon-3000', store: 'Ozon', title: 'Подарочная карта Ozon', amount: 3000 },
|
||||
{ id: 'wildberries-4000', store: 'Wildberries', title: 'Подарочная карта Wildberries', amount: 4000 },
|
||||
{ id: 'mvideo-5000', store: 'М.Видео', title: 'Подарочная карта М.Видео', amount: 5000 },
|
||||
];
|
||||
|
||||
const entryTitle = computed(() => {
|
||||
if (selectedEntry.value.includes('withdrawal')) {
|
||||
return 'Вы открыли бонусную программу из уведомления о выводе.';
|
||||
}
|
||||
if (selectedEntry.value.includes('balance')) {
|
||||
return 'Вы открыли бонусную программу из уведомления об изменении баланса.';
|
||||
}
|
||||
if (selectedEntry.value) {
|
||||
return 'Вы открыли бонусную программу из специального перехода.';
|
||||
}
|
||||
return 'Отдельный бонусный интерфейс для клиента.';
|
||||
});
|
||||
|
||||
const suggestedWithdrawalAmount = computed(() => {
|
||||
if (availableBalance.value < 100) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return String(Math.floor(availableBalance.value));
|
||||
});
|
||||
|
||||
watch(
|
||||
suggestedWithdrawalAmount,
|
||||
(value) => {
|
||||
if (!withdrawalAmount.value && value) {
|
||||
withdrawalAmount.value = value;
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
function formatMoney(value: number) {
|
||||
return new Intl.NumberFormat('ru-RU', {
|
||||
minimumFractionDigits: 0,
|
||||
maximumFractionDigits: 2,
|
||||
}).format(value);
|
||||
}
|
||||
|
||||
function formatDate(value: string) {
|
||||
return new Date(value).toLocaleString('ru-RU');
|
||||
}
|
||||
|
||||
async function submitWithdrawal() {
|
||||
withdrawalFeedback.value = '';
|
||||
|
||||
const amount = Number(withdrawalAmount.value);
|
||||
if (!Number.isFinite(amount) || amount < 100) {
|
||||
withdrawalFeedbackTone.value = 'error';
|
||||
withdrawalFeedback.value = 'Минимальная сумма вывода - 100.';
|
||||
return;
|
||||
}
|
||||
|
||||
if (amount > availableBalance.value) {
|
||||
withdrawalFeedbackTone.value = 'error';
|
||||
withdrawalFeedback.value = 'Сумма вывода не может быть больше доступного баланса.';
|
||||
return;
|
||||
}
|
||||
|
||||
const response = await requestWithdrawalMutation.mutate({
|
||||
input: {
|
||||
amount,
|
||||
},
|
||||
});
|
||||
|
||||
const payload = response?.data?.requestRewardWithdrawal;
|
||||
if (!payload) {
|
||||
withdrawalFeedbackTone.value = 'error';
|
||||
withdrawalFeedback.value = requestWithdrawalMutation.error.value?.message || 'Не удалось отправить заявку на вывод.';
|
||||
return;
|
||||
}
|
||||
|
||||
withdrawalFeedbackTone.value = 'success';
|
||||
withdrawalFeedback.value = `Заявка на вывод создана: ${formatMoney(payload.amount)}.`;
|
||||
withdrawalAmount.value = '';
|
||||
await referralStatsQuery.refetch();
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="bonus-program-page space-y-8">
|
||||
<div class="bonus-program-orbit bonus-program-orbit--a" aria-hidden="true" />
|
||||
<div class="bonus-program-orbit bonus-program-orbit--b" aria-hidden="true" />
|
||||
|
||||
<header class="bonus-program-hero">
|
||||
<div class="space-y-3">
|
||||
<p class="bonus-program-kicker">Bonus Program</p>
|
||||
<h1 class="bonus-program-title">
|
||||
Чёрный кабинет бонусной программы
|
||||
</h1>
|
||||
<p class="bonus-program-copy">
|
||||
{{ entryTitle }}
|
||||
Здесь отдельно живут история начислений, магазин вознаграждений и выводы.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap gap-3">
|
||||
<NuxtLink to="/notifications" class="bonus-program-ghost-button">
|
||||
История уведомлений
|
||||
</NuxtLink>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div v-if="referralStatsQuery.loading.value || meQuery.loading.value" class="bonus-program-panel">
|
||||
Загружаем бонусную программу...
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
<section class="grid gap-4 xl:grid-cols-[1.3fr_0.9fr]">
|
||||
<article class="bonus-program-panel">
|
||||
<p class="bonus-program-caption">Аккаунт</p>
|
||||
<div class="mt-4 flex flex-col gap-5 lg:flex-row lg:items-end lg:justify-between">
|
||||
<div class="space-y-2">
|
||||
<h2 class="text-4xl font-black tracking-[-0.05em] text-white">
|
||||
{{ me?.fullName || 'Клиент бонусной программы' }}
|
||||
</h2>
|
||||
<p class="text-sm leading-6 text-white/65">
|
||||
Отдельная зона для бонусных начислений и статусов выводов.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2 text-left lg:text-right">
|
||||
<p class="bonus-program-caption">Доступный баланс</p>
|
||||
<p class="text-5xl font-black tracking-[-0.05em] text-white">
|
||||
{{ formatMoney(availableBalance) }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-6 grid gap-3 md:grid-cols-3">
|
||||
<div class="bonus-program-stat">
|
||||
<span class="bonus-program-stat__label">Рефералы</span>
|
||||
<span class="bonus-program-stat__value">{{ bonusAccount?.referralsCount ?? 0 }}</span>
|
||||
</div>
|
||||
<div class="bonus-program-stat">
|
||||
<span class="bonus-program-stat__label">Начисления</span>
|
||||
<span class="bonus-program-stat__value">{{ transactions.length }}</span>
|
||||
</div>
|
||||
<div class="bonus-program-stat">
|
||||
<span class="bonus-program-stat__label">Активные выводы</span>
|
||||
<span class="bonus-program-stat__value">{{ pendingWithdrawals.length }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article class="bonus-program-panel">
|
||||
<p class="bonus-program-caption">Вывод бонусов</p>
|
||||
<div class="mt-4 space-y-4">
|
||||
<p class="text-sm leading-6 text-white/70">
|
||||
При изменении статуса вывода клиент получает отдельное уведомление и возвращается именно в этот экран.
|
||||
</p>
|
||||
|
||||
<label class="block space-y-2">
|
||||
<span class="text-xs font-semibold uppercase tracking-[0.14em] text-white/55">Сумма заявки</span>
|
||||
<input
|
||||
v-model="withdrawalAmount"
|
||||
type="number"
|
||||
min="100"
|
||||
step="1"
|
||||
class="bonus-program-input"
|
||||
placeholder="Например, 1500"
|
||||
>
|
||||
</label>
|
||||
|
||||
<button
|
||||
class="bonus-program-primary-button"
|
||||
:disabled="requestWithdrawalMutation.loading.value || !canWithdraw"
|
||||
@click="submitWithdrawal"
|
||||
>
|
||||
{{
|
||||
requestWithdrawalMutation.loading.value
|
||||
? 'Отправляем заявку...'
|
||||
: canWithdraw
|
||||
? 'Подать заявку на вывод'
|
||||
: 'Недостаточно бонусов для вывода'
|
||||
}}
|
||||
</button>
|
||||
|
||||
<div
|
||||
v-if="withdrawalFeedback"
|
||||
class="rounded-[24px] border px-4 py-3 text-sm"
|
||||
:class="withdrawalFeedbackTone === 'success' ? 'border-[#184e31] bg-[#0f1f16] text-[#dff7e8]' : 'border-[#6a2626] bg-[#1b1010] text-[#ffd4d4]'"
|
||||
>
|
||||
{{ withdrawalFeedback }}
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<section class="grid gap-4 xl:grid-cols-[1.05fr_0.95fr]">
|
||||
<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">
|
||||
{{ transactions.length }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div v-if="transactions.length === 0" class="bonus-program-empty mt-5">
|
||||
Пока нет начислений. Когда придут первые бонусы, они появятся здесь отдельной чёрной лентой.
|
||||
</div>
|
||||
|
||||
<div v-else class="mt-5 space-y-3">
|
||||
<article
|
||||
v-for="transaction in transactions"
|
||||
:key="transaction.id"
|
||||
class="bonus-program-feed-item"
|
||||
>
|
||||
<div class="flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
|
||||
<div class="space-y-2">
|
||||
<p class="text-lg font-bold text-white">+{{ formatMoney(transaction.amount) }}</p>
|
||||
<p class="text-sm leading-6 text-white/72">{{ transaction.reason }}</p>
|
||||
<p class="text-xs uppercase tracking-[0.12em] text-white/40">{{ formatDate(transaction.createdAt) }}</p>
|
||||
</div>
|
||||
|
||||
<NuxtLink
|
||||
v-if="transaction.orderId"
|
||||
to="/orders"
|
||||
class="bonus-program-inline-link"
|
||||
>
|
||||
Открыть заказы
|
||||
</NuxtLink>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<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">
|
||||
{{ pendingWithdrawals.length }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div v-if="pendingWithdrawals.length === 0" class="bonus-program-empty mt-5">
|
||||
Активных выводов сейчас нет. Как только менеджер получит заявку, она появится в этом блоке.
|
||||
</div>
|
||||
|
||||
<div v-else class="mt-5 space-y-3">
|
||||
<article
|
||||
v-for="withdrawal in pendingWithdrawals"
|
||||
:key="withdrawal.id"
|
||||
class="bonus-program-feed-item"
|
||||
>
|
||||
<div class="space-y-2">
|
||||
<div class="flex flex-wrap items-center gap-3">
|
||||
<p class="text-lg font-bold text-white">{{ formatMoney(withdrawal.amount) }}</p>
|
||||
<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">
|
||||
{{ withdrawal.status }}
|
||||
</span>
|
||||
</div>
|
||||
<p class="text-xs uppercase tracking-[0.12em] text-white/40">{{ formatDate(withdrawal.createdAt) }}</p>
|
||||
<p v-if="withdrawal.reviewComment" class="text-sm leading-6 text-white/72">
|
||||
{{ withdrawal.reviewComment }}
|
||||
</p>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<article class="bonus-program-panel">
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<p class="bonus-program-caption">Магазин</p>
|
||||
<h2 class="mt-2 text-2xl font-black tracking-[-0.04em] text-white">Вознаграждения</h2>
|
||||
</div>
|
||||
<span class="rounded-full border border-white/10 bg-white/5 px-3 py-1 text-xs font-semibold uppercase tracking-[0.12em] text-white/55">
|
||||
{{ rewardCards.length }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="mt-5 grid gap-3 md:grid-cols-3">
|
||||
<article
|
||||
v-for="reward in rewardCards"
|
||||
:key="reward.id"
|
||||
class="rounded-[24px] border border-white/10 bg-white/[0.04] p-4"
|
||||
>
|
||||
<p class="text-xs font-semibold uppercase tracking-[0.14em] text-white/45">
|
||||
{{ reward.store }}
|
||||
</p>
|
||||
<h3 class="mt-3 text-lg font-bold text-white">
|
||||
{{ reward.title }}
|
||||
</h3>
|
||||
<p class="mt-4 text-sm font-semibold text-white/70">
|
||||
{{ formatMoney(reward.amount) }} бонусов
|
||||
</p>
|
||||
</article>
|
||||
</div>
|
||||
</article>
|
||||
</template>
|
||||
</section>
|
||||
</template>
|
||||
255
app/pages/bonus-system/[userId].vue
Normal file
@@ -0,0 +1,255 @@
|
||||
<script setup lang="ts">
|
||||
import { useMutation, useQuery } from '@vue/apollo-composable';
|
||||
import {
|
||||
CreateBonusProgramLinkDocument,
|
||||
ManagerBonusAccountDocument,
|
||||
type ManagerBonusAccountQuery,
|
||||
} from '~/composables/graphql/generated';
|
||||
|
||||
definePageMeta({
|
||||
middleware: ['manager-only'],
|
||||
path: '/admin/bonuses/balances/:userId',
|
||||
alias: ['/bonus-system/:userId'],
|
||||
});
|
||||
|
||||
type TransactionItem = ManagerBonusAccountQuery['managerBonusAccount']['transactions'][number];
|
||||
type PendingWithdrawalItem = ManagerBonusAccountQuery['managerBonusAccount']['pendingWithdrawals'][number];
|
||||
|
||||
const route = useRoute();
|
||||
const userId = computed(() => String(route.params.userId || ''));
|
||||
const createBonusProgramLinkMutation = useMutation(CreateBonusProgramLinkDocument, { throws: 'never' });
|
||||
const bonusProgramLink = ref('');
|
||||
const bonusProgramLinkExpiresAt = ref('');
|
||||
const bonusProgramLinkFeedback = ref('');
|
||||
|
||||
const bonusAccountQuery = useQuery(ManagerBonusAccountDocument, () => ({
|
||||
userId: userId.value,
|
||||
}));
|
||||
|
||||
const bonusAccount = computed(() => bonusAccountQuery.result.value?.managerBonusAccount ?? null);
|
||||
const transactions = computed<TransactionItem[]>(() => bonusAccount.value?.transactions ?? []);
|
||||
const pendingWithdrawals = computed<PendingWithdrawalItem[]>(() => bonusAccount.value?.pendingWithdrawals ?? []);
|
||||
|
||||
function formatAmount(value: number) {
|
||||
return new Intl.NumberFormat('ru-RU', {
|
||||
minimumFractionDigits: 0,
|
||||
maximumFractionDigits: 2,
|
||||
}).format(value);
|
||||
}
|
||||
|
||||
function formatDateTime(value: string) {
|
||||
return new Date(value).toLocaleString('ru-RU');
|
||||
}
|
||||
|
||||
function withdrawalStatusLabel(status: string) {
|
||||
if (status === 'APPROVED') {
|
||||
return 'Проведена';
|
||||
}
|
||||
if (status === 'REJECTED') {
|
||||
return 'Отклонена';
|
||||
}
|
||||
return 'На проверке';
|
||||
}
|
||||
|
||||
function withdrawalStatusClass(status: string) {
|
||||
if (status === 'APPROVED') {
|
||||
return 'bg-[#def7e8] text-[#0d854a]';
|
||||
}
|
||||
if (status === 'REJECTED') {
|
||||
return 'bg-[#fde8ea] text-[#b73742]';
|
||||
}
|
||||
return 'bg-[#fff3d8] text-[#9a6100]';
|
||||
}
|
||||
|
||||
async function generateBonusProgramLink() {
|
||||
bonusProgramLinkFeedback.value = '';
|
||||
|
||||
const response = await createBonusProgramLinkMutation.mutate({
|
||||
userId: userId.value,
|
||||
});
|
||||
|
||||
const payload = response?.data?.createBonusProgramLink;
|
||||
if (!payload?.url) {
|
||||
bonusProgramLinkFeedback.value = createBonusProgramLinkMutation.error.value?.message || 'Не удалось сгенерировать ссылку.';
|
||||
return;
|
||||
}
|
||||
|
||||
bonusProgramLink.value = payload.url;
|
||||
bonusProgramLinkExpiresAt.value = payload.expiresAt;
|
||||
bonusProgramLinkFeedback.value = 'Ссылка готова. Её можно переслать клиенту.';
|
||||
}
|
||||
|
||||
async function copyBonusProgramLink() {
|
||||
if (!bonusProgramLink.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
await navigator.clipboard.writeText(bonusProgramLink.value);
|
||||
bonusProgramLinkFeedback.value = 'Ссылка скопирована.';
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="space-y-6">
|
||||
<div v-if="bonusAccountQuery.loading.value" class="manager-empty-state">
|
||||
Загружаем бонусный счёт...
|
||||
</div>
|
||||
|
||||
<div v-else-if="!bonusAccount" class="manager-empty-state">
|
||||
Бонусный счёт не найден.
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
<div class="space-y-4">
|
||||
<UiBackHeader
|
||||
to="/admin/bonuses/balances"
|
||||
back-label="Назад к бонусным счетам"
|
||||
:title="`Бонусный счёт ${bonusAccount.fullName}`"
|
||||
:subtitle="bonusAccount.companyName || bonusAccount.email || undefined"
|
||||
>
|
||||
<template #actions>
|
||||
<div class="flex flex-col gap-3 md:items-end">
|
||||
<div class="text-left md:text-right">
|
||||
<p class="text-[11px] font-semibold uppercase tracking-[0.18em] text-[#6a8a76]">Доступный бонус</p>
|
||||
<p class="mt-2 text-3xl font-black leading-none text-[#123824]">{{ formatAmount(bonusAccount.balance) }}</p>
|
||||
</div>
|
||||
|
||||
<button
|
||||
class="btn rounded-full border-0 bg-[#123824] px-5 text-white hover:bg-[#0f2f20]"
|
||||
:disabled="createBonusProgramLinkMutation.loading.value"
|
||||
@click="generateBonusProgramLink"
|
||||
>
|
||||
{{ createBonusProgramLinkMutation.loading.value ? 'Генерируем...' : 'Сгенерировать ссылку' }}
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
</UiBackHeader>
|
||||
|
||||
<article v-if="bonusProgramLink" class="surface-card rounded-[28px] px-5 py-4">
|
||||
<div class="flex flex-col gap-4 md:flex-row md:items-end md:justify-between">
|
||||
<div class="min-w-0 flex-1 space-y-2">
|
||||
<p class="text-[11px] font-semibold uppercase tracking-[0.18em] text-[#6a8a76]">Ссылка в бонусный кабинет</p>
|
||||
<p class="text-sm text-[#355947]">
|
||||
Эту ссылку менеджер может отправить клиенту. Дальше клиент авторизуется через отдельный Telegram-бот бонусной программы.
|
||||
</p>
|
||||
<div class="rounded-[20px] bg-[#f8fbf9] px-4 py-3 text-sm font-semibold text-[#123824] break-all">
|
||||
{{ bonusProgramLink }}
|
||||
</div>
|
||||
<p v-if="bonusProgramLinkExpiresAt" class="text-xs text-[#5c7b69]">
|
||||
Действует до {{ formatDateTime(bonusProgramLinkExpiresAt) }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-3">
|
||||
<a
|
||||
:href="bonusProgramLink"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
class="btn rounded-full border border-[#d7e9de] bg-white px-5 text-[#123824] hover:bg-[#f3f8f5]"
|
||||
>
|
||||
Открыть
|
||||
</a>
|
||||
<button
|
||||
class="btn rounded-full border-0 bg-[#139957] px-5 text-white hover:bg-[#0d854a]"
|
||||
@click="copyBonusProgramLink"
|
||||
>
|
||||
Скопировать
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p v-if="bonusProgramLinkFeedback" class="mt-4 text-sm font-semibold text-[#0d854a]">
|
||||
{{ bonusProgramLinkFeedback }}
|
||||
</p>
|
||||
</article>
|
||||
|
||||
<p
|
||||
v-else-if="bonusProgramLinkFeedback"
|
||||
class="text-sm font-semibold text-[#0d854a]"
|
||||
>
|
||||
{{ bonusProgramLinkFeedback }}
|
||||
</p>
|
||||
|
||||
<div v-if="pendingWithdrawals.length" class="space-y-3">
|
||||
<div 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 class="space-y-3">
|
||||
<div class="space-y-1">
|
||||
<p class="text-lg font-bold text-[#123824]">Транзакции</p>
|
||||
<p class="text-sm text-[#5c7b69]">История начислений и операций по бонусному счёту.</p>
|
||||
</div>
|
||||
|
||||
<div v-if="transactions.length === 0" class="manager-empty-state mt-4">
|
||||
Начислений пока нет.
|
||||
</div>
|
||||
|
||||
<div v-else class="space-y-3">
|
||||
<article
|
||||
v-for="transaction in transactions"
|
||||
:key="transaction.id"
|
||||
class="surface-card rounded-[28px] px-5 py-4"
|
||||
>
|
||||
<div class="grid gap-4 md:grid-cols-[140px_minmax(0,1fr)_180px_140px] md:items-center md:gap-6">
|
||||
<div>
|
||||
<p class="text-base font-bold text-[#123824]">+{{ formatAmount(transaction.amount) }} ₽</p>
|
||||
</div>
|
||||
|
||||
<div class="min-w-0 space-y-1">
|
||||
<p class="text-sm font-semibold text-[#123824]">Начисление</p>
|
||||
<p class="text-sm text-[#355947]">{{ transaction.reason }}</p>
|
||||
</div>
|
||||
|
||||
<div class="text-sm text-[#5c7b69]">
|
||||
{{ formatDateTime(transaction.createdAt) }}
|
||||
</div>
|
||||
|
||||
<div class="text-left md:text-right">
|
||||
<NuxtLink
|
||||
v-if="transaction.orderId"
|
||||
:to="`/admin/orders/${transaction.orderId}`"
|
||||
class="text-sm font-semibold text-[#0d854a]"
|
||||
>
|
||||
Открыть заказ
|
||||
</NuxtLink>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</section>
|
||||
</template>
|
||||
@@ -1,21 +1,121 @@
|
||||
<script setup lang="ts">
|
||||
import { useQuery } from '@vue/apollo-composable';
|
||||
import {
|
||||
ManagerBonusBalancesDocument,
|
||||
ManagerUsersDocument,
|
||||
ManagerWithdrawalRequestsDocument,
|
||||
type ManagerBonusBalancesQuery,
|
||||
type ManagerUsersQuery,
|
||||
type ManagerWithdrawalRequestsQuery,
|
||||
} from '~/composables/graphql/generated';
|
||||
import { messengerConnectionAvatarSrc } from '~/composables/useMessengerConnectionPresentation';
|
||||
|
||||
definePageMeta({
|
||||
middleware: ['manager-only'],
|
||||
path: '/admin/bonuses/:section(balances|requests|rewards)?',
|
||||
alias: ['/bonus-system'],
|
||||
});
|
||||
|
||||
type BalanceItem = ManagerBonusBalancesQuery['managerBonusBalances'][number];
|
||||
type ManagerUserItem = ManagerUsersQuery['managerUsers'][number];
|
||||
type WithdrawalItem = ManagerWithdrawalRequestsQuery['managerWithdrawalRequests'][number];
|
||||
type ProductCard = {
|
||||
id: string;
|
||||
store: string;
|
||||
title: string;
|
||||
amount: number;
|
||||
gradient: string;
|
||||
};
|
||||
|
||||
const route = useRoute();
|
||||
const search = ref('');
|
||||
const balancesQuery = useQuery(ManagerBonusBalancesDocument);
|
||||
const usersQuery = useQuery(ManagerUsersDocument);
|
||||
const withdrawalsQuery = useQuery(ManagerWithdrawalRequestsDocument, {
|
||||
status: 'PENDING',
|
||||
});
|
||||
|
||||
const activeTab = computed<'balances' | 'withdrawals' | 'rewards'>(() => {
|
||||
if (route.path === '/admin/bonuses/requests') {
|
||||
return 'withdrawals';
|
||||
}
|
||||
if (route.path === '/admin/bonuses/rewards') {
|
||||
return 'rewards';
|
||||
}
|
||||
return 'balances';
|
||||
});
|
||||
|
||||
const productCards: ProductCard[] = [
|
||||
{
|
||||
id: 'ozon-3000',
|
||||
store: 'Ozon',
|
||||
title: 'Подарочная карта Ozon',
|
||||
amount: 3000,
|
||||
gradient: 'linear-gradient(135deg, #38b6ff 0%, #1369ff 55%, #0b2f72 100%)',
|
||||
},
|
||||
{
|
||||
id: 'ozon-5000',
|
||||
store: 'Ozon',
|
||||
title: 'Подарочная карта Ozon',
|
||||
amount: 5000,
|
||||
gradient: 'linear-gradient(135deg, #65d0ff 0%, #247bff 52%, #12315e 100%)',
|
||||
},
|
||||
{
|
||||
id: 'wildberries-3000',
|
||||
store: 'Wildberries',
|
||||
title: 'Подарочная карта Wildberries',
|
||||
amount: 3000,
|
||||
gradient: 'linear-gradient(135deg, #d84dff 0%, #8b27ff 52%, #39006a 100%)',
|
||||
},
|
||||
{
|
||||
id: 'wildberries-4000',
|
||||
store: 'Wildberries',
|
||||
title: 'Подарочная карта Wildberries',
|
||||
amount: 4000,
|
||||
gradient: 'linear-gradient(135deg, #ef7cff 0%, #a12dff 50%, #4c0b7d 100%)',
|
||||
},
|
||||
{
|
||||
id: 'mvideo-4000',
|
||||
store: 'М.Видео',
|
||||
title: 'Подарочная карта М.Видео',
|
||||
amount: 4000,
|
||||
gradient: 'linear-gradient(135deg, #ff9461 0%, #ff5630 48%, #821414 100%)',
|
||||
},
|
||||
{
|
||||
id: 'mvideo-5000',
|
||||
store: 'М.Видео',
|
||||
title: 'Подарочная карта М.Видео',
|
||||
amount: 5000,
|
||||
gradient: 'linear-gradient(135deg, #ffb17e 0%, #ff6842 50%, #8f1818 100%)',
|
||||
},
|
||||
];
|
||||
|
||||
const balances = computed<BalanceItem[]>(() => balancesQuery.result.value?.managerBonusBalances ?? []);
|
||||
const users = computed<ManagerUserItem[]>(() => usersQuery.result.value?.managerUsers ?? []);
|
||||
const withdrawals = computed<WithdrawalItem[]>(() => withdrawalsQuery.result.value?.managerWithdrawalRequests ?? []);
|
||||
const usersById = computed(() => new Map(users.value.map((user) => [user.id, user])));
|
||||
|
||||
const filteredBalances = computed(() => {
|
||||
const query = search.value.trim().toLowerCase();
|
||||
|
||||
return balances.value
|
||||
.filter((item) => {
|
||||
if (!query) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return [
|
||||
item.fullName,
|
||||
item.email,
|
||||
item.companyName || '',
|
||||
String(item.balance),
|
||||
String(item.transactionsCount),
|
||||
]
|
||||
.join(' ')
|
||||
.toLowerCase()
|
||||
.includes(query);
|
||||
});
|
||||
});
|
||||
|
||||
const filteredWithdrawals = computed(() => {
|
||||
const query = search.value.trim().toLowerCase();
|
||||
@@ -38,6 +138,130 @@ const filteredWithdrawals = computed(() => {
|
||||
.includes(query);
|
||||
});
|
||||
});
|
||||
|
||||
const filteredProducts = computed(() => {
|
||||
const query = search.value.trim().toLowerCase();
|
||||
|
||||
return productCards.filter((item) => {
|
||||
if (!query) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return [
|
||||
item.store,
|
||||
item.title,
|
||||
String(item.amount),
|
||||
]
|
||||
.join(' ')
|
||||
.toLowerCase()
|
||||
.includes(query);
|
||||
});
|
||||
});
|
||||
|
||||
const {
|
||||
canLoadMore: canLoadMoreBalances,
|
||||
loadMore: loadMoreBalances,
|
||||
loadMoreSentinel: loadMoreBalancesSentinel,
|
||||
remainingCount: remainingBalancesCount,
|
||||
visibleItems: visibleBalances,
|
||||
} = useIncrementalList(filteredBalances, {
|
||||
pageSize: 24,
|
||||
enabled: computed(() => activeTab.value === 'balances'),
|
||||
resetKeys: [search, activeTab],
|
||||
});
|
||||
|
||||
const {
|
||||
canLoadMore: canLoadMoreWithdrawals,
|
||||
loadMore: loadMoreWithdrawals,
|
||||
loadMoreSentinel: loadMoreWithdrawalsSentinel,
|
||||
remainingCount: remainingWithdrawalsCount,
|
||||
visibleItems: visibleWithdrawals,
|
||||
} = useIncrementalList(filteredWithdrawals, {
|
||||
pageSize: 24,
|
||||
enabled: computed(() => activeTab.value === 'withdrawals'),
|
||||
resetKeys: [search, activeTab],
|
||||
});
|
||||
|
||||
const WITHDRAWAL_DATE_FORMATTER = new Intl.DateTimeFormat('ru-RU', {
|
||||
day: '2-digit',
|
||||
month: 'short',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
|
||||
function userInitials(fullName: string) {
|
||||
const parts = fullName
|
||||
.trim()
|
||||
.split(/\s+/)
|
||||
.filter(Boolean)
|
||||
.slice(0, 2);
|
||||
|
||||
if (!parts.length) {
|
||||
return 'FR';
|
||||
}
|
||||
|
||||
return parts.map((part) => part[0]?.toUpperCase() ?? '').join('');
|
||||
}
|
||||
|
||||
function formatAmount(value: number) {
|
||||
return new Intl.NumberFormat('ru-RU', {
|
||||
minimumFractionDigits: 0,
|
||||
maximumFractionDigits: 2,
|
||||
}).format(value);
|
||||
}
|
||||
|
||||
function formatWithdrawalCode(id: string) {
|
||||
return `WD-${id.slice(-6).toUpperCase()}`;
|
||||
}
|
||||
|
||||
function formatDateTime(value: string) {
|
||||
return WITHDRAWAL_DATE_FORMATTER.format(new Date(value));
|
||||
}
|
||||
|
||||
function withdrawalStatusLabel(status: string) {
|
||||
if (status === 'APPROVED') {
|
||||
return 'Подтверждена';
|
||||
}
|
||||
if (status === 'REJECTED') {
|
||||
return 'Отклонена';
|
||||
}
|
||||
return 'На проверке';
|
||||
}
|
||||
|
||||
function withdrawalStatusClass(status: string) {
|
||||
if (status === 'APPROVED') {
|
||||
return 'bg-[#def7e8] text-[#0d854a]';
|
||||
}
|
||||
if (status === 'REJECTED') {
|
||||
return 'bg-[#fde8ea] text-[#b73742]';
|
||||
}
|
||||
return 'bg-[#fff3d8] text-[#9a6100]';
|
||||
}
|
||||
|
||||
function requesterMeta(withdrawal: WithdrawalItem) {
|
||||
const requester = usersById.value.get(withdrawal.requesterId);
|
||||
const fullName = withdrawal.requesterFullName;
|
||||
|
||||
return {
|
||||
avatarSrc: messengerConnectionAvatarSrc(requester?.telegramConnection),
|
||||
initials: userInitials(fullName),
|
||||
companyName: withdrawal.companyName || requester?.companyName || '',
|
||||
};
|
||||
}
|
||||
|
||||
function productVisualLabel(product: ProductCard) {
|
||||
return product.store
|
||||
.replace(/[^A-Za-zА-Яа-яЁё0-9]+/g, ' ')
|
||||
.trim()
|
||||
.split(/\s+/)
|
||||
.slice(0, 2)
|
||||
.map((part) => part.charAt(0).toUpperCase())
|
||||
.join('');
|
||||
}
|
||||
|
||||
function compactProductTitle(product: ProductCard) {
|
||||
return `Подарочная карта ${product.store}`;
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -45,34 +269,155 @@ const filteredWithdrawals = computed(() => {
|
||||
<UiSectionSearchHero
|
||||
v-model="search"
|
||||
title="Бонусы"
|
||||
search-placeholder="Пользователь, сумма или статус"
|
||||
/>
|
||||
:search-placeholder="activeTab === 'balances'
|
||||
? 'Клиент, связанный клиент или email'
|
||||
: activeTab === 'withdrawals'
|
||||
? 'Номер выплаты, клиент или сумма'
|
||||
: 'Название или номинал'"
|
||||
>
|
||||
<template #controls>
|
||||
<NuxtLink
|
||||
v-if="activeTab === 'balances'"
|
||||
to="/admin/bonuses/links/new"
|
||||
class="btn btn-primary border-0"
|
||||
>
|
||||
Добавить
|
||||
</NuxtLink>
|
||||
</template>
|
||||
</UiSectionSearchHero>
|
||||
|
||||
<div v-if="withdrawalsQuery.loading.value" class="manager-empty-state">
|
||||
Загружаем заявки...
|
||||
</div>
|
||||
<div v-else-if="filteredWithdrawals.length === 0" class="manager-empty-state">
|
||||
Активных заявок на выплату сейчас нет.
|
||||
</div>
|
||||
<div v-else class="space-y-4">
|
||||
<article
|
||||
v-for="withdrawal in filteredWithdrawals"
|
||||
:key="withdrawal.id"
|
||||
class="surface-card rounded-3xl px-5 py-5"
|
||||
>
|
||||
<div class="flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
|
||||
<div class="space-y-1">
|
||||
<p class="text-sm font-semibold text-[#123824]">{{ withdrawal.requesterFullName }}</p>
|
||||
<p class="text-sm text-[#355947]">{{ withdrawal.requesterEmail }}</p>
|
||||
<p v-if="withdrawal.companyName" class="text-sm text-[#355947]">{{ withdrawal.companyName }}</p>
|
||||
<p class="text-sm text-[#355947]">Сумма: {{ withdrawal.amount }}</p>
|
||||
<p class="text-xs text-[#5c7b69]">{{ new Date(withdrawal.createdAt).toLocaleString() }}</p>
|
||||
</div>
|
||||
<NuxtLink :to="`/bonus-system/withdrawals/${withdrawal.id}`" class="btn btn-accent btn-sm border-0">
|
||||
Проверить выплату
|
||||
</NuxtLink>
|
||||
<template v-if="activeTab === 'balances'">
|
||||
<div v-if="balancesQuery.loading.value || usersQuery.loading.value" class="manager-empty-state">
|
||||
Загружаем бонусные счета...
|
||||
</div>
|
||||
<div v-else-if="filteredBalances.length === 0" class="manager-empty-state">
|
||||
Бонусных счетов пока нет.
|
||||
</div>
|
||||
<div v-else class="space-y-4">
|
||||
<div class="grid gap-4 sm:grid-cols-2 xl:grid-cols-4 2xl:grid-cols-6">
|
||||
<UsersGridCard
|
||||
v-for="item in visibleBalances"
|
||||
:key="item.userId"
|
||||
:to="`/admin/bonuses/balances/${item.userId}`"
|
||||
:full-name="item.fullName"
|
||||
:avatar-src="messengerConnectionAvatarSrc(usersById.get(item.userId)?.telegramConnection)"
|
||||
:initials="userInitials(item.fullName)"
|
||||
:meta-value="`${formatAmount(item.balance)} ₽`"
|
||||
/>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="canLoadMoreBalances"
|
||||
ref="loadMoreBalancesSentinel"
|
||||
class="flex justify-center"
|
||||
>
|
||||
<button class="btn btn-outline border-[#d7e9de] bg-white" @click="loadMoreBalances">
|
||||
Показать ещё {{ Math.min(remainingBalancesCount, 24) }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-else-if="activeTab === 'rewards'">
|
||||
<div v-if="filteredProducts.length === 0" class="manager-empty-state">
|
||||
По текущему запросу товары не найдены.
|
||||
</div>
|
||||
<div v-else class="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
|
||||
<article
|
||||
v-for="product in filteredProducts"
|
||||
:key="product.id"
|
||||
class="surface-card rounded-[28px] p-5"
|
||||
>
|
||||
<div class="flex items-center gap-4">
|
||||
<div
|
||||
class="flex h-16 w-16 shrink-0 items-center justify-center rounded-[20px] text-lg font-black text-white"
|
||||
:style="{ background: product.gradient }"
|
||||
>
|
||||
{{ productVisualLabel(product) }}
|
||||
</div>
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<span class="rounded-full bg-[#eef5f0] px-3 py-1 text-xs font-bold uppercase tracking-[0.12em] text-[#4e7060]">
|
||||
{{ product.store }}
|
||||
</span>
|
||||
<span class="rounded-full bg-[#fff8dc] px-3 py-1 text-xs font-bold text-[#7a5b00]">
|
||||
{{ formatAmount(product.amount) }} ₽
|
||||
</span>
|
||||
</div>
|
||||
<h2 class="mt-3 text-lg font-bold leading-tight text-[#123824]">{{ compactProductTitle(product) }}</h2>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<div v-if="withdrawalsQuery.loading.value" class="manager-empty-state">
|
||||
Загружаем заявки...
|
||||
</div>
|
||||
<div v-else-if="filteredWithdrawals.length === 0" class="manager-empty-state">
|
||||
Активных заявок на выплату сейчас нет.
|
||||
</div>
|
||||
<div v-else class="space-y-3">
|
||||
<NuxtLink
|
||||
v-for="withdrawal in visibleWithdrawals"
|
||||
:key="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"
|
||||
>
|
||||
<div class="grid gap-4 md:grid-cols-[minmax(0,1fr)_minmax(0,1.4fr)_180px_140px] md:items-center md:gap-6">
|
||||
<div class="min-w-0">
|
||||
<h2 class="truncate text-lg font-bold text-[#123824]">{{ formatWithdrawalCode(withdrawal.id) }}</h2>
|
||||
<p class="mt-1 text-sm text-[#688676]">{{ formatDateTime(withdrawal.createdAt) }}</p>
|
||||
</div>
|
||||
|
||||
<div class="flex min-w-0 items-center gap-3">
|
||||
<img
|
||||
v-if="requesterMeta(withdrawal).avatarSrc"
|
||||
:src="requesterMeta(withdrawal).avatarSrc"
|
||||
:alt="withdrawal.requesterFullName"
|
||||
class="h-12 w-12 rounded-[16px] object-cover"
|
||||
>
|
||||
<div
|
||||
v-else
|
||||
class="flex h-12 w-12 shrink-0 items-center justify-center rounded-[16px] bg-[linear-gradient(135deg,#dff7e9_0%,#c2ead3_100%)] text-sm font-black text-[#123824]"
|
||||
>
|
||||
{{ requesterMeta(withdrawal).initials }}
|
||||
</div>
|
||||
|
||||
<div class="min-w-0">
|
||||
<p class="truncate text-sm font-semibold text-[#123824]">{{ withdrawal.requesterFullName }}</p>
|
||||
<p class="truncate text-sm text-[#557562]">
|
||||
{{ requesterMeta(withdrawal).companyName || withdrawal.requesterEmail }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-start">
|
||||
<span
|
||||
class="rounded-full px-3 py-1 text-sm font-semibold"
|
||||
:class="withdrawalStatusClass(withdrawal.status)"
|
||||
>
|
||||
{{ withdrawalStatusLabel(withdrawal.status) }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="text-left md:text-right">
|
||||
<p class="text-base font-bold text-[#123824]">{{ formatAmount(withdrawal.amount) }} ₽</p>
|
||||
</div>
|
||||
</div>
|
||||
</NuxtLink>
|
||||
|
||||
<div
|
||||
v-if="canLoadMoreWithdrawals"
|
||||
ref="loadMoreWithdrawalsSentinel"
|
||||
class="flex justify-center"
|
||||
>
|
||||
<button class="btn btn-outline border-[#d7e9de] bg-white" @click="loadMoreWithdrawals">
|
||||
Показать ещё {{ Math.min(remainingWithdrawalsCount, 24) }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
@@ -1,49 +1,139 @@
|
||||
<script setup lang="ts">
|
||||
import { useMutation } from '@vue/apollo-composable';
|
||||
import { CreateReferralDocument } from '~/composables/graphql/generated';
|
||||
import { useMutation, useQuery } from '@vue/apollo-composable';
|
||||
import {
|
||||
CreateBonusProgramLinkDocument,
|
||||
ManagerUsersDocument,
|
||||
type CreateBonusProgramLinkMutation,
|
||||
type ManagerUsersQuery,
|
||||
} from '~/composables/graphql/generated';
|
||||
|
||||
definePageMeta({
|
||||
middleware: ['manager-only'],
|
||||
path: '/admin/bonuses/links/new',
|
||||
alias: ['/bonus-system/links/new', '/bonus-system/referrals/new', '/admin/bonuses/referrals/new'],
|
||||
});
|
||||
|
||||
const refereeUserId = ref('');
|
||||
const createdReferralId = ref('');
|
||||
const createReferralMutation = useMutation(CreateReferralDocument);
|
||||
type ManagerUserItem = ManagerUsersQuery['managerUsers'][number];
|
||||
const userId = ref('');
|
||||
const errorMessage = ref('');
|
||||
const bonusProgramLink = ref('');
|
||||
const bonusProgramLinkExpiresAt = ref('');
|
||||
const usersQuery = useQuery(ManagerUsersDocument);
|
||||
const createBonusProgramLinkMutation = useMutation(CreateBonusProgramLinkDocument, { throws: 'never' });
|
||||
|
||||
async function createReferral() {
|
||||
createdReferralId.value = '';
|
||||
const response = await createReferralMutation.mutate({
|
||||
input: {
|
||||
refereeUserId: refereeUserId.value,
|
||||
},
|
||||
const clientOptions = computed<ManagerUserItem[]>(() => (
|
||||
(usersQuery.result.value?.managerUsers ?? [])
|
||||
.filter((user) => user.role === 'CLIENT')
|
||||
));
|
||||
|
||||
function userOptionLabel(user: ManagerUserItem) {
|
||||
return [user.fullName, user.companyName || user.email]
|
||||
.filter(Boolean)
|
||||
.join(' • ');
|
||||
}
|
||||
|
||||
async function createBonusAccountLink() {
|
||||
errorMessage.value = '';
|
||||
bonusProgramLink.value = '';
|
||||
bonusProgramLinkExpiresAt.value = '';
|
||||
|
||||
if (!userId.value) {
|
||||
errorMessage.value = 'Выберите клиента.';
|
||||
return;
|
||||
}
|
||||
|
||||
const bonusLinkResponse = await createBonusProgramLinkMutation.mutate({
|
||||
userId: userId.value,
|
||||
});
|
||||
|
||||
createdReferralId.value = response?.data?.createReferral.id ?? '';
|
||||
const bonusLinkPayload: CreateBonusProgramLinkMutation['createBonusProgramLink'] | undefined = bonusLinkResponse?.data?.createBonusProgramLink;
|
||||
if (!bonusLinkPayload?.url) {
|
||||
errorMessage.value = createBonusProgramLinkMutation.error.value?.message || 'Не удалось сгенерировать ссылку.';
|
||||
return;
|
||||
}
|
||||
|
||||
bonusProgramLink.value = bonusLinkPayload.url;
|
||||
bonusProgramLinkExpiresAt.value = bonusLinkPayload.expiresAt;
|
||||
}
|
||||
|
||||
async function copyBonusProgramLink() {
|
||||
if (!bonusProgramLink.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
await navigator.clipboard.writeText(bonusProgramLink.value);
|
||||
}
|
||||
|
||||
function formatDateTime(value: string) {
|
||||
return new Date(value).toLocaleString('ru-RU');
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="space-y-6 max-w-3xl">
|
||||
<NuxtLink to="/bonus-system" class="text-sm font-semibold text-[#0d854a]">← Назад к бонусам</NuxtLink>
|
||||
<UiBackHeader
|
||||
to="/admin/bonuses/balances"
|
||||
back-label="Назад к бонусным счетам"
|
||||
title="Создать бонусный счет"
|
||||
subtitle="Менеджер выбирает клиента и сразу получает ссылку, которую можно переслать ему."
|
||||
/>
|
||||
|
||||
<div class="manager-hero">
|
||||
<p class="manager-eyebrow">Бонусы</p>
|
||||
<h1 class="manager-title">Создать реферальную связь</h1>
|
||||
</div>
|
||||
|
||||
<div class="surface-card rounded-3xl p-5">
|
||||
<div class="surface-card rounded-3xl p-5 space-y-4">
|
||||
<label class="form-control">
|
||||
<span class="label-text">ID приглашенного пользователя</span>
|
||||
<input v-model="refereeUserId" class="input manager-field w-full" placeholder="user id">
|
||||
<span class="label-text">Клиент</span>
|
||||
<select v-model="userId" class="select manager-field w-full">
|
||||
<option value="">Выберите клиента</option>
|
||||
<option v-for="user in clientOptions" :key="user.id" :value="user.id">
|
||||
{{ userOptionLabel(user) }}
|
||||
</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<div class="mt-4">
|
||||
<button class="btn btn-primary border-0" @click="createReferral">Создать</button>
|
||||
<button
|
||||
class="btn btn-primary border-0"
|
||||
:disabled="createBonusProgramLinkMutation.loading.value || usersQuery.loading.value"
|
||||
@click="createBonusAccountLink"
|
||||
>
|
||||
{{ createBonusProgramLinkMutation.loading.value ? 'Генерируем...' : 'Создать ссылку' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="createdReferralId" class="surface-card rounded-3xl p-5 text-sm text-[#123824]">
|
||||
Создана связь: <span class="font-semibold">{{ createdReferralId }}</span>
|
||||
<div v-if="errorMessage" class="surface-card rounded-3xl border border-[#d27d7d] bg-[#fff4f4] p-5 text-sm text-[#8b2a2a]">
|
||||
{{ errorMessage }}
|
||||
</div>
|
||||
|
||||
<article v-if="bonusProgramLink" class="surface-card rounded-3xl p-5 space-y-4">
|
||||
<div class="space-y-2">
|
||||
<p class="text-sm font-semibold text-[#123824]">Ссылка в бонусный кабинет</p>
|
||||
<p class="text-sm text-[#466653]">
|
||||
Эту ссылку менеджер может сразу отправить клиенту.
|
||||
</p>
|
||||
<div class="rounded-[20px] bg-[#f8fbf9] px-4 py-3 text-sm font-semibold text-[#123824] break-all">
|
||||
{{ bonusProgramLink }}
|
||||
</div>
|
||||
<p v-if="bonusProgramLinkExpiresAt" class="text-xs text-[#5c7b69]">
|
||||
Действует до {{ formatDateTime(bonusProgramLinkExpiresAt) }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap gap-3">
|
||||
<a
|
||||
:href="bonusProgramLink"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
class="btn rounded-full border border-[#d7e9de] bg-white px-5 text-[#123824] hover:bg-[#f3f8f5]"
|
||||
>
|
||||
Открыть
|
||||
</a>
|
||||
<button
|
||||
class="btn rounded-full border-0 bg-[#139957] px-5 text-white hover:bg-[#0d854a]"
|
||||
@click="copyBonusProgramLink"
|
||||
>
|
||||
Скопировать
|
||||
</button>
|
||||
</div>
|
||||
</article>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
@@ -4,6 +4,8 @@ import { AddBonusTransactionDocument } from '~/composables/graphql/generated';
|
||||
|
||||
definePageMeta({
|
||||
middleware: ['manager-only'],
|
||||
path: '/admin/bonuses/transactions/new',
|
||||
alias: ['/bonus-system/transactions/new'],
|
||||
});
|
||||
|
||||
const userId = ref('');
|
||||
@@ -29,12 +31,11 @@ async function addBonus() {
|
||||
|
||||
<template>
|
||||
<section class="space-y-6 max-w-3xl">
|
||||
<NuxtLink to="/bonus-system" class="text-sm font-semibold text-[#0d854a]">← Назад к бонусам</NuxtLink>
|
||||
|
||||
<div class="manager-hero">
|
||||
<p class="manager-eyebrow">Бонусы</p>
|
||||
<h1 class="manager-title">Добавить бонусную транзакцию</h1>
|
||||
</div>
|
||||
<UiBackHeader
|
||||
to="/admin/bonuses/balances"
|
||||
back-label="Назад к бонусным счетам"
|
||||
title="Добавить бонусную транзакцию"
|
||||
/>
|
||||
|
||||
<div class="surface-card rounded-3xl p-5 space-y-3">
|
||||
<label class="form-control">
|
||||
|
||||
@@ -8,6 +8,8 @@ import {
|
||||
|
||||
definePageMeta({
|
||||
middleware: ['manager-only'],
|
||||
path: '/admin/bonuses/requests/:id',
|
||||
alias: ['/bonus-system/withdrawals/:id'],
|
||||
});
|
||||
|
||||
const route = useRoute();
|
||||
@@ -19,14 +21,22 @@ const withdrawalsQuery = useQuery(ManagerWithdrawalRequestsDocument, {
|
||||
});
|
||||
const reviewMutation = useMutation(ReviewRewardWithdrawalDocument);
|
||||
|
||||
const decision = ref<'APPROVE' | 'REJECT'>('APPROVE');
|
||||
const reviewComment = ref('');
|
||||
const reviewResult = ref('');
|
||||
const isProcessed = ref(true);
|
||||
const savePending = computed(() => reviewMutation.loading.value);
|
||||
|
||||
const currentWithdrawal = computed(() =>
|
||||
(withdrawalsQuery.result.value?.managerWithdrawalRequests ?? []).find((item: WithdrawalItem) => item.id === withdrawalId.value),
|
||||
);
|
||||
|
||||
watch(currentWithdrawal, (withdrawal) => {
|
||||
if (!withdrawal) {
|
||||
return;
|
||||
}
|
||||
|
||||
isProcessed.value = withdrawal.status !== 'REJECTED';
|
||||
}, { immediate: true });
|
||||
|
||||
async function reviewWithdrawal() {
|
||||
if (!currentWithdrawal.value) {
|
||||
return;
|
||||
@@ -35,8 +45,7 @@ async function reviewWithdrawal() {
|
||||
const response = await reviewMutation.mutate({
|
||||
input: {
|
||||
withdrawalId: currentWithdrawal.value.id,
|
||||
decision: decision.value,
|
||||
reviewComment: reviewComment.value || undefined,
|
||||
decision: isProcessed.value ? 'APPROVE' : 'REJECT',
|
||||
},
|
||||
});
|
||||
|
||||
@@ -46,9 +55,7 @@ async function reviewWithdrawal() {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="space-y-6 max-w-3xl">
|
||||
<NuxtLink to="/bonus-system" class="text-sm font-semibold text-[#0d854a]">← Назад к бонусам</NuxtLink>
|
||||
|
||||
<section class="space-y-6">
|
||||
<div v-if="withdrawalsQuery.loading.value" class="manager-empty-state">
|
||||
Загружаем заявку на вывод...
|
||||
</div>
|
||||
@@ -58,30 +65,42 @@ async function reviewWithdrawal() {
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
<div class="manager-hero">
|
||||
<p class="manager-eyebrow">Вывод</p>
|
||||
<h1 class="manager-title">Проверка заявки на вывод</h1>
|
||||
<p class="manager-copy">
|
||||
{{ currentWithdrawal.requesterFullName }} · {{ currentWithdrawal.requesterEmail }} · Сумма: {{ currentWithdrawal.amount }}
|
||||
</p>
|
||||
</div>
|
||||
<UiBackHeader
|
||||
to="/admin/bonuses/requests"
|
||||
back-label="Назад к бонусам"
|
||||
title="Проверка заявки на вывод"
|
||||
:subtitle="`${currentWithdrawal.requesterFullName} · ${currentWithdrawal.requesterEmail} · Сумма: ${currentWithdrawal.amount}`"
|
||||
/>
|
||||
|
||||
<div class="surface-card rounded-3xl p-5 space-y-3">
|
||||
<label class="form-control">
|
||||
<span class="label-text">Решение</span>
|
||||
<select v-model="decision" class="select manager-field w-full">
|
||||
<option value="APPROVE">Одобрить</option>
|
||||
<option value="REJECT">Отклонить</option>
|
||||
</select>
|
||||
</label>
|
||||
<div class="surface-card rounded-3xl p-5 md:p-6">
|
||||
<div class="space-y-5">
|
||||
<label class="flex items-start gap-4 rounded-[24px] bg-[#f5faf7] px-4 py-4">
|
||||
<input
|
||||
v-model="isProcessed"
|
||||
type="checkbox"
|
||||
class="checkbox mt-1 border-[#b9d7c5] bg-white [--chkbg:#123824] [--chkfg:#ffffff]"
|
||||
>
|
||||
<span class="space-y-1">
|
||||
<span class="block text-base font-bold text-[#123824]">Проведено</span>
|
||||
<span class="block text-sm leading-6 text-[#5c7b69]">
|
||||
Отметьте выплату как проведённую. Если галочка снята, заявка будет отклонена.
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<label class="form-control">
|
||||
<span class="label-text">Комментарий</span>
|
||||
<textarea v-model="reviewComment" class="textarea manager-field min-h-28 w-full" placeholder="Комментарий для заявки" />
|
||||
</label>
|
||||
|
||||
<div>
|
||||
<button class="btn btn-primary border-0" @click="reviewWithdrawal">Сохранить решение</button>
|
||||
<button
|
||||
class="btn h-12 w-full rounded-full border-0 bg-[#123824] text-white shadow-[0_16px_32px_rgba(18,56,36,0.18)] hover:bg-[#0f2f20] disabled:border-0 disabled:bg-[#cfd8d2] disabled:text-[#6f8579]"
|
||||
:disabled="savePending"
|
||||
@click="reviewWithdrawal"
|
||||
>
|
||||
{{
|
||||
savePending
|
||||
? 'Сохраняем...'
|
||||
: isProcessed
|
||||
? 'Провести выплату'
|
||||
: 'Отклонить заявку'
|
||||
}}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -18,9 +18,6 @@ const {
|
||||
items: cartItems,
|
||||
fetchCart,
|
||||
selectedDeliveryAddressId,
|
||||
totalPositions,
|
||||
totalItems,
|
||||
totalVolume,
|
||||
incrementQuantity,
|
||||
decrementQuantity,
|
||||
removeProduct,
|
||||
@@ -60,15 +57,6 @@ onMounted(() => {
|
||||
void fetchCart(true);
|
||||
});
|
||||
|
||||
function lineVolume(productId: string) {
|
||||
const item = cartItems.value.find((entry) => entry.productId === productId);
|
||||
if (!item) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return Number(item.quantity) * Number(item.parameters.width) * Number(item.parameters.thickness);
|
||||
}
|
||||
|
||||
function increment(productId: string) {
|
||||
success.value = '';
|
||||
errorMessage.value = '';
|
||||
@@ -157,8 +145,32 @@ async function submitCart() {
|
||||
<NuxtLink to="/profile/counterparty" class="link link-hover font-semibold">профиле</NuxtLink>.
|
||||
</div>
|
||||
|
||||
<h2 class="text-xl font-bold text-[#123824]">Состав заказа</h2>
|
||||
|
||||
<div v-if="cartItems.length === 0" class="alert surface-card">
|
||||
Корзина пока пустая. Добавьте товар из каталога.
|
||||
</div>
|
||||
|
||||
<OrdersOrderItemsTable
|
||||
v-else
|
||||
mode="cart"
|
||||
:framed="false"
|
||||
:items="cartItems.map((item) => ({
|
||||
id: item.productId,
|
||||
productName: item.productName,
|
||||
sku: item.sku,
|
||||
quantity: item.quantity,
|
||||
parameters: item.parameters,
|
||||
unitPrice: null,
|
||||
lineTotal: null,
|
||||
}))"
|
||||
@increment="increment"
|
||||
@decrement="decrement"
|
||||
@remove="removeFromCart"
|
||||
/>
|
||||
|
||||
<div class="surface-card rounded-3xl p-4 md:p-5">
|
||||
<h2 class="text-lg font-bold text-[#123824]">Адрес доставки</h2>
|
||||
<h2 class="text-lg font-bold text-[#123824]">Информация о доставке</h2>
|
||||
|
||||
<div v-if="deliveryAddressesQuery.loading.value" class="alert mt-3 surface-card">
|
||||
Загружаем адреса...
|
||||
@@ -171,7 +183,7 @@ async function submitCart() {
|
||||
<label
|
||||
v-for="address in deliveryAddresses"
|
||||
:key="address.id"
|
||||
class="flex cursor-pointer items-start gap-3 rounded-2xl bg-white p-3 transition hover:shadow-md"
|
||||
class="surface-card surface-card-interactive flex items-start gap-3 rounded-2xl p-3"
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
@@ -191,54 +203,6 @@ async function submitCart() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h2 class="text-xl font-bold text-[#123824]">Позиции</h2>
|
||||
|
||||
<div v-if="cartItems.length === 0" class="alert surface-card">
|
||||
Корзина пока пустая. Добавьте товар из каталога.
|
||||
</div>
|
||||
|
||||
<ul v-else class="space-y-3">
|
||||
<li
|
||||
v-for="item in cartItems"
|
||||
:key="item.productId"
|
||||
class="surface-card flex flex-col gap-3 rounded-3xl px-4 py-4 md:flex-row md:items-center md:justify-between md:px-5 md:py-5"
|
||||
>
|
||||
<div>
|
||||
<p class="font-semibold text-[#123824]">{{ item.productName }}</p>
|
||||
<p class="text-xs opacity-70">SKU: {{ item.sku }}</p>
|
||||
<p class="text-sm opacity-80">
|
||||
Объем: {{ lineVolume(item.productId) }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<button class="btn btn-square btn-sm" @click="decrement(item.productId)">-</button>
|
||||
<span class="min-w-8 text-center font-semibold">{{ item.quantity }}</span>
|
||||
<button class="btn btn-square btn-sm" @click="increment(item.productId)">+</button>
|
||||
<button class="btn btn-ghost btn-sm text-error" @click="removeFromCart(item.productId)">
|
||||
Удалить
|
||||
</button>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<div class="divider my-1" />
|
||||
|
||||
<div class="space-y-2 text-sm text-[#214735]">
|
||||
<div class="flex items-center justify-between">
|
||||
<span>Позиций</span>
|
||||
<span class="font-semibold">{{ totalPositions }}</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<span>Количество, шт.</span>
|
||||
<span class="font-semibold">{{ totalItems }}</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<span>Суммарный объем</span>
|
||||
<span class="font-semibold">{{ totalVolume }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
class="btn w-full bg-[#139957] text-white hover:bg-[#0d854a]"
|
||||
:disabled="sending || counterpartyLoading || !isCounterpartyComplete || !selectedDeliveryAddressId || cartItems.length === 0"
|
||||
|
||||
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,127 +1,340 @@
|
||||
<script setup lang="ts">
|
||||
import { useMutation, useQuery } from '@vue/apollo-composable';
|
||||
import OrderStatusBadge from '~/components/orders/OrderStatusBadge.vue';
|
||||
import {
|
||||
BlockOrderDocument,
|
||||
CompleteOrderDocument,
|
||||
ManagerFinalizeOrderDocument,
|
||||
ManagerOrdersDocument,
|
||||
ManagerUsersDetailDocument,
|
||||
ManagerSetOrderOfferDocument,
|
||||
StartOrderWorkDocument,
|
||||
ManagerSetOrderStatusDocument,
|
||||
OrderStatus,
|
||||
OrderDetailDocument,
|
||||
type OrderDetailQuery,
|
||||
type ManagerUsersDetailQuery,
|
||||
} from '~/composables/graphql/generated';
|
||||
import { messengerConnectionAvatarSrc } from '~/composables/useMessengerConnectionPresentation';
|
||||
import {
|
||||
formatPrice,
|
||||
} from '~/composables/useOrderDetailPresentation';
|
||||
import { formatOrderCode } from '~/composables/useOrderCodePresentation';
|
||||
|
||||
definePageMeta({
|
||||
middleware: ['manager-only'],
|
||||
path: '/admin/orders/:id',
|
||||
alias: ['/client-orders/:id'],
|
||||
});
|
||||
|
||||
const route = useRoute();
|
||||
const orderId = computed(() => String(route.params.id || ''));
|
||||
|
||||
const ordersQuery = useQuery(ManagerOrdersDocument, { status: null });
|
||||
type ManagerOrderItem = NonNullable<OrderDetailQuery['order']>;
|
||||
type ManagerCustomerItem = ManagerUsersDetailQuery['managerUsers'][number];
|
||||
type StatusOption = {
|
||||
value: OrderStatus;
|
||||
label: string;
|
||||
};
|
||||
|
||||
const orderQuery = useQuery(OrderDetailDocument, () => ({
|
||||
id: orderId.value,
|
||||
}));
|
||||
const managerUsersQuery = useQuery(ManagerUsersDetailDocument);
|
||||
|
||||
const setOfferMutation = useMutation(ManagerSetOrderOfferDocument);
|
||||
const finalizeMutation = useMutation(ManagerFinalizeOrderDocument);
|
||||
const blockMutation = useMutation(BlockOrderDocument);
|
||||
const startWorkMutation = useMutation(StartOrderWorkDocument);
|
||||
const completeWorkMutation = useMutation(CompleteOrderDocument);
|
||||
const setOrderStatusMutation = useMutation(ManagerSetOrderStatusDocument);
|
||||
|
||||
const currentOrder = computed(() =>
|
||||
(ordersQuery.result.value?.managerOrders ?? []).find((item) => item.id === orderId.value),
|
||||
const itemPriceDrafts = reactive<Record<string, string>>({});
|
||||
const deliveryTermsDraft = ref('');
|
||||
const deliveryFeeDraft = ref('');
|
||||
const editingPriceItemId = ref<string | null>(null);
|
||||
const editingDeliveryTerms = ref(false);
|
||||
const editingDeliveryFee = ref(false);
|
||||
const editingStatus = ref(false);
|
||||
const statusDraft = ref<OrderStatus | ''>('');
|
||||
const autosavePending = ref(false);
|
||||
const statusMutationPending = ref(false);
|
||||
let autosaveTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
const currentOrder = computed<ManagerOrderItem | null>(() =>
|
||||
orderQuery.result.value?.order ?? null,
|
||||
);
|
||||
|
||||
const offerForm = reactive({
|
||||
deliveryTerms: '',
|
||||
deliveryFee: 0,
|
||||
totalPrice: 0,
|
||||
});
|
||||
|
||||
watchEffect(() => {
|
||||
if (!currentOrder.value) {
|
||||
return;
|
||||
const currentOrderCode = computed(() => formatOrderCode(currentOrder.value?.code));
|
||||
const currentCustomer = computed<ManagerCustomerItem | null>(() => {
|
||||
const customerId = currentOrder.value?.customerId;
|
||||
if (!customerId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
offerForm.deliveryTerms = currentOrder.value.deliveryTerms || 'Доставка 3-5 дней';
|
||||
offerForm.deliveryFee = Number(currentOrder.value.deliveryFee ?? 1000);
|
||||
offerForm.totalPrice = Number(currentOrder.value.totalPrice ?? 12500);
|
||||
return (managerUsersQuery.result.value?.managerUsers ?? []).find((item) => item.id === customerId) ?? null;
|
||||
});
|
||||
|
||||
function userInitials(fullName: string) {
|
||||
const parts = fullName
|
||||
.trim()
|
||||
.split(/\s+/)
|
||||
.filter(Boolean)
|
||||
.slice(0, 2);
|
||||
|
||||
if (!parts.length) {
|
||||
return 'FR';
|
||||
}
|
||||
|
||||
return parts.map((part) => part[0]?.toUpperCase() ?? '').join('');
|
||||
}
|
||||
|
||||
watch(
|
||||
currentOrder,
|
||||
(order) => {
|
||||
editingPriceItemId.value = null;
|
||||
editingDeliveryTerms.value = false;
|
||||
editingDeliveryFee.value = false;
|
||||
editingStatus.value = false;
|
||||
statusDraft.value = '';
|
||||
|
||||
for (const key of Object.keys(itemPriceDrafts)) {
|
||||
itemPriceDrafts[key] = '';
|
||||
}
|
||||
|
||||
if (!order) {
|
||||
deliveryTermsDraft.value = '';
|
||||
deliveryFeeDraft.value = '';
|
||||
return;
|
||||
}
|
||||
|
||||
for (const item of order.items) {
|
||||
itemPriceDrafts[item.id] = item.unitPrice == null ? '' : String(item.unitPrice);
|
||||
}
|
||||
|
||||
deliveryTermsDraft.value = order.deliveryTerms ?? '';
|
||||
deliveryFeeDraft.value = order.deliveryFee == null ? '' : String(order.deliveryFee);
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
function parseMoneyDraft(value: string) {
|
||||
const trimmed = String(value).replace(',', '.').trim();
|
||||
if (!trimmed) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const normalized = Number(trimmed);
|
||||
if (!Number.isFinite(normalized) || normalized < 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return Math.round((normalized + Number.EPSILON) * 100) / 100;
|
||||
}
|
||||
|
||||
const draftDeliveryTerms = computed(() => deliveryTermsDraft.value.trim() || currentOrder.value?.deliveryTerms || null);
|
||||
const draftDeliveryFee = computed(() => parseMoneyDraft(deliveryFeeDraft.value));
|
||||
const canEditOffer = computed(() => currentOrder.value != null);
|
||||
const activePriceItemIds = computed(() => (
|
||||
editingPriceItemId.value ? [editingPriceItemId.value] : []
|
||||
));
|
||||
const statusOptions: StatusOption[] = [
|
||||
{ value: OrderStatus.New, label: 'Заявка' },
|
||||
{ value: OrderStatus.ManagerProcessing, label: 'В обработке' },
|
||||
{ value: OrderStatus.WaitingDoubleConfirm, label: 'Предложение' },
|
||||
{ value: OrderStatus.Confirmed, label: 'Подтвержден' },
|
||||
{ value: OrderStatus.InProgress, label: 'В работе' },
|
||||
{ value: OrderStatus.Completed, label: 'Завершен' },
|
||||
{ value: OrderStatus.ManagerBlocked, label: 'Пауза' },
|
||||
{ value: OrderStatus.ManagerRejected, label: 'Отклонен менеджером' },
|
||||
{ value: OrderStatus.ClientRejected, label: 'Отклонен клиентом' },
|
||||
];
|
||||
|
||||
const offerSignature = computed(() => {
|
||||
if (!currentOrder.value) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return JSON.stringify({
|
||||
deliveryTerms: deliveryTermsDraft.value.trim(),
|
||||
deliveryFee: parseMoneyDraft(deliveryFeeDraft.value),
|
||||
itemPrices: currentOrder.value.items.map((item) => ({
|
||||
itemId: item.id,
|
||||
unitPrice: parseMoneyDraft(itemPriceDrafts[item.id] ?? ''),
|
||||
})),
|
||||
});
|
||||
});
|
||||
|
||||
const publishedSignature = computed(() => {
|
||||
if (!currentOrder.value) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return JSON.stringify({
|
||||
deliveryTerms: currentOrder.value.deliveryTerms ?? '',
|
||||
deliveryFee: currentOrder.value.deliveryFee ?? null,
|
||||
itemPrices: currentOrder.value.items.map((item) => ({
|
||||
itemId: item.id,
|
||||
unitPrice: item.unitPrice ?? null,
|
||||
})),
|
||||
});
|
||||
});
|
||||
|
||||
const offerReady = computed(() => {
|
||||
if (!currentOrder.value) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const deliveryFee = parseMoneyDraft(deliveryFeeDraft.value);
|
||||
if (deliveryFee == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return currentOrder.value.items.every((item) => parseMoneyDraft(itemPriceDrafts[item.id] ?? '') != null);
|
||||
});
|
||||
|
||||
const offerTotal = computed(() => {
|
||||
if (!currentOrder.value || !offerReady.value) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const productsTotal = currentOrder.value.items.reduce((sum, item) => (
|
||||
sum + item.quantity * (parseMoneyDraft(itemPriceDrafts[item.id] ?? '') ?? 0)
|
||||
), 0);
|
||||
|
||||
return Math.round((productsTotal + (parseMoneyDraft(deliveryFeeDraft.value) ?? 0) + Number.EPSILON) * 100) / 100;
|
||||
});
|
||||
|
||||
async function refetchOrder() {
|
||||
await ordersQuery.refetch({ status: null });
|
||||
await orderQuery.refetch({ id: orderId.value });
|
||||
}
|
||||
|
||||
async function publishOffer() {
|
||||
if (!currentOrder.value) {
|
||||
async function focusElement(selector: string) {
|
||||
await nextTick();
|
||||
document.querySelector<HTMLInputElement | HTMLSelectElement>(selector)?.focus();
|
||||
}
|
||||
|
||||
async function saveOffer() {
|
||||
if (!currentOrder.value || !canEditOffer.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
await setOfferMutation.mutate({
|
||||
input: {
|
||||
autosavePending.value = true;
|
||||
try {
|
||||
await setOfferMutation.mutate({
|
||||
input: {
|
||||
orderId: currentOrder.value.id,
|
||||
itemPrices: currentOrder.value.items.map((item) => ({
|
||||
itemId: item.id,
|
||||
unitPrice: parseMoneyDraft(itemPriceDrafts[item.id] ?? ''),
|
||||
})),
|
||||
deliveryTerms: deliveryTermsDraft.value.trim(),
|
||||
deliveryFee: parseMoneyDraft(deliveryFeeDraft.value),
|
||||
},
|
||||
});
|
||||
await refetchOrder();
|
||||
}
|
||||
finally {
|
||||
autosavePending.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function openPriceEditor(itemId: string) {
|
||||
if (!canEditOffer.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
editingPriceItemId.value = itemId;
|
||||
await focusElement(`[data-unit-price-input="${itemId}"]`);
|
||||
}
|
||||
|
||||
function closePriceEditor(itemId: string) {
|
||||
if (editingPriceItemId.value === itemId) {
|
||||
editingPriceItemId.value = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function openDeliveryTermsEditor() {
|
||||
if (!canEditOffer.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
editingDeliveryTerms.value = true;
|
||||
await focusElement('[data-delivery-terms-input]');
|
||||
}
|
||||
|
||||
function closeDeliveryTermsEditor() {
|
||||
editingDeliveryTerms.value = false;
|
||||
}
|
||||
|
||||
async function openDeliveryFeeEditor() {
|
||||
if (!canEditOffer.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
editingDeliveryFee.value = true;
|
||||
await focusElement('[data-delivery-fee-input]');
|
||||
}
|
||||
|
||||
function closeDeliveryFeeEditor() {
|
||||
editingDeliveryFee.value = false;
|
||||
}
|
||||
|
||||
async function openStatusEditor() {
|
||||
if (statusMutationPending.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
editingStatus.value = true;
|
||||
statusDraft.value = currentOrder.value?.status ?? '';
|
||||
await focusElement('[data-manager-status-select]');
|
||||
}
|
||||
|
||||
function closeStatusEditor() {
|
||||
if (!statusMutationPending.value) {
|
||||
editingStatus.value = false;
|
||||
statusDraft.value = '';
|
||||
}
|
||||
}
|
||||
|
||||
async function applyStatusChange() {
|
||||
if (!currentOrder.value || !statusDraft.value) {
|
||||
closeStatusEditor();
|
||||
return;
|
||||
}
|
||||
|
||||
if (statusDraft.value === currentOrder.value.status) {
|
||||
closeStatusEditor();
|
||||
return;
|
||||
}
|
||||
|
||||
statusMutationPending.value = true;
|
||||
|
||||
try {
|
||||
await setOrderStatusMutation.mutate({
|
||||
orderId: currentOrder.value.id,
|
||||
deliveryTerms: offerForm.deliveryTerms,
|
||||
deliveryFee: Number(offerForm.deliveryFee),
|
||||
totalPrice: Number(offerForm.totalPrice),
|
||||
},
|
||||
});
|
||||
|
||||
await refetchOrder();
|
||||
}
|
||||
|
||||
async function approveOrder() {
|
||||
if (!currentOrder.value) {
|
||||
return;
|
||||
status: statusDraft.value,
|
||||
});
|
||||
await refetchOrder();
|
||||
}
|
||||
|
||||
await finalizeMutation.mutate({ orderId: currentOrder.value.id, decision: 'APPROVE' });
|
||||
await refetchOrder();
|
||||
}
|
||||
|
||||
async function rejectOrder() {
|
||||
if (!currentOrder.value) {
|
||||
return;
|
||||
finally {
|
||||
statusMutationPending.value = false;
|
||||
closeStatusEditor();
|
||||
}
|
||||
|
||||
await finalizeMutation.mutate({ orderId: currentOrder.value.id, decision: 'REJECT' });
|
||||
await refetchOrder();
|
||||
}
|
||||
|
||||
async function blockOrder() {
|
||||
if (!currentOrder.value) {
|
||||
return;
|
||||
}
|
||||
watch(
|
||||
[offerSignature, publishedSignature],
|
||||
([nextSignature, currentSignature]) => {
|
||||
if (autosaveTimer) {
|
||||
clearTimeout(autosaveTimer);
|
||||
autosaveTimer = null;
|
||||
}
|
||||
|
||||
await blockMutation.mutate({
|
||||
input: {
|
||||
orderId: currentOrder.value.id,
|
||||
reason: 'Нужно уточнение параметров заказа.',
|
||||
},
|
||||
});
|
||||
await refetchOrder();
|
||||
}
|
||||
if (!canEditOffer.value || !nextSignature || nextSignature === currentSignature || autosavePending.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
async function startOrder() {
|
||||
if (!currentOrder.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
await startWorkMutation.mutate({ orderId: currentOrder.value.id });
|
||||
await refetchOrder();
|
||||
}
|
||||
|
||||
async function completeOrder() {
|
||||
if (!currentOrder.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
await completeWorkMutation.mutate({ orderId: currentOrder.value.id });
|
||||
await refetchOrder();
|
||||
}
|
||||
autosaveTimer = setTimeout(() => {
|
||||
void saveOffer();
|
||||
}, 700);
|
||||
},
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="space-y-6">
|
||||
<NuxtLink to="/client-orders" class="text-sm font-semibold text-[#0d854a]">← Назад к заказам клиентов</NuxtLink>
|
||||
|
||||
<div v-if="ordersQuery.loading.value" class="manager-empty-state">
|
||||
<div v-if="orderQuery.loading.value" class="manager-empty-state">
|
||||
Загружаем заказ...
|
||||
</div>
|
||||
|
||||
@@ -130,66 +343,121 @@ async function completeOrder() {
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
<div class="manager-hero">
|
||||
<p class="manager-eyebrow">Заказ</p>
|
||||
<h1 class="manager-title">{{ currentOrder.code }}</h1>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-4 lg:grid-cols-[1.1fr_0.9fr]">
|
||||
<div class="space-y-4">
|
||||
<div class="surface-card rounded-3xl p-5">
|
||||
<h2 class="text-xl font-bold text-[#123824]">Статус заказа</h2>
|
||||
<div class="mt-4 flex flex-wrap items-center justify-between gap-3">
|
||||
<div class="space-y-1 text-sm text-[#355947]">
|
||||
<p>Создан: {{ new Date(currentOrder.createdAt).toLocaleString() }}</p>
|
||||
<p>Клиент: {{ currentOrder.customerId }}</p>
|
||||
</div>
|
||||
<OrderStatusBadge :status="currentOrder.status" />
|
||||
<UiBackHeader
|
||||
to="/admin/orders"
|
||||
back-label="Назад к заказам клиентов"
|
||||
:title="`Заказ ${currentOrderCode}`"
|
||||
>
|
||||
<template #actions>
|
||||
<NuxtLink
|
||||
:to="`/admin/orders/clients/${currentOrder.customerId}`"
|
||||
class="surface-card surface-card-interactive flex min-w-[220px] items-center gap-3 rounded-[24px] px-4 py-3"
|
||||
>
|
||||
<img
|
||||
v-if="currentCustomer && messengerConnectionAvatarSrc(currentCustomer.telegramConnection)"
|
||||
:src="messengerConnectionAvatarSrc(currentCustomer.telegramConnection)"
|
||||
:alt="currentCustomer.fullName"
|
||||
class="h-12 w-12 rounded-[16px] object-cover"
|
||||
>
|
||||
<div
|
||||
v-else
|
||||
class="flex h-12 w-12 shrink-0 items-center justify-center rounded-[16px] bg-[linear-gradient(135deg,#dff7e9_0%,#c2ead3_100%)] text-sm font-black text-[#123824]"
|
||||
>
|
||||
{{ userInitials(currentCustomer?.fullName || 'Fregat') }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="surface-card rounded-3xl p-5">
|
||||
<h2 class="text-xl font-bold text-[#123824]">Состав заказа</h2>
|
||||
<ul class="mt-4 space-y-3">
|
||||
<li v-for="item in currentOrder.items" :key="item.id" class="manager-mini-card text-sm text-[#123824]">
|
||||
{{ item.productName }} × {{ item.quantity }}
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="surface-card rounded-3xl p-5">
|
||||
<h2 class="text-xl font-bold text-[#123824]">Доставка</h2>
|
||||
<div class="mt-4 grid gap-3 md:grid-cols-2">
|
||||
<div class="manager-mini-card text-sm text-[#123824]">
|
||||
Адрес: {{ currentOrder.deliveryAddress || 'не выбран' }}
|
||||
</div>
|
||||
<div class="manager-mini-card text-sm text-[#123824]">
|
||||
Условия: {{ currentOrder.deliveryTerms || 'еще не указаны' }}
|
||||
</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>
|
||||
</div>
|
||||
</NuxtLink>
|
||||
</template>
|
||||
</UiBackHeader>
|
||||
|
||||
<div class="space-y-4">
|
||||
<div class="surface-card rounded-3xl p-5">
|
||||
<p class="text-[11px] font-semibold uppercase tracking-[0.18em] text-[#6a8a76]">Статус заказа</p>
|
||||
|
||||
<label v-if="editingStatus" class="mt-3 block">
|
||||
<select
|
||||
v-model="statusDraft"
|
||||
data-manager-status-select
|
||||
class="w-full min-w-[220px] rounded-2xl bg-[#f3f5f4] px-4 py-3 text-sm font-semibold text-[#123824] outline-none transition focus:shadow-[0_0_0_3px_rgba(19,153,87,0.12)]"
|
||||
:disabled="statusMutationPending"
|
||||
@change="void applyStatusChange()"
|
||||
@blur="closeStatusEditor"
|
||||
>
|
||||
<option
|
||||
v-for="option in statusOptions"
|
||||
:key="option.value"
|
||||
:value="option.value"
|
||||
>
|
||||
{{ option.label }}
|
||||
</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<button
|
||||
v-else
|
||||
type="button"
|
||||
class="mt-3 rounded-2xl px-0 py-0 text-left"
|
||||
@dblclick="void openStatusEditor()"
|
||||
>
|
||||
<OrdersOrderStatusBadge :status="currentOrder.status" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="space-y-4">
|
||||
<div class="surface-card rounded-3xl p-5">
|
||||
<h2 class="text-xl font-bold text-[#123824]">Оффер</h2>
|
||||
<div class="mt-4 space-y-3">
|
||||
<input v-model="offerForm.deliveryTerms" class="input manager-field w-full" placeholder="Условия доставки">
|
||||
<input v-model="offerForm.deliveryFee" type="number" class="input manager-field w-full" placeholder="Стоимость доставки">
|
||||
<input v-model="offerForm.totalPrice" type="number" class="input manager-field w-full" placeholder="Итоговая стоимость">
|
||||
<button class="btn btn-primary border-0" @click="publishOffer">Публиковать оффер</button>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<h2 class="text-xl font-bold text-[#123824]">Состав заказа</h2>
|
||||
<OrdersOrderItemsTable
|
||||
class="mt-4"
|
||||
:items="currentOrder.items"
|
||||
:calculation-payload="currentOrder.calculationPayload"
|
||||
mode="manager-pricing"
|
||||
:unit-price-drafts="itemPriceDrafts"
|
||||
:editable-price-item-ids="activePriceItemIds"
|
||||
:disabled="!canEditOffer"
|
||||
:framed="false"
|
||||
price-placeholder="Рассчитывается"
|
||||
@update:unit-price="({ itemId, value }) => { itemPriceDrafts[itemId] = value; }"
|
||||
@activate:unit-price="void openPriceEditor($event)"
|
||||
@finish:unit-price="closePriceEditor($event)"
|
||||
/>
|
||||
<OrdersOrderDeliveryLine
|
||||
class="mt-3"
|
||||
mode="manager"
|
||||
:can-edit="canEditOffer"
|
||||
:delivery-address="currentOrder.deliveryAddress"
|
||||
:delivery-terms="draftDeliveryTerms"
|
||||
:delivery-fee="draftDeliveryFee"
|
||||
:editing-delivery-terms="editingDeliveryTerms"
|
||||
:editing-delivery-fee="editingDeliveryFee"
|
||||
:delivery-terms-draft="deliveryTermsDraft"
|
||||
:delivery-fee-draft="deliveryFeeDraft"
|
||||
@update:delivery-terms="deliveryTermsDraft = $event"
|
||||
@update:delivery-fee="deliveryFeeDraft = $event"
|
||||
@activate:delivery-terms="void openDeliveryTermsEditor()"
|
||||
@finish:delivery-terms="closeDeliveryTermsEditor()"
|
||||
@activate:delivery-fee="void openDeliveryFeeEditor()"
|
||||
@finish:delivery-fee="closeDeliveryFeeEditor()"
|
||||
/>
|
||||
|
||||
<div class="surface-card rounded-3xl p-5">
|
||||
<h2 class="text-xl font-bold text-[#123824]">Действия</h2>
|
||||
<div class="mt-4 flex flex-wrap gap-2">
|
||||
<button class="btn btn-success btn-sm border-0" @click="approveOrder">Подтвердить</button>
|
||||
<button class="btn btn-error btn-sm border-0" @click="rejectOrder">Отклонить</button>
|
||||
<button class="btn btn-warning btn-sm border-0" @click="blockOrder">Заблокировать</button>
|
||||
<button class="btn btn-accent btn-sm border-0" :disabled="currentOrder.status !== 'CONFIRMED'" @click="startOrder">В работу</button>
|
||||
<button class="btn btn-neutral btn-sm border-0" :disabled="currentOrder.status !== 'IN_PROGRESS'" @click="completeOrder">Завершить</button>
|
||||
</div>
|
||||
<div class="mt-4 flex flex-wrap items-center justify-between gap-3">
|
||||
<p class="text-sm text-[#5c7b69]">
|
||||
{{
|
||||
offerTotal == null
|
||||
? currentOrder.totalPrice == null
|
||||
? 'Итог пока не задан.'
|
||||
: `Текущий итог: ${formatPrice(currentOrder.totalPrice)}`
|
||||
: autosavePending
|
||||
? `Сохраняем: ${formatPrice(offerTotal)}`
|
||||
: `Текущий итог: ${formatPrice(offerTotal)}`
|
||||
}}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -3,33 +3,41 @@ import { useQuery } from '@vue/apollo-composable';
|
||||
import FullCalendar from '@fullcalendar/vue3';
|
||||
import dayGridPlugin from '@fullcalendar/daygrid';
|
||||
import ruLocale from '@fullcalendar/core/locales/ru';
|
||||
import OrderStatusBadge from '~/components/orders/OrderStatusBadge.vue';
|
||||
import {
|
||||
ManagerOrdersDocument,
|
||||
ManagerUsersDocument,
|
||||
type ManagerOrdersQuery,
|
||||
type ManagerUsersQuery,
|
||||
} from '~/composables/graphql/generated';
|
||||
import { messengerConnectionAvatarSrc } from '~/composables/useMessengerConnectionPresentation';
|
||||
import { formatOrderCode } from '~/composables/useOrderCodePresentation';
|
||||
import { formatPrice } from '~/composables/useOrderDetailPresentation';
|
||||
|
||||
definePageMeta({
|
||||
middleware: ['manager-only'],
|
||||
path: '/admin/orders',
|
||||
alias: ['/client-orders'],
|
||||
});
|
||||
|
||||
type ManagerOrderItem = ManagerOrdersQuery['managerOrders'][number];
|
||||
type ManagerUserItem = ManagerUsersQuery['managerUsers'][number];
|
||||
type StatusFilter = 'ALL' | 'NEW' | 'PRICED' | 'IN_PROGRESS' | 'CLOSED';
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
|
||||
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 ordersQuery = useQuery(ManagerOrdersDocument, { status: null });
|
||||
const usersQuery = useQuery(ManagerUsersDocument);
|
||||
const search = ref('');
|
||||
const statusFilter = ref<'ALL' | 'WAITING' | 'ACTIVE' | 'CLOSED'>('ALL');
|
||||
const statusFilter = ref<StatusFilter>('ALL');
|
||||
|
||||
const viewMode = computed<'cards' | 'calendar'>(() => (
|
||||
route.query.view === 'calendar' ? 'calendar' : 'cards'
|
||||
const viewMode = computed<'list' | 'calendar'>(() => (
|
||||
route.query.view === 'calendar'
|
||||
? 'calendar'
|
||||
: 'list'
|
||||
));
|
||||
|
||||
function setViewMode(view: 'cards' | 'calendar') {
|
||||
function setViewMode(view: 'list' | 'calendar') {
|
||||
void router.replace({
|
||||
query: {
|
||||
...route.query,
|
||||
@@ -38,61 +46,227 @@ function setViewMode(view: 'cards' | 'calendar') {
|
||||
});
|
||||
}
|
||||
|
||||
const usersById = computed<Record<string, ManagerUserItem>>(() => Object.fromEntries(
|
||||
(usersQuery.result.value?.managerUsers ?? []).map((user) => [user.id, user]),
|
||||
));
|
||||
|
||||
function customerCardMeta(customerId: string) {
|
||||
const customer = usersById.value[customerId];
|
||||
if (!customer) {
|
||||
return {
|
||||
name: customerId,
|
||||
avatarSrc: '',
|
||||
fallbackAvatarSrc: '/favicon.ico',
|
||||
initials: customerId.slice(0, 2).toUpperCase(),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
name: customer.fullName,
|
||||
avatarSrc: messengerConnectionAvatarSrc(customer.telegramConnection),
|
||||
fallbackAvatarSrc: '/favicon.ico',
|
||||
initials: customer.fullName
|
||||
.split(/\s+/)
|
||||
.filter(Boolean)
|
||||
.slice(0, 2)
|
||||
.map((part) => part.charAt(0).toUpperCase())
|
||||
.join(''),
|
||||
};
|
||||
}
|
||||
|
||||
function getOrderGroup(order: ManagerOrderItem) {
|
||||
if (order.status === 'NEW' || order.status === 'MANAGER_PROCESSING') {
|
||||
return 'NEW';
|
||||
}
|
||||
if (order.status === 'WAITING_DOUBLE_CONFIRM' || order.status === 'CONFIRMED') {
|
||||
return 'PRICED';
|
||||
}
|
||||
if (order.status === 'IN_PROGRESS') {
|
||||
return 'IN_PROGRESS';
|
||||
}
|
||||
return 'CLOSED';
|
||||
}
|
||||
|
||||
function orderGroupLabel(group: StatusFilter) {
|
||||
if (group === 'NEW') {
|
||||
return 'Заявка';
|
||||
}
|
||||
if (group === 'PRICED') {
|
||||
return 'Расчёт';
|
||||
}
|
||||
if (group === 'IN_PROGRESS') {
|
||||
return 'В работе';
|
||||
}
|
||||
if (group === 'CLOSED') {
|
||||
return 'Закрыт';
|
||||
}
|
||||
return 'Все';
|
||||
}
|
||||
|
||||
function orderGroupBadgeClass(group: StatusFilter) {
|
||||
if (group === 'NEW') {
|
||||
return 'manager-calendar-order-card__status manager-calendar-order-card__status--new';
|
||||
}
|
||||
if (group === 'PRICED') {
|
||||
return 'manager-calendar-order-card__status manager-calendar-order-card__status--priced';
|
||||
}
|
||||
if (group === 'IN_PROGRESS') {
|
||||
return 'manager-calendar-order-card__status manager-calendar-order-card__status--progress';
|
||||
}
|
||||
return 'manager-calendar-order-card__status manager-calendar-order-card__status--closed';
|
||||
}
|
||||
|
||||
function matchesFilter(order: ManagerOrderItem) {
|
||||
if (statusFilter.value === 'ALL') {
|
||||
return true;
|
||||
}
|
||||
if (statusFilter.value === 'WAITING') {
|
||||
return order.status === 'WAITING_DOUBLE_CONFIRM';
|
||||
}
|
||||
if (statusFilter.value === 'ACTIVE') {
|
||||
return ACTIVE_STATUSES.has(order.status);
|
||||
}
|
||||
return CLOSED_STATUSES.has(order.status);
|
||||
return getOrderGroup(order) === statusFilter.value;
|
||||
}
|
||||
|
||||
const filteredOrders = computed(() => {
|
||||
const searchedOrders = computed<ManagerOrderItem[]>(() => {
|
||||
const orders = ordersQuery.result.value?.managerOrders ?? [];
|
||||
const query = search.value.trim().toLowerCase();
|
||||
|
||||
return orders.filter((order) => {
|
||||
const text = [
|
||||
order.code,
|
||||
formatOrderCode(order.code),
|
||||
order.customerId,
|
||||
customerCardMeta(order.customerId).name,
|
||||
order.deliveryAddress || '',
|
||||
...order.items.map((item) => item.productName),
|
||||
]
|
||||
.join(' ')
|
||||
.toLowerCase();
|
||||
|
||||
const matchesSearch = !query || text.includes(query);
|
||||
return matchesSearch && matchesFilter(order);
|
||||
return !query || text.includes(query);
|
||||
});
|
||||
});
|
||||
|
||||
const filteredOrders = computed<ManagerOrderItem[]>(() => searchedOrders.value.filter((order) => matchesFilter(order)));
|
||||
|
||||
const {
|
||||
canLoadMore,
|
||||
loadMore,
|
||||
loadMoreSentinel,
|
||||
remainingCount,
|
||||
visibleItems: visibleOrders,
|
||||
} = useIncrementalList(filteredOrders, {
|
||||
pageSize: 24,
|
||||
enabled: computed(() => viewMode.value === 'list'),
|
||||
resetKeys: [search, statusFilter, viewMode],
|
||||
});
|
||||
|
||||
function escapeHtml(value: string) {
|
||||
return String(value)
|
||||
.replaceAll('&', '&')
|
||||
.replaceAll('<', '<')
|
||||
.replaceAll('>', '>')
|
||||
.replaceAll('"', '"')
|
||||
.replaceAll("'", ''');
|
||||
}
|
||||
|
||||
const statusTabs = computed<Array<{ id: StatusFilter; label: string; count: number }>>(() => [
|
||||
{
|
||||
id: 'ALL',
|
||||
label: 'Все',
|
||||
count: searchedOrders.value.length,
|
||||
},
|
||||
{
|
||||
id: 'NEW',
|
||||
label: 'Заявки',
|
||||
count: searchedOrders.value.filter((order) => getOrderGroup(order) === 'NEW').length,
|
||||
},
|
||||
{
|
||||
id: 'PRICED',
|
||||
label: 'Предложения',
|
||||
count: searchedOrders.value.filter((order) => getOrderGroup(order) === 'PRICED').length,
|
||||
},
|
||||
{
|
||||
id: 'IN_PROGRESS',
|
||||
label: 'В работе',
|
||||
count: searchedOrders.value.filter((order) => getOrderGroup(order) === 'IN_PROGRESS').length,
|
||||
},
|
||||
{
|
||||
id: 'CLOSED',
|
||||
label: 'Закрытые',
|
||||
count: searchedOrders.value.filter((order) => getOrderGroup(order) === 'CLOSED').length,
|
||||
},
|
||||
]);
|
||||
|
||||
const calendarOptions = computed(() => ({
|
||||
plugins: [dayGridPlugin],
|
||||
locale: ruLocale,
|
||||
initialView: 'dayGridMonth',
|
||||
initialView: 'dayGridWeek',
|
||||
height: 'auto',
|
||||
fixedWeekCount: false,
|
||||
firstDay: 1,
|
||||
weekends: false,
|
||||
headerToolbar: {
|
||||
left: 'prev,next today',
|
||||
center: 'title',
|
||||
right: '',
|
||||
right: 'dayGridDay,dayGridWeek,dayGridMonth',
|
||||
},
|
||||
buttonText: {
|
||||
today: 'Сегодня',
|
||||
day: 'День',
|
||||
week: 'Неделя',
|
||||
month: 'Месяц',
|
||||
},
|
||||
events: filteredOrders.value.map((order: ManagerOrderItem) => {
|
||||
const customer = customerCardMeta(order.customerId);
|
||||
const group = getOrderGroup(order);
|
||||
|
||||
return {
|
||||
id: order.id,
|
||||
title: formatOrderCode(order.code),
|
||||
start: new Date(order.createdAt).toISOString(),
|
||||
allDay: true,
|
||||
extendedProps: {
|
||||
customerName: customer.name,
|
||||
avatarSrc: customer.avatarSrc,
|
||||
fallbackAvatarSrc: customer.fallbackAvatarSrc,
|
||||
initials: customer.initials,
|
||||
orderCode: formatOrderCode(order.code),
|
||||
orderGroupLabel: orderGroupLabel(group),
|
||||
orderGroupClass: orderGroupBadgeClass(group),
|
||||
totalPriceLabel: formatPrice(order.totalPrice) ?? 'Цена уточняется',
|
||||
},
|
||||
};
|
||||
}),
|
||||
eventContent: ({ event }: { event: { extendedProps: Record<string, string> } }) => {
|
||||
const customerName = escapeHtml(event.extendedProps.customerName || '');
|
||||
const avatarSrc = event.extendedProps.avatarSrc || event.extendedProps.fallbackAvatarSrc;
|
||||
const orderCode = escapeHtml(event.extendedProps.orderCode || '');
|
||||
const orderGroupLabel = escapeHtml(event.extendedProps.orderGroupLabel || '');
|
||||
const orderGroupClass = escapeHtml(event.extendedProps.orderGroupClass || '');
|
||||
const totalPriceLabel = escapeHtml(event.extendedProps.totalPriceLabel || '');
|
||||
const initials = escapeHtml(event.extendedProps.initials || '');
|
||||
|
||||
return {
|
||||
html: `
|
||||
<div class="manager-calendar-order-card">
|
||||
<div class="manager-calendar-order-card__header">
|
||||
<div class="manager-calendar-order-card__avatar-shell">
|
||||
${avatarSrc
|
||||
? `<img src="${escapeHtml(avatarSrc)}" alt="${customerName}" class="manager-calendar-order-card__avatar">`
|
||||
: `<span class="manager-calendar-order-card__initials">${initials}</span>`}
|
||||
</div>
|
||||
<div class="manager-calendar-order-card__text">
|
||||
<div class="manager-calendar-order-card__name">${customerName}</div>
|
||||
<div class="manager-calendar-order-card__code">${orderCode}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="manager-calendar-order-card__meta">
|
||||
<span class="${orderGroupClass}">${orderGroupLabel}</span>
|
||||
<div class="manager-calendar-order-card__price">${totalPriceLabel}</div>
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
};
|
||||
},
|
||||
events: filteredOrders.value.map((order) => ({
|
||||
id: order.id,
|
||||
title: `${order.code} • ${order.customerId}`,
|
||||
start: new Date(order.createdAt).toISOString(),
|
||||
allDay: true,
|
||||
})),
|
||||
eventClick: ({ event }: { event: { id: string } }) => {
|
||||
void router.push(`/client-orders/${event.id}`);
|
||||
void router.push(`/admin/orders/${event.id}`);
|
||||
},
|
||||
}));
|
||||
</script>
|
||||
@@ -106,23 +280,44 @@ const calendarOptions = computed(() => ({
|
||||
>
|
||||
<template #controls>
|
||||
<div class="flex w-full flex-col gap-3 md:w-auto md:flex-row">
|
||||
<select v-model="statusFilter" class="select select-bordered w-full rounded-full bg-white md:w-64">
|
||||
<option value="ALL">Все заказы</option>
|
||||
<option value="WAITING">Ожидают подтверждения</option>
|
||||
<option value="ACTIVE">Активные</option>
|
||||
<option value="CLOSED">Закрытые</option>
|
||||
</select>
|
||||
|
||||
<div class="tabs tabs-boxed w-fit bg-white">
|
||||
<button class="tab" :class="{ 'tab-active': viewMode === 'cards' }" @click="setViewMode('cards')">
|
||||
Карточки
|
||||
<div class="inline-flex w-fit rounded-full border border-[#d7e9de] bg-white p-1">
|
||||
<button
|
||||
class="rounded-full px-4 py-2 text-sm font-semibold text-[#355947] transition"
|
||||
:class="viewMode === 'list' ? 'bg-[#123824] text-white' : 'hover:bg-[#f4faf6]'"
|
||||
@click="setViewMode('list')"
|
||||
>
|
||||
Список
|
||||
</button>
|
||||
<button class="tab" :class="{ 'tab-active': viewMode === 'calendar' }" @click="setViewMode('calendar')">
|
||||
<button
|
||||
class="rounded-full px-4 py-2 text-sm font-semibold text-[#355947] transition"
|
||||
:class="viewMode === 'calendar' ? 'bg-[#123824] text-white' : 'hover:bg-[#f4faf6]'"
|
||||
@click="setViewMode('calendar')"
|
||||
>
|
||||
Календарь
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<button
|
||||
v-for="tab in statusTabs"
|
||||
:key="tab.id"
|
||||
class="inline-flex items-center gap-2 rounded-full border px-4 py-2 text-sm font-semibold transition"
|
||||
:class="statusFilter === tab.id
|
||||
? 'border-[#0d854a] bg-[#0d854a] text-white shadow-[0_18px_38px_rgba(13,133,74,0.18)]'
|
||||
: 'border-[#d4e8da] bg-white text-[#355947] hover:border-[#b6d7c1]'"
|
||||
@click="statusFilter = tab.id"
|
||||
>
|
||||
<span>{{ tab.label }}</span>
|
||||
<span
|
||||
class="rounded-full px-2 py-0.5 text-xs"
|
||||
:class="statusFilter === tab.id ? 'bg-white/20 text-white' : 'bg-[#eef7f1] text-[#0d854a]'"
|
||||
>
|
||||
{{ tab.count }}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</UiSectionSearchHero>
|
||||
|
||||
<div v-if="ordersQuery.loading.value" class="manager-empty-state">
|
||||
@@ -132,37 +327,210 @@ const calendarOptions = computed(() => ({
|
||||
Заказы по текущим условиям не найдены.
|
||||
</div>
|
||||
|
||||
<div v-else-if="viewMode === 'calendar'" class="surface-card rounded-3xl p-4 md:p-5">
|
||||
<div v-else-if="viewMode === 'calendar'" class="manager-calendar-shell">
|
||||
<FullCalendar :options="calendarOptions" />
|
||||
</div>
|
||||
|
||||
<div v-else class="space-y-4">
|
||||
<NuxtLink
|
||||
v-for="order in filteredOrders"
|
||||
<OrdersOrderSummaryCard
|
||||
v-for="order in visibleOrders"
|
||||
:key="order.id"
|
||||
:to="`/client-orders/${order.id}`"
|
||||
class="surface-card block rounded-3xl p-5"
|
||||
>
|
||||
<div class="flex flex-wrap items-start justify-between gap-3">
|
||||
<div class="space-y-1">
|
||||
<h2 class="text-lg font-bold text-[#123824]">{{ order.code }}</h2>
|
||||
<p class="text-sm text-[#5c7b69]">Клиент: {{ order.customerId }}</p>
|
||||
<p class="text-sm text-[#5c7b69]">Создан: {{ new Date(order.createdAt).toLocaleString() }}</p>
|
||||
<p v-if="order.deliveryAddress" class="text-sm text-[#5c7b69]">Адрес: {{ order.deliveryAddress }}</p>
|
||||
</div>
|
||||
<OrderStatusBadge :status="order.status" />
|
||||
</div>
|
||||
:to="`/admin/orders/${order.id}`"
|
||||
:code="order.code"
|
||||
:status="order.status"
|
||||
:created-at="order.createdAt"
|
||||
:total-price="order.totalPrice"
|
||||
:items="order.items"
|
||||
/>
|
||||
|
||||
<ul class="mt-4 grid gap-2 text-sm text-[#214735]">
|
||||
<li
|
||||
v-for="item in order.items"
|
||||
:key="item.id"
|
||||
class="rounded-2xl border border-[#d6ebde] bg-white px-4 py-3"
|
||||
>
|
||||
{{ item.productName }} × {{ item.quantity }}
|
||||
</li>
|
||||
</ul>
|
||||
</NuxtLink>
|
||||
<div
|
||||
v-if="canLoadMore"
|
||||
ref="loadMoreSentinel"
|
||||
class="flex justify-center pt-2"
|
||||
>
|
||||
<button class="btn btn-outline border-[#d7e9de] bg-white" @click="loadMore">
|
||||
Показать ещё {{ Math.min(remainingCount, 24) }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
.manager-calendar-order-card {
|
||||
display: grid;
|
||||
gap: 0.55rem;
|
||||
min-height: 84px;
|
||||
border-radius: 18px;
|
||||
background: #ffffff;
|
||||
padding: 0.85rem 0.9rem;
|
||||
box-shadow: 0 12px 26px rgba(18, 56, 36, 0.08);
|
||||
}
|
||||
|
||||
.manager-calendar-order-card__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.7rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.manager-calendar-order-card__avatar-shell {
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
flex: 0 0 auto;
|
||||
overflow: hidden;
|
||||
border-radius: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: #edf3ef;
|
||||
}
|
||||
|
||||
.manager-calendar-order-card__avatar {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.manager-calendar-order-card__initials {
|
||||
font-size: 0.78rem;
|
||||
font-weight: 800;
|
||||
color: #123824;
|
||||
}
|
||||
|
||||
.manager-calendar-order-card__text {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.manager-calendar-order-card__name {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-size: 0.8rem;
|
||||
line-height: 1.15;
|
||||
font-weight: 800;
|
||||
color: #123824;
|
||||
}
|
||||
|
||||
.manager-calendar-order-card__code {
|
||||
margin-top: 0.18rem;
|
||||
font-size: 0.74rem;
|
||||
line-height: 1.1;
|
||||
font-weight: 700;
|
||||
color: #688676;
|
||||
}
|
||||
|
||||
.manager-calendar-order-card__meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.65rem;
|
||||
}
|
||||
|
||||
.manager-calendar-order-card__status {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 999px;
|
||||
padding: 0.28rem 0.58rem;
|
||||
font-size: 0.64rem;
|
||||
line-height: 1;
|
||||
font-weight: 800;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.manager-calendar-order-card__status--new {
|
||||
background: #edf3ef;
|
||||
color: #355947;
|
||||
}
|
||||
|
||||
.manager-calendar-order-card__status--priced {
|
||||
background: #eef7f1;
|
||||
color: #0d854a;
|
||||
}
|
||||
|
||||
.manager-calendar-order-card__status--progress {
|
||||
background: #edf2f6;
|
||||
color: #2f5872;
|
||||
}
|
||||
|
||||
.manager-calendar-order-card__status--closed {
|
||||
background: #f1f3f2;
|
||||
color: #617268;
|
||||
}
|
||||
|
||||
.manager-calendar-order-card__price {
|
||||
font-size: 0.76rem;
|
||||
font-weight: 700;
|
||||
color: #0d854a;
|
||||
text-align: right;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.manager-calendar-shell :deep(.fc) {
|
||||
--fc-border-color: #dbe5df;
|
||||
--fc-button-bg-color: #123824;
|
||||
--fc-button-border-color: #123824;
|
||||
--fc-button-hover-bg-color: #0d854a;
|
||||
--fc-button-hover-border-color: #0d854a;
|
||||
--fc-button-active-bg-color: #0d854a;
|
||||
--fc-button-active-border-color: #0d854a;
|
||||
--fc-neutral-bg-color: transparent;
|
||||
--fc-page-bg-color: transparent;
|
||||
}
|
||||
|
||||
.manager-calendar-shell :deep(.fc-theme-standard td),
|
||||
.manager-calendar-shell :deep(.fc-theme-standard th),
|
||||
.manager-calendar-shell :deep(.fc-theme-standard .fc-scrollgrid) {
|
||||
border-color: #dbe5df;
|
||||
}
|
||||
|
||||
.manager-calendar-shell :deep(.fc-theme-standard .fc-scrollgrid) {
|
||||
border-radius: 22px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.manager-calendar-shell :deep(.fc-col-header-cell-cushion) {
|
||||
padding: 0.85rem 0.35rem;
|
||||
font-size: 0.74rem;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: #688676;
|
||||
}
|
||||
|
||||
.manager-calendar-shell :deep(.fc-daygrid-day-frame) {
|
||||
background: transparent;
|
||||
padding: 0.5rem;
|
||||
}
|
||||
|
||||
.manager-calendar-shell :deep(.fc-daygrid-day-top) {
|
||||
justify-content: flex-end;
|
||||
padding: 0.25rem 0.35rem 0.3rem;
|
||||
}
|
||||
|
||||
.manager-calendar-shell :deep(.fc-daygrid-day-events) {
|
||||
margin: 0;
|
||||
padding: 0 0.3rem 0.45rem;
|
||||
}
|
||||
|
||||
.manager-calendar-shell :deep(.fc-daygrid-event-harness) {
|
||||
margin-top: 0.45rem;
|
||||
}
|
||||
|
||||
.manager-calendar-shell :deep(.fc-daygrid-day-number) {
|
||||
font-size: 0.78rem;
|
||||
font-weight: 700;
|
||||
color: #5a7968;
|
||||
}
|
||||
|
||||
.fc .fc-daygrid-event {
|
||||
border: 0;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.fc .fc-daygrid-dot-event:hover,
|
||||
.fc .fc-daygrid-event:hover {
|
||||
background: transparent;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,130 +1,251 @@
|
||||
<script setup lang="ts">
|
||||
import { useMutation, useQuery } from '@vue/apollo-composable';
|
||||
import {
|
||||
ManagerOrdersDocument,
|
||||
ManagerUsersDetailDocument,
|
||||
RegistrationRequestsDocument,
|
||||
ReviewRegistrationRequestDocument,
|
||||
type ManagerOrdersQuery,
|
||||
type ManagerUsersDetailQuery,
|
||||
type RegistrationRequestsQuery,
|
||||
} from '~/composables/graphql/generated';
|
||||
import { messengerConnectionAvatarSrc } from '~/composables/useMessengerConnectionPresentation';
|
||||
|
||||
definePageMeta({
|
||||
middleware: ['manager-only'],
|
||||
path: '/admin/orders/:mode(clients|requests)/:id',
|
||||
alias: ['/clients/:id'],
|
||||
});
|
||||
|
||||
type ManagerUserItem = ManagerUsersDetailQuery['managerUsers'][number];
|
||||
type ManagerOrderItem = ManagerOrdersQuery['managerOrders'][number];
|
||||
type RequestItem = RegistrationRequestsQuery['registrationRequests'][number];
|
||||
|
||||
const route = useRoute();
|
||||
const requestId = computed(() => String(route.params.id || ''));
|
||||
const backTarget = computed(() => (
|
||||
route.query.tab === 'requests' ? '/clients?tab=requests' : '/clients'
|
||||
));
|
||||
const entityId = computed(() => String(route.params.id || ''));
|
||||
const entityMode = computed(() => String(route.params.mode || 'clients'));
|
||||
const isRequestMode = computed(() => entityMode.value === 'requests');
|
||||
const backTarget = computed(() => '/admin/orders/clients');
|
||||
|
||||
const clientQuery = useQuery(RegistrationRequestsDocument, {
|
||||
const usersQuery = useQuery(ManagerUsersDetailDocument);
|
||||
const requestsQuery = useQuery(RegistrationRequestsDocument, {
|
||||
status: null,
|
||||
});
|
||||
const userOrdersQuery = useQuery(ManagerOrdersDocument, () => ({
|
||||
status: null,
|
||||
customerId: isRequestMode.value ? null : entityId.value,
|
||||
}));
|
||||
const reviewMutation = useMutation(ReviewRegistrationRequestDocument);
|
||||
|
||||
const currentClient = computed(() =>
|
||||
(clientQuery.result.value?.registrationRequests ?? []).find((item) => item.id === requestId.value),
|
||||
const currentUser = computed<ManagerUserItem | null>(() =>
|
||||
(usersQuery.result.value?.managerUsers ?? []).find((item: ManagerUserItem) => item.id === entityId.value) ?? null,
|
||||
);
|
||||
|
||||
const currentRequest = computed(() =>
|
||||
(requestsQuery.result.value?.registrationRequests ?? []).find((item: RequestItem) => item.id === entityId.value),
|
||||
);
|
||||
|
||||
const currentUserOrders = computed<ManagerOrderItem[]>(() => userOrdersQuery.result.value?.managerOrders ?? []);
|
||||
|
||||
const {
|
||||
canLoadMore: canLoadMoreUserOrders,
|
||||
loadMore: loadMoreUserOrders,
|
||||
loadMoreSentinel: loadMoreUserOrdersSentinel,
|
||||
remainingCount: remainingUserOrdersCount,
|
||||
visibleItems: visibleUserOrders,
|
||||
} = useIncrementalList(currentUserOrders, {
|
||||
pageSize: 24,
|
||||
enabled: computed(() => !isRequestMode.value),
|
||||
resetKeys: [entityId, isRequestMode],
|
||||
});
|
||||
|
||||
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('');
|
||||
}
|
||||
|
||||
async function approveRequest() {
|
||||
if (!currentClient.value) {
|
||||
if (!currentRequest.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
await reviewMutation.mutate({
|
||||
input: {
|
||||
requestId: currentClient.value.id,
|
||||
requestId: currentRequest.value.id,
|
||||
decision: 'APPROVE',
|
||||
},
|
||||
});
|
||||
|
||||
await clientQuery.refetch({ status: null });
|
||||
await requestsQuery.refetch({ status: null });
|
||||
}
|
||||
|
||||
async function rejectRequest() {
|
||||
if (!currentClient.value) {
|
||||
if (!currentRequest.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
await reviewMutation.mutate({
|
||||
input: {
|
||||
requestId: currentClient.value.id,
|
||||
requestId: currentRequest.value.id,
|
||||
decision: 'REJECT',
|
||||
rejectionReason: 'Не хватает данных для регистрации.',
|
||||
},
|
||||
});
|
||||
|
||||
await clientQuery.refetch({ status: null });
|
||||
await requestsQuery.refetch({ status: null });
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="space-y-6">
|
||||
<NuxtLink :to="backTarget" class="text-sm font-semibold text-[#0d854a]">← Назад к пользователям</NuxtLink>
|
||||
<template v-if="isRequestMode">
|
||||
<div v-if="requestsQuery.loading.value" class="manager-empty-state">
|
||||
Загружаем карточку клиента...
|
||||
</div>
|
||||
|
||||
<div v-if="clientQuery.loading.value" class="manager-empty-state">
|
||||
Загружаем карточку клиента...
|
||||
</div>
|
||||
<div v-else-if="!currentRequest" class="manager-empty-state">
|
||||
Карточка клиента не найдена.
|
||||
</div>
|
||||
|
||||
<div v-else-if="!currentClient" class="manager-empty-state">
|
||||
Карточка клиента не найдена.
|
||||
</div>
|
||||
<template v-else>
|
||||
<UiBackHeader
|
||||
:to="backTarget"
|
||||
back-label="Назад к пользователям"
|
||||
:title="`Заявка ${currentRequest.companyName}`"
|
||||
:subtitle="`Контакт: ${currentRequest.contactName} · ${currentRequest.email}`"
|
||||
>
|
||||
<template #actions>
|
||||
<div v-if="currentRequest.status === 'PENDING'" class="flex flex-wrap gap-2">
|
||||
<button class="btn btn-success border-0" @click="approveRequest">Одобрить</button>
|
||||
<button class="btn btn-error border-0" @click="rejectRequest">Отклонить</button>
|
||||
</div>
|
||||
</template>
|
||||
</UiBackHeader>
|
||||
|
||||
<div class="grid gap-4 lg:grid-cols-3">
|
||||
<div class="manager-stat-card">
|
||||
<p class="manager-stat-label">Статус</p>
|
||||
<p class="manager-stat-value text-lg">
|
||||
{{ currentRequest.status === 'APPROVED' ? 'Активен' : currentRequest.status === 'REJECTED' ? 'Отклонен' : 'На проверке' }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="manager-stat-card">
|
||||
<p class="manager-stat-label">Дата заявки</p>
|
||||
<p class="manager-stat-value text-lg">{{ new Date(currentRequest.createdAt).toLocaleDateString() }}</p>
|
||||
</div>
|
||||
<div class="manager-stat-card">
|
||||
<p class="manager-stat-label">ИНН</p>
|
||||
<p class="manager-stat-value text-lg">{{ currentRequest.inn || 'Не указан' }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<div class="flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
|
||||
<div class="manager-hero">
|
||||
<p class="manager-eyebrow">Клиент</p>
|
||||
<h1 class="manager-title">{{ currentClient.companyName }}</h1>
|
||||
<p class="manager-copy">Контакт: {{ currentClient.contactName }} · {{ currentClient.email }}</p>
|
||||
</div>
|
||||
|
||||
<div v-if="currentClient.status === 'PENDING'" class="flex flex-wrap gap-2">
|
||||
<button class="btn btn-success border-0" @click="approveRequest">Одобрить</button>
|
||||
<button class="btn btn-error border-0" @click="rejectRequest">Отклонить</button>
|
||||
</div>
|
||||
<div v-if="usersQuery.loading.value || userOrdersQuery.loading.value" class="manager-empty-state">
|
||||
Загружаем пользователя...
|
||||
</div>
|
||||
|
||||
<div class="grid gap-4 lg:grid-cols-3">
|
||||
<div class="manager-stat-card">
|
||||
<p class="manager-stat-label">Статус</p>
|
||||
<p class="manager-stat-value text-lg">
|
||||
{{ currentClient.status === 'APPROVED' ? 'Активен' : currentClient.status === 'REJECTED' ? 'Отклонен' : 'На проверке' }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="manager-stat-card">
|
||||
<p class="manager-stat-label">Дата заявки</p>
|
||||
<p class="manager-stat-value text-lg">{{ new Date(currentClient.createdAt).toLocaleDateString() }}</p>
|
||||
</div>
|
||||
<div class="manager-stat-card">
|
||||
<p class="manager-stat-label">ИНН</p>
|
||||
<p class="manager-stat-value text-lg">{{ currentClient.inn || 'Не указан' }}</p>
|
||||
</div>
|
||||
<div v-else-if="!currentUser" class="manager-empty-state">
|
||||
Пользователь не найден.
|
||||
</div>
|
||||
|
||||
<div class="surface-card rounded-3xl p-5">
|
||||
<h2 class="text-xl font-bold text-[#123824]">Информация</h2>
|
||||
<div class="mt-4 grid gap-3 md:grid-cols-2">
|
||||
<div class="manager-mini-card">
|
||||
<p class="text-xs font-semibold uppercase tracking-[0.12em] text-[#5c7b69]">Компания</p>
|
||||
<p class="mt-2 text-sm text-[#123824]">{{ currentClient.companyName }}</p>
|
||||
</div>
|
||||
<div class="manager-mini-card">
|
||||
<p class="text-xs font-semibold uppercase tracking-[0.12em] text-[#5c7b69]">Контакт</p>
|
||||
<p class="mt-2 text-sm text-[#123824]">{{ currentClient.contactName }}</p>
|
||||
</div>
|
||||
<div class="manager-mini-card">
|
||||
<p class="text-xs font-semibold uppercase tracking-[0.12em] text-[#5c7b69]">Email</p>
|
||||
<p class="mt-2 text-sm text-[#123824]">{{ currentClient.email }}</p>
|
||||
</div>
|
||||
<div class="manager-mini-card">
|
||||
<p class="text-xs font-semibold uppercase tracking-[0.12em] text-[#5c7b69]">Обновлено</p>
|
||||
<p class="mt-2 text-sm text-[#123824]">{{ new Date(currentClient.updatedAt).toLocaleString() }}</p>
|
||||
<template v-else>
|
||||
<UiBackHeader
|
||||
:to="backTarget"
|
||||
back-label="Назад к пользователям"
|
||||
:title="`Клиент ${currentUser.fullName}`"
|
||||
:subtitle="currentUser.companyName || currentUser.email"
|
||||
/>
|
||||
|
||||
<div class="rounded-[36px] bg-[#edf3ee] p-6 md:p-8">
|
||||
<div class="flex flex-col gap-6 md:flex-row md:items-start">
|
||||
<div class="flex shrink-0 justify-center md:block">
|
||||
<img
|
||||
v-if="messengerConnectionAvatarSrc(currentUser.telegramConnection)"
|
||||
:src="messengerConnectionAvatarSrc(currentUser.telegramConnection)"
|
||||
:alt="currentUser.fullName"
|
||||
class="h-28 w-28 rounded-[36px] object-cover shadow-[0_14px_30px_rgba(18,56,36,0.14)]"
|
||||
>
|
||||
<div
|
||||
v-else
|
||||
class="flex h-28 w-28 items-center justify-center rounded-[36px] bg-[linear-gradient(135deg,#dff7e9_0%,#c2ead3_100%)] text-4xl font-black text-[#123824] shadow-[0_14px_30px_rgba(18,56,36,0.14)]"
|
||||
>
|
||||
{{ userInitials(currentUser.fullName) }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="min-w-0 flex-1 space-y-5">
|
||||
<div class="grid gap-3 md:grid-cols-2 xl:grid-cols-4">
|
||||
<div class="rounded-[24px] bg-white/70 px-4 py-3">
|
||||
<p class="text-[11px] font-semibold uppercase tracking-[0.18em] text-[#6a8a76]">Email</p>
|
||||
<p class="mt-1 break-words text-sm font-semibold text-[#123824]">{{ currentUser.email }}</p>
|
||||
</div>
|
||||
<div class="rounded-[24px] bg-white/70 px-4 py-3">
|
||||
<p class="text-[11px] font-semibold uppercase tracking-[0.18em] text-[#6a8a76]">Telegram</p>
|
||||
<p class="mt-1 text-sm font-semibold text-[#123824]">
|
||||
{{ currentUser.telegramConnection?.username ? `@${currentUser.telegramConnection.username}` : 'Не подключен' }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="rounded-[24px] bg-white/70 px-4 py-3">
|
||||
<p class="text-[11px] font-semibold uppercase tracking-[0.18em] text-[#6a8a76]">Компания</p>
|
||||
<p class="mt-1 text-sm font-semibold text-[#123824]">{{ currentUser.companyName || 'Не указана' }}</p>
|
||||
</div>
|
||||
<div class="rounded-[24px] bg-white/70 px-4 py-3">
|
||||
<p class="text-[11px] font-semibold uppercase tracking-[0.18em] text-[#6a8a76]">ИНН</p>
|
||||
<p class="mt-1 text-sm font-semibold text-[#123824]">{{ currentUser.inn || 'Не указан' }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="currentClient.rejectionReason" class="surface-card rounded-3xl p-5">
|
||||
<h2 class="text-xl font-bold text-[#123824]">Причина отказа</h2>
|
||||
<p class="mt-3 text-sm text-[#a34a34]">{{ currentClient.rejectionReason }}</p>
|
||||
</div>
|
||||
<div class="space-y-4">
|
||||
<div class="space-y-1">
|
||||
<h2 class="text-2xl font-black tracking-[-0.03em] text-[#123824]">Заказы пользователя</h2>
|
||||
<p class="text-sm text-[#5c7b69]">
|
||||
Всего заказов: {{ currentUser.orderCount }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="surface-card rounded-3xl p-5">
|
||||
<div v-if="currentUserOrders.length === 0" class="manager-empty-state mt-4">
|
||||
У пользователя пока нет заказов.
|
||||
</div>
|
||||
<div v-else class="mt-4 space-y-3">
|
||||
<OrdersOrderSummaryCard
|
||||
v-for="order in visibleUserOrders"
|
||||
:key="order.id"
|
||||
:to="`/admin/orders/${order.id}`"
|
||||
:code="order.code"
|
||||
:status="order.status"
|
||||
:created-at="order.createdAt"
|
||||
:total-price="order.totalPrice"
|
||||
:items="order.items"
|
||||
/>
|
||||
|
||||
<div
|
||||
v-if="canLoadMoreUserOrders"
|
||||
ref="loadMoreUserOrdersSentinel"
|
||||
class="flex justify-center pt-2"
|
||||
>
|
||||
<button class="btn btn-outline border-[#d7e9de] bg-white" @click="loadMoreUserOrders">
|
||||
Показать ещё {{ Math.min(remainingUserOrdersCount, 24) }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
@@ -1,89 +1,30 @@
|
||||
<script setup lang="ts">
|
||||
import { useQuery } from '@vue/apollo-composable';
|
||||
import {
|
||||
ManagerUsersDocument,
|
||||
RegistrationRequestsDocument,
|
||||
type ManagerUsersQuery,
|
||||
type RegistrationRequestsQuery,
|
||||
} from '~/composables/graphql/generated';
|
||||
import { ManagerUsersDocument } from '~/composables/graphql/generated';
|
||||
import { messengerConnectionAvatarSrc } from '~/composables/useMessengerConnectionPresentation';
|
||||
|
||||
definePageMeta({
|
||||
middleware: ['manager-only'],
|
||||
path: '/admin/orders/clients',
|
||||
alias: ['/clients'],
|
||||
});
|
||||
|
||||
type ManagerUserItem = ManagerUsersQuery['managerUsers'][number];
|
||||
type RequestItem = RegistrationRequestsQuery['registrationRequests'][number];
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const search = ref('');
|
||||
|
||||
const usersQuery = useQuery(ManagerUsersDocument);
|
||||
const requestsQuery = useQuery(RegistrationRequestsDocument, {
|
||||
status: null,
|
||||
});
|
||||
|
||||
const activeTab = computed<'users' | 'requests'>(() => (
|
||||
route.query.tab === 'requests' ? 'requests' : 'users'
|
||||
));
|
||||
|
||||
function setTab(tab: 'users' | 'requests') {
|
||||
void router.replace({
|
||||
query: {
|
||||
...route.query,
|
||||
tab,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const filteredUsers = computed(() => {
|
||||
const items = usersQuery.result.value?.managerUsers ?? [];
|
||||
const query = search.value.trim().toLowerCase();
|
||||
|
||||
return items.filter((item) => {
|
||||
if (!query) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return item.fullName.toLowerCase().includes(query);
|
||||
});
|
||||
});
|
||||
|
||||
function requestStatusLabel(status: RequestItem['status']) {
|
||||
if (status === 'APPROVED') {
|
||||
return 'Одобрена';
|
||||
}
|
||||
if (status === 'REJECTED') {
|
||||
return 'Отклонена';
|
||||
}
|
||||
return 'На проверке';
|
||||
}
|
||||
|
||||
function requestStatusClass(status: RequestItem['status']) {
|
||||
if (status === 'APPROVED') {
|
||||
return 'badge badge-success border-0';
|
||||
}
|
||||
if (status === 'REJECTED') {
|
||||
return 'badge badge-error border-0';
|
||||
}
|
||||
return 'badge badge-warning border-0';
|
||||
}
|
||||
|
||||
const filteredRequests = computed(() => {
|
||||
const items = requestsQuery.result.value?.registrationRequests ?? [];
|
||||
const query = search.value.trim().toLowerCase();
|
||||
|
||||
return items.filter((item) => {
|
||||
if (!query) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return [
|
||||
item.companyName,
|
||||
item.contactName,
|
||||
item.fullName,
|
||||
item.email,
|
||||
item.inn || '',
|
||||
item.companyName || '',
|
||||
]
|
||||
.join(' ')
|
||||
.toLowerCase()
|
||||
@@ -91,6 +32,17 @@ const filteredRequests = computed(() => {
|
||||
});
|
||||
});
|
||||
|
||||
const {
|
||||
canLoadMore: canLoadMoreUsers,
|
||||
loadMore: loadMoreUsers,
|
||||
loadMoreSentinel: loadMoreUsersSentinel,
|
||||
remainingCount: remainingUsersCount,
|
||||
visibleItems: visibleUsers,
|
||||
} = useIncrementalList(filteredUsers, {
|
||||
pageSize: 24,
|
||||
resetKeys: [search],
|
||||
});
|
||||
|
||||
function userInitials(fullName: string) {
|
||||
const parts = fullName
|
||||
.trim()
|
||||
@@ -110,101 +62,43 @@ function userInitials(fullName: string) {
|
||||
<section class="space-y-6">
|
||||
<UiSectionSearchHero
|
||||
v-model="search"
|
||||
title="Пользователи"
|
||||
:search-placeholder="activeTab === 'users' ? 'Имя пользователя' : 'Компания, контакт, email или ИНН'"
|
||||
title="Клиенты"
|
||||
search-placeholder="Имя, компания или email"
|
||||
>
|
||||
<template #tabs>
|
||||
<div class="tabs tabs-boxed w-fit bg-white">
|
||||
<button
|
||||
class="tab"
|
||||
:class="{ 'tab-active': activeTab === 'users' }"
|
||||
@click="setTab('users')"
|
||||
>
|
||||
Пользователи
|
||||
</button>
|
||||
<button
|
||||
class="tab"
|
||||
:class="{ 'tab-active': activeTab === 'requests' }"
|
||||
@click="setTab('requests')"
|
||||
>
|
||||
Заявки
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #controls>
|
||||
<NuxtLink to="/clients/invite" class="btn btn-primary border-0">
|
||||
<NuxtLink to="/admin/orders/clients/invite" class="btn btn-primary border-0">
|
||||
Пригласить
|
||||
</NuxtLink>
|
||||
</template>
|
||||
</UiSectionSearchHero>
|
||||
|
||||
<template v-if="activeTab === 'users'">
|
||||
<div v-if="usersQuery.loading.value" class="manager-empty-state">
|
||||
Загружаем пользователей...
|
||||
</div>
|
||||
<div v-else-if="filteredUsers.length === 0" class="manager-empty-state">
|
||||
Пользователи по текущему запросу не найдены.
|
||||
</div>
|
||||
<div v-else class="grid gap-4 sm:grid-cols-2 xl:grid-cols-4 2xl:grid-cols-6">
|
||||
<article
|
||||
v-for="user in filteredUsers"
|
||||
<div v-if="usersQuery.loading.value" class="manager-empty-state">
|
||||
Загружаем пользователей...
|
||||
</div>
|
||||
<div v-else-if="filteredUsers.length === 0" class="manager-empty-state">
|
||||
Пользователи по текущему запросу не найдены.
|
||||
</div>
|
||||
<div v-else class="space-y-4">
|
||||
<div class="grid gap-4 sm:grid-cols-2 xl:grid-cols-4 2xl:grid-cols-6">
|
||||
<UsersGridCard
|
||||
v-for="user in visibleUsers"
|
||||
:key="user.id"
|
||||
class="surface-card flex min-h-[280px] flex-col rounded-[32px] p-6"
|
||||
>
|
||||
<div class="flex justify-center">
|
||||
<img
|
||||
v-if="messengerConnectionAvatarSrc(user.telegramConnection)"
|
||||
:src="messengerConnectionAvatarSrc(user.telegramConnection)"
|
||||
:alt="user.fullName"
|
||||
class="h-24 w-24 rounded-[32px] object-cover shadow-[0_12px_30px_rgba(18,56,36,0.14)]"
|
||||
>
|
||||
<div
|
||||
v-else
|
||||
class="flex h-24 w-24 items-center justify-center rounded-[32px] bg-[linear-gradient(135deg,#dff7e9_0%,#c2ead3_100%)] text-3xl font-black text-[#123824] shadow-[inset_0_1px_0_rgba(255,255,255,0.65)]"
|
||||
>
|
||||
{{ userInitials(user.fullName) }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex-1" />
|
||||
|
||||
<div class="pt-8 text-center">
|
||||
<h2 class="text-lg font-bold leading-tight text-[#123824]">{{ user.fullName }}</h2>
|
||||
</div>
|
||||
</article>
|
||||
:to="`/admin/orders/clients/${user.id}`"
|
||||
:full-name="user.fullName"
|
||||
:avatar-src="messengerConnectionAvatarSrc(user.telegramConnection)"
|
||||
:initials="userInitials(user.fullName)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<div v-if="requestsQuery.loading.value" class="manager-empty-state">
|
||||
Загружаем заявки...
|
||||
<div
|
||||
v-if="canLoadMoreUsers"
|
||||
ref="loadMoreUsersSentinel"
|
||||
class="flex justify-center"
|
||||
>
|
||||
<button class="btn btn-outline border-[#d7e9de] bg-white" @click="loadMoreUsers">
|
||||
Показать ещё {{ Math.min(remainingUsersCount, 24) }}
|
||||
</button>
|
||||
</div>
|
||||
<div v-else-if="filteredRequests.length === 0" class="manager-empty-state">
|
||||
Заявки по текущему запросу не найдены.
|
||||
</div>
|
||||
<div v-else class="grid gap-4 lg:grid-cols-2 xl:grid-cols-3">
|
||||
<NuxtLink
|
||||
v-for="request in filteredRequests"
|
||||
:key="request.id"
|
||||
:to="`/clients/${request.id}?tab=requests`"
|
||||
class="surface-card rounded-3xl p-5"
|
||||
>
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<div class="space-y-1">
|
||||
<h2 class="text-lg font-bold text-[#123824]">{{ request.companyName }}</h2>
|
||||
<p class="text-sm text-[#466653]">{{ request.contactName }}</p>
|
||||
</div>
|
||||
<span :class="requestStatusClass(request.status)">{{ requestStatusLabel(request.status) }}</span>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 space-y-2 text-sm text-[#355947]">
|
||||
<p>{{ request.email }}</p>
|
||||
<p v-if="request.inn">ИНН: {{ request.inn }}</p>
|
||||
<p>{{ new Date(request.createdAt).toLocaleDateString() }}</p>
|
||||
</div>
|
||||
</NuxtLink>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
@@ -4,6 +4,8 @@ import { CreateInvitationDocument } from '~/composables/graphql/generated';
|
||||
|
||||
definePageMeta({
|
||||
middleware: ['manager-only'],
|
||||
path: '/admin/orders/clients/invite',
|
||||
alias: ['/clients/invite'],
|
||||
});
|
||||
|
||||
const email = ref('');
|
||||
@@ -37,13 +39,12 @@ async function createInvitation() {
|
||||
|
||||
<template>
|
||||
<section class="space-y-6 max-w-3xl">
|
||||
<NuxtLink to="/clients" class="text-sm font-semibold text-[#0d854a]">← Назад к пользователям</NuxtLink>
|
||||
|
||||
<div class="manager-hero">
|
||||
<p class="manager-eyebrow">Приглашение</p>
|
||||
<h1 class="manager-title">Пригласить нового клиента</h1>
|
||||
<p class="manager-copy">Форма вынесена отдельно, чтобы список клиентов оставался чистым и спокойным.</p>
|
||||
</div>
|
||||
<UiBackHeader
|
||||
to="/admin/orders/clients"
|
||||
back-label="Назад к пользователям"
|
||||
title="Пригласить нового клиента"
|
||||
subtitle="Форма вынесена отдельно, чтобы список клиентов оставался чистым и спокойным."
|
||||
/>
|
||||
|
||||
<div class="surface-card rounded-3xl p-5">
|
||||
<div class="grid gap-3">
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import CatalogConfigurator from '~/components/catalog/CatalogConfigurator.vue';
|
||||
import CatalogProductTypeList from '~/components/catalog/CatalogProductTypeList.vue';
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<CatalogConfigurator />
|
||||
<CatalogProductTypeList />
|
||||
</template>
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
RequestLoginCodeDocument,
|
||||
VerifyLoginCodeDocument,
|
||||
} from '~/composables/graphql/generated';
|
||||
import { useMessengerMiniApp } from '~/composables/useMessengerMiniApp';
|
||||
import { useMessengerStart } from '~/composables/useMessengerStart';
|
||||
|
||||
const config = useRuntimeConfig();
|
||||
@@ -23,26 +24,45 @@ const expiresAt = ref('');
|
||||
const code = ref('');
|
||||
const feedback = ref('');
|
||||
const feedbackTone = ref<'success' | 'error'>('success');
|
||||
const autoRequestTimer = ref<ReturnType<typeof setTimeout> | null>(null);
|
||||
const lastRequestedEmail = ref('');
|
||||
|
||||
const requestCodeMutation = useMutation(RequestLoginCodeDocument, { throws: 'never' });
|
||||
const verifyCodeMutation = useMutation(VerifyLoginCodeDocument, { throws: 'never' });
|
||||
const consumeLoginTokenMutation = useMutation(ConsumeLoginTokenDocument, { throws: 'never' });
|
||||
const { openMessengerBot, pendingChannel } = useMessengerStart();
|
||||
const {
|
||||
channel: messengerMiniAppChannel,
|
||||
channelLabel: messengerMiniAppChannelLabel,
|
||||
displayName: messengerMiniAppDisplayName,
|
||||
initData: messengerMiniAppInitData,
|
||||
isAvailable: isMessengerMiniApp,
|
||||
} = useMessengerMiniApp();
|
||||
|
||||
const telegramBotUrl = computed(() => config.public.telegramBotUrl || '');
|
||||
const maxBotUrl = computed(() => config.public.maxBotUrl || '');
|
||||
|
||||
const normalizedEmail = computed(() => email.value.trim().toLowerCase());
|
||||
const isEmailReady = computed(() => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(normalizedEmail.value));
|
||||
const canUseTelegramLogin = computed(() => messengerMiniAppChannel.value !== 'TELEGRAM' && Boolean(telegramBotUrl.value));
|
||||
const canUseMaxLogin = computed(() => messengerMiniAppChannel.value !== 'MAX' && Boolean(maxBotUrl.value));
|
||||
const hasMessengerButtons = computed(() => canUseTelegramLogin.value || canUseMaxLogin.value);
|
||||
const nextPath = computed(() =>
|
||||
typeof route.query.next === 'string' && route.query.next.startsWith('/')
|
||||
? route.query.next
|
||||
: '',
|
||||
);
|
||||
const telegramMiniAppMode = ref<'idle' | 'checking' | 'authenticated' | 'needs_email'>('idle');
|
||||
|
||||
async function finalizeSession(accessToken: string) {
|
||||
authCookie.value = accessToken;
|
||||
}
|
||||
|
||||
async function navigateAfterLogin(user: { company?: { id: string } | null }) {
|
||||
if (!user.company?.id) {
|
||||
async function navigateAfterLogin(user: { company?: { id: string } | null; companyId?: string | null }) {
|
||||
if (nextPath.value) {
|
||||
await navigateTo(nextPath.value);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!user.company?.id && !user.companyId) {
|
||||
await navigateTo('/profile');
|
||||
return;
|
||||
}
|
||||
@@ -50,19 +70,16 @@ async function navigateAfterLogin(user: { company?: { id: string } | null }) {
|
||||
await navigateTo('/');
|
||||
}
|
||||
|
||||
function clearAutoRequestTimer() {
|
||||
if (!autoRequestTimer.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
clearTimeout(autoRequestTimer.value);
|
||||
autoRequestTimer.value = null;
|
||||
}
|
||||
|
||||
function normalizeApolloErrorMessage(message: string) {
|
||||
if (message.includes('User for this destination was not found.')) {
|
||||
return 'Пользователь с таким e-mail не найден. Вход доступен только для созданных аккаунтов.';
|
||||
}
|
||||
if (message.includes('Telegram initData')) {
|
||||
return 'Не получилось проверить Telegram Mini App. Откройте кабинет из Telegram заново.';
|
||||
}
|
||||
if (message.includes('MAX initData')) {
|
||||
return 'Не получилось проверить MAX Mini App. Откройте кабинет из MAX заново.';
|
||||
}
|
||||
return message;
|
||||
}
|
||||
|
||||
@@ -93,7 +110,6 @@ async function requestCode() {
|
||||
if (requestCodeMutation.loading.value) {
|
||||
return;
|
||||
}
|
||||
clearAutoRequestTimer();
|
||||
|
||||
feedback.value = '';
|
||||
const result = await requestCodeMutation.mutate({
|
||||
@@ -110,7 +126,6 @@ async function requestCode() {
|
||||
return;
|
||||
}
|
||||
|
||||
lastRequestedEmail.value = normalizedEmail.value;
|
||||
challengeToken.value = payload.challengeToken;
|
||||
maskedEmail.value = payload.destination;
|
||||
expiresAt.value = new Date(payload.expiresAt).toLocaleString();
|
||||
@@ -143,9 +158,19 @@ async function verifyCode() {
|
||||
}
|
||||
|
||||
await finalizeSession(payload.accessToken);
|
||||
await connectMessengerMiniApp();
|
||||
await navigateAfterLogin(payload.user);
|
||||
}
|
||||
|
||||
function returnToRequestStep() {
|
||||
step.value = 'request';
|
||||
code.value = '';
|
||||
feedback.value = '';
|
||||
challengeToken.value = '';
|
||||
maskedEmail.value = '';
|
||||
expiresAt.value = '';
|
||||
}
|
||||
|
||||
async function consumeLoginToken(loginToken: string) {
|
||||
feedback.value = '';
|
||||
const result = await consumeLoginTokenMutation.mutate({
|
||||
@@ -166,6 +191,85 @@ async function consumeLoginToken(loginToken: string) {
|
||||
await navigateAfterLogin(payload.user);
|
||||
}
|
||||
|
||||
function resolveMessengerMiniAppEndpoint(mode: 'session' | 'connect') {
|
||||
if (messengerMiniAppChannel.value === 'MAX') {
|
||||
return `/api/auth/max-mini-app/${mode}`;
|
||||
}
|
||||
|
||||
return `/api/auth/telegram-mini-app/${mode}`;
|
||||
}
|
||||
|
||||
function resolveMessengerMiniAppLabel() {
|
||||
return messengerMiniAppChannelLabel.value || 'Mini App';
|
||||
}
|
||||
|
||||
async function connectMessengerMiniApp() {
|
||||
if (!isMessengerMiniApp.value || !messengerMiniAppInitData.value) {
|
||||
return;
|
||||
}
|
||||
if (telegramMiniAppMode.value === 'authenticated') {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await $fetch(resolveMessengerMiniAppEndpoint('connect'), {
|
||||
method: 'POST',
|
||||
body: {
|
||||
initData: messengerMiniAppInitData.value,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('messenger mini app connect failed', error);
|
||||
}
|
||||
}
|
||||
|
||||
async function tryMessengerMiniAppLogin() {
|
||||
if (!isMessengerMiniApp.value || !messengerMiniAppInitData.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
telegramMiniAppMode.value = 'checking';
|
||||
|
||||
try {
|
||||
const payload = await $fetch<{
|
||||
ok: true;
|
||||
authenticated: boolean;
|
||||
accessToken?: string;
|
||||
user?: { company?: { id: string } | null; companyId?: string | null };
|
||||
telegramUser?: { displayName?: string };
|
||||
maxUser?: { displayName?: string };
|
||||
}>(resolveMessengerMiniAppEndpoint('session'), {
|
||||
method: 'POST',
|
||||
body: {
|
||||
initData: messengerMiniAppInitData.value,
|
||||
},
|
||||
});
|
||||
|
||||
if (payload.authenticated && payload.accessToken && payload.user) {
|
||||
telegramMiniAppMode.value = 'authenticated';
|
||||
await finalizeSession(payload.accessToken);
|
||||
await navigateAfterLogin(payload.user);
|
||||
return;
|
||||
}
|
||||
|
||||
telegramMiniAppMode.value = 'needs_email';
|
||||
const messengerUser = payload.maxUser ?? payload.telegramUser;
|
||||
feedback.value = messengerUser?.displayName
|
||||
? `${messengerUser.displayName}, введите рабочий e-mail. После входа мы привяжем этот ${resolveMessengerMiniAppLabel()} к вашему кабинету.`
|
||||
: `Введите рабочий e-mail. После входа мы привяжем этот ${resolveMessengerMiniAppLabel()} к вашему кабинету.`;
|
||||
feedbackTone.value = 'success';
|
||||
} catch (error) {
|
||||
telegramMiniAppMode.value = 'idle';
|
||||
const message = typeof error === 'object' && error && 'data' in error && typeof error.data === 'object' && error.data && 'error' in error.data
|
||||
? String(error.data.error || '')
|
||||
: error instanceof Error
|
||||
? error.message
|
||||
: `Не получилось проверить ${resolveMessengerMiniAppLabel()}.`;
|
||||
feedback.value = normalizeApolloErrorMessage(message);
|
||||
feedbackTone.value = 'error';
|
||||
}
|
||||
}
|
||||
|
||||
async function startMessengerLogin(channel: 'TELEGRAM' | 'MAX') {
|
||||
if (!isEmailReady.value) {
|
||||
feedback.value = 'Введите корректный email.';
|
||||
@@ -189,141 +293,142 @@ async function startMessengerLogin(channel: 'TELEGRAM' | 'MAX') {
|
||||
});
|
||||
}
|
||||
|
||||
function scheduleAutoRequest() {
|
||||
clearAutoRequestTimer();
|
||||
|
||||
if (step.value !== 'request') {
|
||||
return;
|
||||
}
|
||||
if (!isEmailReady.value) {
|
||||
return;
|
||||
}
|
||||
if (normalizedEmail.value === lastRequestedEmail.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
autoRequestTimer.value = setTimeout(() => {
|
||||
void requestCode();
|
||||
}, 450);
|
||||
}
|
||||
|
||||
function onEmailBlur() {
|
||||
if (step.value !== 'request') {
|
||||
return;
|
||||
}
|
||||
if (!isEmailReady.value) {
|
||||
return;
|
||||
}
|
||||
if (normalizedEmail.value === lastRequestedEmail.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
void requestCode();
|
||||
}
|
||||
|
||||
watch([normalizedEmail, step], () => {
|
||||
if (step.value !== 'request') {
|
||||
clearAutoRequestTimer();
|
||||
return;
|
||||
}
|
||||
|
||||
scheduleAutoRequest();
|
||||
});
|
||||
|
||||
onMounted(async () => {
|
||||
const loginToken = typeof route.query.login_token === 'string' ? route.query.login_token : '';
|
||||
if (loginToken) {
|
||||
await consumeLoginToken(loginToken);
|
||||
return;
|
||||
}
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
clearAutoRequestTimer();
|
||||
await tryMessengerMiniAppLogin();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="mx-auto flex min-h-[calc(100vh-4rem)] w-full max-w-3xl items-center py-8">
|
||||
<div class="card w-full border border-base-300/60 bg-base-100 shadow-xl">
|
||||
<div class="card-body p-5 md:p-8">
|
||||
<div class="mb-4 text-center">
|
||||
<h1 class="text-3xl font-extrabold">Вход в личный кабинет</h1>
|
||||
<section class="mx-auto flex w-full max-w-[540px] items-center justify-center py-6 md:py-10">
|
||||
<div class="surface-card w-full rounded-[32px] border border-white/70 px-6 py-6 shadow-[0_26px_70px_rgba(18,56,36,0.12)] md:px-8 md:py-8">
|
||||
<div class="space-y-6">
|
||||
<div class="space-y-3 text-center">
|
||||
<p class="text-[11px] font-bold uppercase tracking-[0.22em] text-[#6a8a76]">Фрегат</p>
|
||||
<div class="space-y-2">
|
||||
<h1 class="text-3xl font-black tracking-[-0.04em] text-[#123824] md:text-4xl">Вход</h1>
|
||||
<p class="text-sm leading-6 text-[#5c7b69]">
|
||||
Войдите по рабочему e-mail и коду из письма.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="step === 'request'" class="space-y-4">
|
||||
<fieldset class="fieldset">
|
||||
<legend class="fieldset-legend text-base font-semibold">E-mail</legend>
|
||||
<div
|
||||
v-if="telegramMiniAppMode === 'checking' || isMessengerMiniApp"
|
||||
class="rounded-[24px] border px-4 py-3 text-sm leading-6"
|
||||
:class="telegramMiniAppMode === 'checking'
|
||||
? 'border-[#dce9e1] bg-[#f7fbf9] text-[#355947]'
|
||||
: 'border-[#cbe9d6] bg-[#f1fbf5] text-[#1c6b45]'"
|
||||
>
|
||||
<template v-if="telegramMiniAppMode === 'checking'">
|
||||
{{ `Проверяем аккаунт ${resolveMessengerMiniAppLabel()}…` }}
|
||||
</template>
|
||||
<template v-else>
|
||||
{{
|
||||
messengerMiniAppDisplayName
|
||||
? `Вы вошли из ${resolveMessengerMiniAppLabel()} как ${messengerMiniAppDisplayName}.`
|
||||
: `Вы открыли кабинет внутри ${resolveMessengerMiniAppLabel()}.`
|
||||
}}
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div v-if="step === 'request'" class="space-y-5">
|
||||
<label class="block space-y-2">
|
||||
<span class="text-sm font-semibold text-[#355947]">E-mail</span>
|
||||
<input
|
||||
v-model="email"
|
||||
type="email"
|
||||
class="input input-bordered w-full"
|
||||
class="input manager-field h-14 w-full px-4 text-base text-[#123824]"
|
||||
placeholder="name@company.com"
|
||||
@keydown.enter.prevent="requestCode"
|
||||
@blur="onEmailBlur"
|
||||
>
|
||||
</fieldset>
|
||||
</label>
|
||||
|
||||
<div class="grid gap-2 sm:grid-cols-2">
|
||||
<button
|
||||
class="btn btn-secondary"
|
||||
:class="{ 'btn-disabled pointer-events-none': !telegramBotUrl || !isEmailReady }"
|
||||
:disabled="pendingChannel === 'TELEGRAM' || !telegramBotUrl || !isEmailReady"
|
||||
@click="startMessengerLogin('TELEGRAM')"
|
||||
>
|
||||
{{ pendingChannel === 'TELEGRAM' ? 'Открываем Telegram…' : 'Войти через Telegram' }}
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-accent"
|
||||
:class="{ 'btn-disabled pointer-events-none': !maxBotUrl || !isEmailReady }"
|
||||
:disabled="pendingChannel === 'MAX' || !maxBotUrl || !isEmailReady"
|
||||
@click="startMessengerLogin('MAX')"
|
||||
>
|
||||
{{ pendingChannel === 'MAX' ? 'Открываем Max…' : 'Войти через Max' }}
|
||||
</button>
|
||||
<button
|
||||
class="btn h-12 w-full rounded-full border-0 bg-[#123824] text-white shadow-[0_16px_32px_rgba(18,56,36,0.18)] hover:bg-[#0f2f20] disabled:border-0 disabled:bg-[#cfd8d2] disabled:text-[#6f8579]"
|
||||
:disabled="requestCodeMutation.loading.value || !isEmailReady"
|
||||
@click="requestCode"
|
||||
>
|
||||
{{ requestCodeMutation.loading.value ? 'Отправляем код…' : 'Получить код' }}
|
||||
</button>
|
||||
|
||||
<div v-if="hasMessengerButtons" class="space-y-3">
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="h-px flex-1 bg-[#e2ece6]" />
|
||||
<span class="text-[11px] font-bold uppercase tracking-[0.18em] text-[#7a9386]">или войти через</span>
|
||||
<span class="h-px flex-1 bg-[#e2ece6]" />
|
||||
</div>
|
||||
|
||||
<div class="grid gap-3 sm:grid-cols-2">
|
||||
<button
|
||||
v-if="canUseTelegramLogin"
|
||||
class="btn h-12 rounded-full border border-[#d7e6dc] bg-white text-[#123824] hover:border-[#bed6c7] hover:bg-[#f6fbf8] disabled:border-[#e6ede9] disabled:bg-[#f8fbf9] disabled:text-[#91a79b]"
|
||||
:disabled="pendingChannel === 'TELEGRAM' || !isEmailReady"
|
||||
@click="startMessengerLogin('TELEGRAM')"
|
||||
>
|
||||
{{ pendingChannel === 'TELEGRAM' ? 'Открываем Telegram…' : 'Telegram' }}
|
||||
</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>
|
||||
|
||||
<p v-if="requestCodeMutation.loading.value" class="text-sm text-base-content/70">
|
||||
Проверяем e-mail и отправляем код...
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div v-else class="space-y-4">
|
||||
<p class="text-sm text-base-content/70">E-mail: {{ maskedEmail }}</p>
|
||||
<div v-else class="space-y-5">
|
||||
<div class="rounded-[24px] bg-[#f5faf7] px-4 py-3 text-sm text-[#355947]">
|
||||
Код отправлен на <span class="font-semibold text-[#123824]">{{ maskedEmail }}</span>
|
||||
</div>
|
||||
|
||||
<fieldset class="fieldset">
|
||||
<legend class="fieldset-legend text-base font-semibold">Код</legend>
|
||||
<label class="block space-y-2">
|
||||
<span class="text-sm font-semibold text-[#355947]">Код из письма</span>
|
||||
<input
|
||||
v-model="code"
|
||||
type="text"
|
||||
maxlength="6"
|
||||
class="input input-bordered w-full"
|
||||
class="input manager-field h-14 w-full px-4 text-base tracking-[0.22em] text-[#123824]"
|
||||
placeholder="123456"
|
||||
@keydown.enter.prevent="verifyCode"
|
||||
>
|
||||
</fieldset>
|
||||
</label>
|
||||
|
||||
<button
|
||||
class="btn btn-primary w-full"
|
||||
:disabled="verifyCodeMutation.loading.value"
|
||||
@click="verifyCode"
|
||||
>
|
||||
{{ verifyCodeMutation.loading.value ? 'Проверяем…' : 'Войти' }}
|
||||
</button>
|
||||
<div class="space-y-3">
|
||||
<button
|
||||
class="btn h-12 w-full rounded-full border-0 bg-[#123824] text-white shadow-[0_16px_32px_rgba(18,56,36,0.18)] hover:bg-[#0f2f20] disabled:border-0 disabled:bg-[#cfd8d2] disabled:text-[#6f8579]"
|
||||
:disabled="verifyCodeMutation.loading.value || !code.trim()"
|
||||
@click="verifyCode"
|
||||
>
|
||||
{{ verifyCodeMutation.loading.value ? 'Проверяем…' : 'Войти' }}
|
||||
</button>
|
||||
|
||||
<button
|
||||
class="btn btn-ghost w-full"
|
||||
@click="step = 'request'; code = ''; feedback = ''; challengeToken = ''; maskedEmail = ''"
|
||||
>
|
||||
Изменить e-mail
|
||||
</button>
|
||||
<button
|
||||
class="btn h-12 w-full rounded-full border border-[#d7e6dc] bg-white text-[#123824] hover:border-[#bed6c7] hover:bg-[#f6fbf8]"
|
||||
@click="returnToRequestStep"
|
||||
>
|
||||
Изменить e-mail
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p class="text-xs text-base-content/60">Код действует до {{ expiresAt }}</p>
|
||||
<p class="text-xs text-[#7a9386]">Код действует до {{ expiresAt }}</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="feedback"
|
||||
class="alert mt-2"
|
||||
:class="feedbackTone === 'success' ? 'alert-success' : 'alert-error'"
|
||||
class="rounded-[24px] border px-4 py-3 text-sm leading-6"
|
||||
:class="feedbackTone === 'success'
|
||||
? 'border-[#cbe9d6] bg-[#f1fbf5] text-[#1c6b45]'
|
||||
: 'border-[#f1d1c7] bg-[#fff3ef] text-[#9d4426]'"
|
||||
>
|
||||
{{ feedback }}
|
||||
</div>
|
||||
|
||||
108
app/pages/messages.vue
Normal file
@@ -0,0 +1,108 @@
|
||||
<script setup lang="ts">
|
||||
import { useQuery } from '@vue/apollo-composable';
|
||||
import {
|
||||
NotificationTemplatesDocument,
|
||||
type NotificationTemplatesQuery,
|
||||
} from '~/composables/graphql/generated';
|
||||
|
||||
definePageMeta({
|
||||
middleware: ['manager-only'],
|
||||
path: '/admin/settings/messages',
|
||||
alias: ['/messages'],
|
||||
});
|
||||
|
||||
type TemplateItem = NotificationTemplatesQuery['notificationTemplates'][number];
|
||||
type TemplateChannel = TemplateItem['channels'][number];
|
||||
|
||||
const templatesQuery = useQuery(NotificationTemplatesDocument);
|
||||
|
||||
const templates = computed<TemplateItem[]>(() => templatesQuery.result.value?.notificationTemplates ?? []);
|
||||
|
||||
function channelLabel(channel: TemplateChannel['channel']) {
|
||||
if (channel === 'EMAIL') {
|
||||
return 'Email';
|
||||
}
|
||||
if (channel === 'TELEGRAM') {
|
||||
return 'Telegram';
|
||||
}
|
||||
return 'Max';
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="space-y-6">
|
||||
<h1 class="text-3xl font-extrabold text-[#0f2f20]">Сообщения</h1>
|
||||
|
||||
<div v-if="templatesQuery.loading.value" class="manager-empty-state">
|
||||
Загружаем шаблоны...
|
||||
</div>
|
||||
|
||||
<div v-else-if="templates.length === 0" class="manager-empty-state">
|
||||
Шаблонов пока нет.
|
||||
</div>
|
||||
|
||||
<div v-else class="space-y-6">
|
||||
<section
|
||||
v-for="template in templates"
|
||||
:key="template.id"
|
||||
>
|
||||
<h2 class="text-xl font-bold text-[#123824]">
|
||||
{{ template.title }}
|
||||
</h2>
|
||||
|
||||
<div class="mt-4 grid gap-4 xl:grid-cols-3">
|
||||
<section
|
||||
v-for="channel in template.channels"
|
||||
:key="`${template.id}-${channel.channel}`"
|
||||
class="surface-card rounded-[24px] bg-white p-4"
|
||||
>
|
||||
<h3 class="text-sm font-extrabold uppercase tracking-[0.14em] text-[#355947]">
|
||||
{{ channelLabel(channel.channel) }}
|
||||
</h3>
|
||||
|
||||
<div class="mt-3 rounded-[20px] bg-[#f8fbf9] p-4">
|
||||
<div
|
||||
v-if="channel.subject"
|
||||
class="mb-4"
|
||||
>
|
||||
<p class="text-xs font-semibold uppercase tracking-[0.12em] text-[#5c7b69]">
|
||||
Тема:
|
||||
</p>
|
||||
<p class="mt-2 text-sm font-semibold text-[#123824]">
|
||||
{{ channel.subject }}
|
||||
</p>
|
||||
|
||||
<div class="mt-4 h-px bg-[#d7e4dc]" />
|
||||
</div>
|
||||
|
||||
<div class="space-y-3 text-sm leading-6 text-[#123824]">
|
||||
<p
|
||||
v-for="line in channel.body"
|
||||
:key="line"
|
||||
>
|
||||
{{ line }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div v-if="channel.buttonText" class="mt-4">
|
||||
<a
|
||||
v-if="channel.buttonUrl"
|
||||
:href="channel.buttonUrl"
|
||||
class="btn h-11 rounded-full border-0 bg-[#123824] px-5 text-white hover:bg-[#0f2f20]"
|
||||
>
|
||||
{{ channel.buttonText }}
|
||||
</a>
|
||||
<span
|
||||
v-else
|
||||
class="inline-flex h-11 items-center rounded-full bg-[#123824] px-5 text-sm font-semibold text-white"
|
||||
>
|
||||
{{ channel.buttonText }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
@@ -118,6 +118,7 @@ async function sendTest() {
|
||||
</div>
|
||||
|
||||
<div class="surface-card rounded-3xl p-5">
|
||||
<div class="mt-5">
|
||||
<div class="space-y-4">
|
||||
<h2 class="text-xl font-bold text-[#123824]">Подключение каналов</h2>
|
||||
|
||||
@@ -266,6 +267,7 @@ async function sendTest() {
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
@@ -1,101 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { useQuery } from '@vue/apollo-composable';
|
||||
import OrderStatusBadge from '~/components/orders/OrderStatusBadge.vue';
|
||||
import {
|
||||
MyOrdersDocument,
|
||||
type MyOrdersQuery,
|
||||
} from '~/composables/graphql/generated';
|
||||
|
||||
type OrderItem = MyOrdersQuery['myOrders'][number];
|
||||
|
||||
const allOrders = useQuery(MyOrdersDocument);
|
||||
const search = ref('');
|
||||
const statusFilter = ref<'ALL' | 'WAITING' | 'ACTIVE' | 'CLOSED'>('ALL');
|
||||
|
||||
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']);
|
||||
|
||||
function matchesFilter(order: OrderItem) {
|
||||
if (statusFilter.value === 'ALL') {
|
||||
return true;
|
||||
}
|
||||
if (statusFilter.value === 'WAITING') {
|
||||
return order.status === 'WAITING_DOUBLE_CONFIRM';
|
||||
}
|
||||
if (statusFilter.value === 'ACTIVE') {
|
||||
return ACTIVE_STATUSES.has(order.status);
|
||||
}
|
||||
return CLOSED_STATUSES.has(order.status);
|
||||
}
|
||||
|
||||
const filteredOrders = computed(() => {
|
||||
const orders = allOrders.result.value?.myOrders ?? [];
|
||||
const normalizedSearch = search.value.trim().toLowerCase();
|
||||
|
||||
return orders.filter((order) => {
|
||||
const text = [
|
||||
order.code,
|
||||
...order.items.map((item) => item.productName),
|
||||
]
|
||||
.join(' ')
|
||||
.toLowerCase();
|
||||
|
||||
const matchSearch = !normalizedSearch || text.includes(normalizedSearch);
|
||||
return matchSearch && matchesFilter(order);
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="space-y-6">
|
||||
<UiSectionSearchHero
|
||||
v-model="search"
|
||||
title="Мои заказы"
|
||||
search-placeholder="Номер заказа или товар"
|
||||
>
|
||||
<template #controls>
|
||||
<select v-model="statusFilter" class="select select-bordered w-full rounded-full bg-white md:w-64">
|
||||
<option value="ALL">Все заказы</option>
|
||||
<option value="WAITING">Ожидают подтверждения</option>
|
||||
<option value="ACTIVE">Активные</option>
|
||||
<option value="CLOSED">Закрытые</option>
|
||||
</select>
|
||||
</template>
|
||||
</UiSectionSearchHero>
|
||||
|
||||
<div v-if="allOrders.loading.value" class="alert surface-card border-0">Загрузка заказов...</div>
|
||||
<div v-else-if="filteredOrders.length === 0" class="alert surface-card border-0">
|
||||
Заказы по текущим условиям не найдены.
|
||||
</div>
|
||||
|
||||
<div v-else class="space-y-3">
|
||||
<article
|
||||
v-for="order in filteredOrders"
|
||||
:key="order.id"
|
||||
class="surface-card rounded-3xl p-4 md:p-5"
|
||||
>
|
||||
<div class="flex flex-wrap items-start justify-between gap-3">
|
||||
<div class="space-y-1">
|
||||
<h2 class="text-lg font-bold text-[#123824]">{{ order.code }}</h2>
|
||||
<p class="text-sm text-[#355947]">Создан: {{ new Date(order.createdAt).toLocaleString() }}</p>
|
||||
<p v-if="order.deliveryAddress" class="text-sm text-[#355947]">Адрес: {{ order.deliveryAddress }}</p>
|
||||
<p v-if="order.deliveryTerms" class="text-sm text-[#355947]">Доставка: {{ order.deliveryTerms }}</p>
|
||||
</div>
|
||||
<div class="flex flex-col items-end gap-2">
|
||||
<OrderStatusBadge :status="order.status" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ul class="mt-4 grid gap-2 text-sm text-[#214735]">
|
||||
<li
|
||||
v-for="item in order.items"
|
||||
:key="item.id"
|
||||
class="rounded-2xl border border-[#d6ebde] bg-white px-4 py-3"
|
||||
>
|
||||
{{ item.productName }} × {{ item.quantity }}
|
||||
</li>
|
||||
</ul>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
66
app/pages/orders/[id].vue
Normal file
@@ -0,0 +1,66 @@
|
||||
<script setup lang="ts">
|
||||
import { useQuery } from '@vue/apollo-composable';
|
||||
import {
|
||||
OrderDetailDocument,
|
||||
type OrderDetailQuery,
|
||||
} from '~/composables/graphql/generated';
|
||||
import { formatOrderCode } from '~/composables/useOrderCodePresentation';
|
||||
|
||||
type OrderItem = NonNullable<OrderDetailQuery['order']>;
|
||||
|
||||
const route = useRoute();
|
||||
const orderId = computed(() => String(route.params.id || ''));
|
||||
const orderQuery = useQuery(OrderDetailDocument, () => ({
|
||||
id: orderId.value,
|
||||
}));
|
||||
|
||||
const currentOrder = computed<OrderItem | null>(() =>
|
||||
orderQuery.result.value?.order ?? null,
|
||||
);
|
||||
|
||||
const currentOrderCode = computed(() => formatOrderCode(currentOrder.value?.code));
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="space-y-6">
|
||||
<div v-if="orderQuery.loading.value" class="manager-empty-state">
|
||||
Загружаем заказ...
|
||||
</div>
|
||||
|
||||
<div v-else-if="!currentOrder" class="manager-empty-state">
|
||||
Заказ не найден.
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
<UiBackHeader
|
||||
to="/orders"
|
||||
back-label="Назад к моим заказам"
|
||||
:title="`Заказ ${currentOrderCode}`"
|
||||
/>
|
||||
|
||||
<div class="space-y-4">
|
||||
<OrdersOrderStatusTimelineCard
|
||||
:status="currentOrder.status"
|
||||
:created-at="currentOrder.createdAt"
|
||||
audience="client"
|
||||
/>
|
||||
|
||||
<div>
|
||||
<h2 class="text-xl font-bold text-[#123824]">Состав заказа</h2>
|
||||
<OrdersOrderItemsTable
|
||||
class="mt-4"
|
||||
:items="currentOrder.items"
|
||||
:calculation-payload="currentOrder.calculationPayload"
|
||||
:framed="false"
|
||||
/>
|
||||
<OrdersOrderDeliveryLine
|
||||
class="mt-3"
|
||||
:delivery-address="currentOrder.deliveryAddress"
|
||||
:delivery-terms="currentOrder.deliveryTerms"
|
||||
:delivery-fee="currentOrder.deliveryFee"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</section>
|
||||
</template>
|
||||
173
app/pages/orders/index.vue
Normal file
@@ -0,0 +1,173 @@
|
||||
<script setup lang="ts">
|
||||
import { useQuery } from '@vue/apollo-composable';
|
||||
import {
|
||||
MyOrdersDocument,
|
||||
type MyOrdersQuery,
|
||||
} from '~/composables/graphql/generated';
|
||||
import { formatOrderCode } from '~/composables/useOrderCodePresentation';
|
||||
|
||||
type OrderItem = MyOrdersQuery['myOrders'][number];
|
||||
|
||||
const allOrders = useQuery(MyOrdersDocument);
|
||||
const search = ref('');
|
||||
const statusFilter = ref<'ALL' | 'WAITING' | 'ACTIVE' | 'CLOSED'>('ALL');
|
||||
const dateFrom = ref('');
|
||||
const dateTo = ref('');
|
||||
|
||||
const ACTIVE_STATUSES = new Set(['NEW', 'MANAGER_PROCESSING', 'WAITING_DOUBLE_CONFIRM', 'CONFIRMED', 'IN_PROGRESS']);
|
||||
const CLOSED_STATUSES = new Set(['COMPLETED', 'CLIENT_REJECTED', 'MANAGER_REJECTED', 'MANAGER_BLOCKED']);
|
||||
|
||||
function matchesFilter(order: OrderItem) {
|
||||
if (statusFilter.value === 'ALL') {
|
||||
return true;
|
||||
}
|
||||
if (statusFilter.value === 'WAITING') {
|
||||
return order.status === 'WAITING_DOUBLE_CONFIRM';
|
||||
}
|
||||
if (statusFilter.value === 'ACTIVE') {
|
||||
return ACTIVE_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 orders = allOrders.result.value?.myOrders ?? [];
|
||||
const normalizedSearch = search.value.trim().toLowerCase();
|
||||
|
||||
return orders.filter((order) => {
|
||||
const text = [
|
||||
order.code,
|
||||
formatOrderCode(order.code),
|
||||
...order.items.map((item) => item.productName),
|
||||
]
|
||||
.join(' ')
|
||||
.toLowerCase();
|
||||
|
||||
const matchSearch = !normalizedSearch || text.includes(normalizedSearch);
|
||||
return matchSearch && matchesFilter(order) && matchesDate(order);
|
||||
});
|
||||
});
|
||||
|
||||
const {
|
||||
canLoadMore,
|
||||
loadMore,
|
||||
loadMoreSentinel,
|
||||
remainingCount,
|
||||
visibleItems: visibleOrders,
|
||||
} = useIncrementalList(filteredOrders, {
|
||||
pageSize: 24,
|
||||
resetKeys: [search, statusFilter, dateFrom, dateTo],
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="space-y-6">
|
||||
<UiSectionSearchHero
|
||||
v-model="search"
|
||||
title="Мои заказы"
|
||||
search-placeholder="Номер заказа или товар"
|
||||
>
|
||||
<template #controls>
|
||||
<div class="flex w-full flex-col gap-3 md:w-auto md:flex-row md:flex-wrap md:justify-end">
|
||||
<label class="flex items-center gap-2 rounded-full border border-[#d7e9de] bg-white px-4 py-3 text-sm font-semibold text-[#123824]">
|
||||
<svg class="h-4 w-4 text-[#6b8576]" viewBox="0 0 20 20" fill="none">
|
||||
<path d="M3.33334 5H16.6667" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" />
|
||||
<path d="M6.66666 10H13.3333" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" />
|
||||
<path d="M8.33334 15H11.6667" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" />
|
||||
</svg>
|
||||
<select
|
||||
v-model="statusFilter"
|
||||
class="min-w-0 bg-transparent outline-none"
|
||||
>
|
||||
<option value="ALL">Все заказы</option>
|
||||
<option value="WAITING">Ожидают подтверждения</option>
|
||||
<option value="ACTIVE">Активные</option>
|
||||
<option value="CLOSED">Закрытые</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label class="flex items-center gap-2 rounded-full border border-[#d7e9de] bg-white px-4 py-3 text-sm font-semibold text-[#123824]">
|
||||
<svg class="h-4 w-4 text-[#6b8576]" viewBox="0 0 20 20" fill="none">
|
||||
<rect x="3" y="4.5" width="14" height="12" rx="2.5" stroke="currentColor" stroke-width="1.6" />
|
||||
<path d="M6 2.5V6" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" />
|
||||
<path d="M14 2.5V6" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" />
|
||||
<path d="M3 8.5H17" stroke="currentColor" stroke-width="1.6" />
|
||||
</svg>
|
||||
<input
|
||||
v-model="dateFrom"
|
||||
type="date"
|
||||
class="min-w-0 bg-transparent outline-none"
|
||||
>
|
||||
</label>
|
||||
|
||||
<label class="flex items-center gap-2 rounded-full border border-[#d7e9de] bg-white px-4 py-3 text-sm font-semibold text-[#123824]">
|
||||
<svg class="h-4 w-4 text-[#6b8576]" viewBox="0 0 20 20" fill="none">
|
||||
<rect x="3" y="4.5" width="14" height="12" rx="2.5" stroke="currentColor" stroke-width="1.6" />
|
||||
<path d="M6 2.5V6" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" />
|
||||
<path d="M14 2.5V6" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" />
|
||||
<path d="M3 8.5H17" stroke="currentColor" stroke-width="1.6" />
|
||||
</svg>
|
||||
<input
|
||||
v-model="dateTo"
|
||||
type="date"
|
||||
class="min-w-0 bg-transparent outline-none"
|
||||
>
|
||||
</label>
|
||||
</div>
|
||||
</template>
|
||||
</UiSectionSearchHero>
|
||||
|
||||
<div v-if="allOrders.loading.value" class="manager-empty-state">
|
||||
Загрузка заказов...
|
||||
</div>
|
||||
<div v-else-if="filteredOrders.length === 0" class="manager-empty-state">
|
||||
Заказы по текущим условиям не найдены.
|
||||
</div>
|
||||
|
||||
<div v-else class="space-y-3">
|
||||
<OrdersOrderSummaryCard
|
||||
v-for="order in visibleOrders"
|
||||
:key="order.id"
|
||||
:to="`/orders/${order.id}`"
|
||||
:code="order.code"
|
||||
:status="order.status"
|
||||
:created-at="order.createdAt"
|
||||
:total-price="order.totalPrice"
|
||||
:items="order.items"
|
||||
/>
|
||||
|
||||
<div
|
||||
v-if="canLoadMore"
|
||||
ref="loadMoreSentinel"
|
||||
class="flex justify-center pt-2"
|
||||
>
|
||||
<button class="btn btn-outline border-[#d7e9de] bg-white" @click="loadMore">
|
||||
Показать ещё {{ Math.min(remainingCount, 24) }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
@@ -1,7 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import CatalogConfigurator from '~/components/catalog/CatalogConfigurator.vue';
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<CatalogConfigurator />
|
||||
</template>
|
||||
11
app/pages/products/[slug].vue
Normal file
@@ -0,0 +1,11 @@
|
||||
<script setup lang="ts">
|
||||
import CatalogConfigurator from '~/components/catalog/CatalogConfigurator.vue';
|
||||
|
||||
const route = useRoute();
|
||||
|
||||
const productTypeSlug = computed(() => String(route.params.slug ?? ''));
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<CatalogConfigurator :product-type-slug="productTypeSlug" />
|
||||
</template>
|
||||
7
app/pages/products/index.vue
Normal file
@@ -0,0 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import CatalogProductTypeList from '~/components/catalog/CatalogProductTypeList.vue';
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<CatalogProductTypeList />
|
||||
</template>
|
||||
@@ -1,138 +1,25 @@
|
||||
<script setup lang="ts">
|
||||
import { useMutation, useQuery } from '@vue/apollo-composable';
|
||||
import {
|
||||
CreateMyDeliveryAddressDocument,
|
||||
DeleteMyDeliveryAddressDocument,
|
||||
MyDeliveryAddressesDocument,
|
||||
SetMyDefaultDeliveryAddressDocument,
|
||||
type MyDeliveryAddressesQuery,
|
||||
} from '~/composables/graphql/generated';
|
||||
|
||||
type AddressSuggestion = {
|
||||
value: string;
|
||||
unrestricted_value?: string;
|
||||
data?: {
|
||||
fias_id?: string;
|
||||
};
|
||||
};
|
||||
|
||||
type DeliveryAddressItem = MyDeliveryAddressesQuery['myDeliveryAddresses'][number];
|
||||
|
||||
const addressFeedback = ref('');
|
||||
const addressFeedbackTone = ref<'success' | 'error'>('success');
|
||||
const route = useRoute();
|
||||
const addressFeedback = ref(route.query.created === '1' ? 'Адрес сохранён.' : '');
|
||||
const addressFeedbackTone = ref<'success' | 'error'>(route.query.created === '1' ? 'success' : 'error');
|
||||
|
||||
const deliveryAddressesQuery = useQuery(MyDeliveryAddressesDocument);
|
||||
const createAddressMutation = useMutation(CreateMyDeliveryAddressDocument, { throws: 'never' });
|
||||
const setDefaultAddressMutation = useMutation(SetMyDefaultDeliveryAddressDocument, { throws: 'never' });
|
||||
const deleteAddressMutation = useMutation(DeleteMyDeliveryAddressDocument, { throws: 'never' });
|
||||
|
||||
const addressForm = reactive({
|
||||
label: '',
|
||||
address: '',
|
||||
unrestrictedValue: '',
|
||||
fiasId: '',
|
||||
});
|
||||
const addressSearch = ref('');
|
||||
const addressSuggestions = ref<AddressSuggestion[]>([]);
|
||||
const addressLoading = ref(false);
|
||||
const addressOpen = ref(false);
|
||||
const addressSearchTimer = ref<ReturnType<typeof setTimeout> | null>(null);
|
||||
const addressBusyId = ref<string | null>(null);
|
||||
const addressDropdownRef = ref<HTMLElement | null>(null);
|
||||
|
||||
const deliveryAddresses = computed<DeliveryAddressItem[]>(() => deliveryAddressesQuery.result.value?.myDeliveryAddresses ?? []);
|
||||
|
||||
function clearAddressTimer() {
|
||||
if (!addressSearchTimer.value) {
|
||||
return;
|
||||
}
|
||||
clearTimeout(addressSearchTimer.value);
|
||||
addressSearchTimer.value = null;
|
||||
}
|
||||
|
||||
async function fetchAddressSuggestions() {
|
||||
const query = addressSearch.value.trim();
|
||||
if (query.length < 2) {
|
||||
addressSuggestions.value = [];
|
||||
addressOpen.value = false;
|
||||
return;
|
||||
}
|
||||
|
||||
addressLoading.value = true;
|
||||
await $fetch<{ suggestions: AddressSuggestion[] }>('/api/dadata/address', {
|
||||
method: 'POST',
|
||||
body: { query },
|
||||
})
|
||||
.then((response) => {
|
||||
addressSuggestions.value = response.suggestions || [];
|
||||
addressOpen.value = addressSuggestions.value.length > 0;
|
||||
})
|
||||
.finally(() => {
|
||||
addressLoading.value = false;
|
||||
});
|
||||
}
|
||||
|
||||
function scheduleAddressSuggest() {
|
||||
clearAddressTimer();
|
||||
addressSearchTimer.value = setTimeout(() => {
|
||||
void fetchAddressSuggestions();
|
||||
}, 250);
|
||||
}
|
||||
|
||||
function applyAddressSuggestion(item: AddressSuggestion) {
|
||||
addressOpen.value = false;
|
||||
addressSearch.value = item.value;
|
||||
addressForm.address = item.value;
|
||||
addressForm.unrestrictedValue = item.unrestricted_value || item.value;
|
||||
addressForm.fiasId = item.data?.fias_id || '';
|
||||
}
|
||||
|
||||
function closeDropdownsFromOutside(event: MouseEvent) {
|
||||
const target = event.target as Node | null;
|
||||
if (addressDropdownRef.value && target && !addressDropdownRef.value.contains(target)) {
|
||||
addressOpen.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function addDeliveryAddress() {
|
||||
addressFeedback.value = '';
|
||||
|
||||
const normalizedAddress = addressForm.address.trim() || addressSearch.value.trim();
|
||||
if (normalizedAddress.length < 5) {
|
||||
addressFeedbackTone.value = 'error';
|
||||
addressFeedback.value = 'Введите адрес через подсказки DaData.';
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await createAddressMutation.mutate({
|
||||
input: {
|
||||
label: addressForm.label.trim() ? addressForm.label.trim() : null,
|
||||
address: normalizedAddress,
|
||||
unrestrictedValue: addressForm.unrestrictedValue.trim() ? addressForm.unrestrictedValue.trim() : null,
|
||||
fiasId: addressForm.fiasId.trim() ? addressForm.fiasId.trim() : null,
|
||||
},
|
||||
});
|
||||
|
||||
const payload = result?.data?.createMyDeliveryAddress;
|
||||
if (!payload) {
|
||||
addressFeedbackTone.value = 'error';
|
||||
addressFeedback.value = createAddressMutation.error.value?.message || 'Не удалось добавить адрес.';
|
||||
return;
|
||||
}
|
||||
|
||||
addressForm.label = '';
|
||||
addressForm.address = '';
|
||||
addressForm.unrestrictedValue = '';
|
||||
addressForm.fiasId = '';
|
||||
addressSearch.value = '';
|
||||
addressSuggestions.value = [];
|
||||
addressOpen.value = false;
|
||||
|
||||
addressFeedbackTone.value = 'success';
|
||||
addressFeedback.value = 'Адрес сохранён.';
|
||||
await deliveryAddressesQuery.refetch();
|
||||
}
|
||||
|
||||
async function setDefaultAddress(addressId: string) {
|
||||
addressFeedback.value = '';
|
||||
addressBusyId.value = addressId;
|
||||
@@ -170,113 +57,78 @@ async function deleteAddress(addressId: string) {
|
||||
addressFeedback.value = 'Адрес удалён.';
|
||||
await deliveryAddressesQuery.refetch();
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
document.addEventListener('click', closeDropdownsFromOutside);
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
document.removeEventListener('click', closeDropdownsFromOutside);
|
||||
clearAddressTimer();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="space-y-6">
|
||||
<NuxtLink to="/profile" class="link link-hover text-sm">← Назад в профиль</NuxtLink>
|
||||
<h1 class="text-3xl font-extrabold text-[#0f2f20]">Адреса доставки</h1>
|
||||
|
||||
<div class="surface-card rounded-3xl p-5">
|
||||
<p class="text-sm text-[#355947]">
|
||||
Добавьте адрес через DaData и выберите основной. Этот адрес будет использоваться по умолчанию в корзине.
|
||||
</p>
|
||||
|
||||
<fieldset class="fieldset mt-4">
|
||||
<legend class="fieldset-legend">Название адреса (необязательно)</legend>
|
||||
<input v-model="addressForm.label" type="text" class="input w-full" placeholder="Склад МСК" >
|
||||
</fieldset>
|
||||
|
||||
<div ref="addressDropdownRef" class="relative mt-2">
|
||||
<fieldset class="fieldset">
|
||||
<legend class="fieldset-legend">Поиск адреса (DaData)</legend>
|
||||
<input
|
||||
v-model="addressSearch"
|
||||
type="text"
|
||||
class="input w-full"
|
||||
placeholder="Начните вводить адрес"
|
||||
@input="scheduleAddressSuggest"
|
||||
@focus="addressOpen = addressSuggestions.length > 0"
|
||||
>
|
||||
</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"
|
||||
<UiBackHeader
|
||||
to="/profile"
|
||||
back-label="Назад в профиль"
|
||||
title="Адреса доставки"
|
||||
subtitle="Выберите основной адрес для заказов или добавьте новый."
|
||||
>
|
||||
<template #actions>
|
||||
<NuxtLink
|
||||
to="/profile/addresses/new"
|
||||
class="btn rounded-full border-0 bg-[#123824] px-6 text-white hover:bg-[#0f2f20]"
|
||||
>
|
||||
<button
|
||||
v-for="item in addressSuggestions"
|
||||
:key="`${item.value}-${item.data?.fias_id || ''}`"
|
||||
type="button"
|
||||
class="btn btn-ghost mb-1 h-auto min-h-0 w-full justify-start whitespace-normal px-3 py-2 text-left"
|
||||
@click="applyAddressSuggestion(item)"
|
||||
>
|
||||
<span class="block text-sm font-semibold">{{ item.value }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
Добавить
|
||||
</NuxtLink>
|
||||
</template>
|
||||
</UiBackHeader>
|
||||
|
||||
<button class="btn btn-primary mt-4 w-full" :disabled="createAddressMutation.loading.value" @click="addDeliveryAddress">
|
||||
{{ createAddressMutation.loading.value ? 'Добавляем…' : 'Добавить адрес' }}
|
||||
</button>
|
||||
|
||||
<div v-if="addressFeedback" class="alert mt-3" :class="addressFeedbackTone === 'success' ? 'alert-success' : 'alert-error'">
|
||||
{{ addressFeedback }}
|
||||
</div>
|
||||
<div
|
||||
v-if="addressFeedback"
|
||||
class="rounded-[24px] border px-4 py-3 text-sm font-medium"
|
||||
:class="addressFeedbackTone === 'success'
|
||||
? 'border-[#cbe9d6] bg-[#f1fbf5] text-[#1c6b45]'
|
||||
: 'border-[#f1d1c7] bg-[#fff3ef] text-[#9d4426]'"
|
||||
>
|
||||
{{ addressFeedback }}
|
||||
</div>
|
||||
|
||||
<div class="surface-card rounded-3xl p-5">
|
||||
<h2 class="text-xl font-bold text-[#123824]">Список адресов</h2>
|
||||
<div v-if="deliveryAddressesQuery.loading.value" class="rounded-[28px] bg-white px-5 py-4 text-sm text-[#355947] shadow-[0_18px_38px_rgba(18,56,36,0.08)]">
|
||||
Загружаем адреса...
|
||||
</div>
|
||||
|
||||
<div class="mt-4 space-y-2">
|
||||
<div v-if="deliveryAddressesQuery.loading.value" class="alert surface-card">Загрузка адресов...</div>
|
||||
<div v-else-if="deliveryAddresses.length === 0" class="alert surface-card">
|
||||
Пока нет адресов доставки.
|
||||
</div>
|
||||
<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>
|
||||
|
||||
<article
|
||||
v-for="address in deliveryAddresses"
|
||||
:key="address.id"
|
||||
class="rounded-2xl bg-[#f8fbf9] p-3 transition hover:shadow-md"
|
||||
>
|
||||
<div class="flex items-start justify-between gap-2">
|
||||
<div>
|
||||
<p class="font-semibold text-[#123824]">{{ address.label || 'Адрес доставки' }}</p>
|
||||
<p class="text-sm text-[#355947]">{{ address.unrestrictedValue || address.address }}</p>
|
||||
<div v-else class="space-y-4">
|
||||
<article
|
||||
v-for="address in deliveryAddresses"
|
||||
: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 flex-col gap-4 md:flex-row md:items-center md:justify-between">
|
||||
<div class="min-w-0 space-y-1">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<p class="text-lg font-bold text-[#123824]">{{ address.label || 'Адрес доставки' }}</p>
|
||||
<span v-if="address.isDefault" class="badge border-0 bg-[#e8f5ec] text-[#1c6b45]">Основной</span>
|
||||
</div>
|
||||
<span v-if="address.isDefault" class="badge badge-success">Основной</span>
|
||||
<p class="text-sm leading-6 text-[#557562]">{{ address.unrestrictedValue || address.address }}</p>
|
||||
</div>
|
||||
|
||||
<div class="mt-3 flex flex-wrap gap-2">
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<button
|
||||
v-if="!address.isDefault"
|
||||
class="btn btn-sm"
|
||||
class="btn rounded-full border border-[#d7e6dc] bg-white px-5 text-[#123824] hover:border-[#bed6c7] hover:bg-[#f6fbf8]"
|
||||
:disabled="addressBusyId === address.id"
|
||||
@click="setDefaultAddress(address.id)"
|
||||
>
|
||||
{{ addressBusyId === address.id ? 'Сохраняем…' : 'Сделать основным' }}
|
||||
{{ addressBusyId === address.id ? 'Сохраняем...' : 'Сделать основным' }}
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-sm btn-ghost text-error"
|
||||
class="btn rounded-full border border-[#e5cfc7] bg-[#fff5f1] px-5 text-[#a64d2d] hover:border-[#deb5a8] hover:bg-[#ffe8e0]"
|
||||
:disabled="addressBusyId === address.id"
|
||||
@click="deleteAddress(address.id)"
|
||||
>
|
||||
{{ addressBusyId === address.id ? 'Удаляем…' : 'Удалить' }}
|
||||
{{ addressBusyId === address.id ? 'Удаляем...' : 'Удалить' }}
|
||||
</button>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
</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 },
|
||||
);
|
||||
|
||||
const profileUpdatedAt = computed(() => profileQuery.result.value?.myCounterpartyProfile?.updatedAt ?? null);
|
||||
const profileIsComplete = computed(() => isCounterpartyProfileComplete(counterpartyForm));
|
||||
|
||||
function clearPartyTimer() {
|
||||
@@ -255,179 +254,210 @@ onBeforeUnmount(() => {
|
||||
<template>
|
||||
<section class="space-y-6">
|
||||
<NuxtLink to="/profile" class="link link-hover text-sm">← Назад в профиль</NuxtLink>
|
||||
<h1 class="text-3xl font-extrabold text-[#0f2f20]">Карточка контрагента</h1>
|
||||
|
||||
<div class="surface-card rounded-3xl p-5">
|
||||
<p class="text-sm text-[#355947]">
|
||||
Заполните реквизиты, чтобы оформить заявки и получить полный функционал личного кабинета.
|
||||
<div class="space-y-2">
|
||||
<h1 class="text-3xl font-extrabold text-[#0f2f20]">Карточка контрагента</h1>
|
||||
<p class="text-sm leading-6 text-[#466653]">
|
||||
Заполните реквизиты компании, банка, подписанта и основания.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 space-y-4">
|
||||
<section>
|
||||
<h2 class="mb-3 text-base font-bold">1. Контрагент (DaData)</h2>
|
||||
<div class="space-y-4">
|
||||
<section class="surface-card rounded-3xl p-5 md:p-6">
|
||||
<div class="mb-5 space-y-1">
|
||||
<h2 class="text-xl font-black tracking-[-0.02em] text-[#123824]">Данные компании</h2>
|
||||
<p class="text-sm leading-6 text-[#5c7b69]">
|
||||
Найдите компанию через DaData или заполните реквизиты вручную.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-4">
|
||||
<div ref="partyDropdownRef" class="relative">
|
||||
<fieldset class="fieldset">
|
||||
<legend class="fieldset-legend">Поиск компании</legend>
|
||||
<input
|
||||
v-model="companySearch"
|
||||
type="text"
|
||||
class="input w-full"
|
||||
placeholder="Введите название или ИНН"
|
||||
@input="schedulePartySuggest"
|
||||
@focus="partyOpen = partySuggestions.length > 0"
|
||||
>
|
||||
</fieldset>
|
||||
|
||||
<span v-if="partyLoading" class="loading loading-spinner loading-sm absolute right-3 top-1/2 -translate-y-1/2" />
|
||||
<label class="block space-y-2">
|
||||
<span class="text-sm font-semibold text-[#355947]">Поиск компании</span>
|
||||
<div class="relative">
|
||||
<input
|
||||
v-model="companySearch"
|
||||
type="text"
|
||||
class="input manager-field w-full pr-11"
|
||||
placeholder="Введите название компании или ИНН"
|
||||
@input="schedulePartySuggest"
|
||||
@focus="partyOpen = partySuggestions.length > 0"
|
||||
>
|
||||
<span v-if="partyLoading" class="loading loading-spinner loading-sm absolute right-4 top-1/2 -translate-y-1/2 text-[#5c7b69]" />
|
||||
</div>
|
||||
</label>
|
||||
|
||||
<div
|
||||
v-if="partyOpen && partySuggestions.length > 0"
|
||||
class="absolute z-30 mt-2 max-h-72 w-full overflow-auto rounded-box bg-base-100 p-2"
|
||||
class="absolute z-30 mt-2 max-h-72 w-full overflow-auto rounded-[1.4rem] border border-[#e1ebe4] bg-white p-2 shadow-[0_24px_48px_rgba(18,56,36,0.12)]"
|
||||
>
|
||||
<button
|
||||
v-for="item in partySuggestions"
|
||||
:key="`${item.value}-${item.data?.inn || ''}`"
|
||||
type="button"
|
||||
class="btn btn-ghost mb-1 h-auto min-h-0 w-full justify-start whitespace-normal px-3 py-2 text-left"
|
||||
class="mb-1 w-full rounded-2xl px-4 py-3 text-left transition hover:bg-[#f5faf7]"
|
||||
@click="applyPartySuggestion(item)"
|
||||
>
|
||||
<span>
|
||||
<span class="block text-sm font-semibold">{{ item.value }}</span>
|
||||
<span class="block text-xs opacity-70">ИНН: {{ item.data?.inn || '—' }} <span v-if="item.data?.kpp">• КПП: {{ item.data.kpp }}</span></span>
|
||||
<span class="block text-sm font-semibold text-[#123824]">{{ item.value }}</span>
|
||||
<span class="mt-1 block text-xs text-[#5c7b69]">
|
||||
ИНН: {{ item.data?.inn || '—' }}<span v-if="item.data?.kpp"> • КПП: {{ item.data.kpp }}</span>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<fieldset class="fieldset">
|
||||
<legend class="fieldset-legend">Краткое наименование</legend>
|
||||
<input v-model="counterpartyForm.companyName" type="text" class="input w-full" >
|
||||
</fieldset>
|
||||
<div class="grid gap-4 md:grid-cols-2">
|
||||
<label class="block space-y-2">
|
||||
<span class="text-sm font-semibold text-[#355947]">Краткое наименование</span>
|
||||
<input v-model="counterpartyForm.companyName" type="text" class="input manager-field w-full">
|
||||
</label>
|
||||
|
||||
<fieldset class="fieldset">
|
||||
<legend class="fieldset-legend">Полное наименование</legend>
|
||||
<input v-model="counterpartyForm.companyFullName" type="text" class="input w-full" >
|
||||
</fieldset>
|
||||
|
||||
<div class="grid gap-3 sm:grid-cols-3">
|
||||
<fieldset class="fieldset">
|
||||
<legend class="fieldset-legend">ИНН</legend>
|
||||
<input v-model="counterpartyForm.inn" type="text" class="input w-full" >
|
||||
</fieldset>
|
||||
|
||||
<fieldset class="fieldset">
|
||||
<legend class="fieldset-legend">КПП</legend>
|
||||
<input v-model="counterpartyForm.kpp" type="text" class="input w-full" >
|
||||
</fieldset>
|
||||
|
||||
<fieldset class="fieldset">
|
||||
<legend class="fieldset-legend">ОГРН</legend>
|
||||
<input v-model="counterpartyForm.ogrn" type="text" class="input w-full" >
|
||||
</fieldset>
|
||||
<label class="block space-y-2">
|
||||
<span class="text-sm font-semibold text-[#355947]">Полное наименование</span>
|
||||
<input v-model="counterpartyForm.companyFullName" type="text" class="input manager-field w-full">
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<fieldset class="fieldset">
|
||||
<legend class="fieldset-legend">Юридический адрес</legend>
|
||||
<textarea v-model="counterpartyForm.legalAddress" class="textarea min-h-24 w-full" />
|
||||
</fieldset>
|
||||
</section>
|
||||
<div class="grid gap-4 md:grid-cols-3">
|
||||
<label class="block space-y-2">
|
||||
<span class="text-sm font-semibold text-[#355947]">ИНН</span>
|
||||
<input v-model="counterpartyForm.inn" type="text" class="input manager-field w-full">
|
||||
</label>
|
||||
|
||||
<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>
|
||||
<h2 class="mb-3 text-base font-bold">2. Банк (DaData)</h2>
|
||||
<label class="block space-y-2">
|
||||
<span class="text-sm font-semibold text-[#355947]">ОГРН</span>
|
||||
<input v-model="counterpartyForm.ogrn" type="text" class="input manager-field w-full">
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<label class="block space-y-2">
|
||||
<span class="text-sm font-semibold text-[#355947]">Юридический адрес</span>
|
||||
<textarea v-model="counterpartyForm.legalAddress" class="textarea manager-field min-h-28 w-full" />
|
||||
</label>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="surface-card rounded-3xl p-5 md:p-6">
|
||||
<div class="mb-5">
|
||||
<h2 class="text-xl font-black tracking-[-0.02em] text-[#123824]">Банковские реквизиты</h2>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-4">
|
||||
<div ref="bankDropdownRef" class="relative">
|
||||
<fieldset class="fieldset">
|
||||
<legend class="fieldset-legend">Поиск банка</legend>
|
||||
<input
|
||||
v-model="bankSearch"
|
||||
type="text"
|
||||
class="input w-full"
|
||||
placeholder="Введите название банка"
|
||||
@input="scheduleBankSuggest"
|
||||
@focus="bankOpen = bankSuggestions.length > 0"
|
||||
>
|
||||
</fieldset>
|
||||
|
||||
<span v-if="bankLoading" class="loading loading-spinner loading-sm absolute right-3 top-1/2 -translate-y-1/2" />
|
||||
<label class="block space-y-2">
|
||||
<span class="text-sm font-semibold text-[#355947]">Поиск банка</span>
|
||||
<div class="relative">
|
||||
<input
|
||||
v-model="bankSearch"
|
||||
type="text"
|
||||
class="input manager-field w-full pr-11"
|
||||
placeholder="Введите название банка"
|
||||
@input="scheduleBankSuggest"
|
||||
@focus="bankOpen = bankSuggestions.length > 0"
|
||||
>
|
||||
<span v-if="bankLoading" class="loading loading-spinner loading-sm absolute right-4 top-1/2 -translate-y-1/2 text-[#5c7b69]" />
|
||||
</div>
|
||||
</label>
|
||||
|
||||
<div
|
||||
v-if="bankOpen && bankSuggestions.length > 0"
|
||||
class="absolute z-30 mt-2 max-h-72 w-full overflow-auto rounded-box bg-base-100 p-2"
|
||||
class="absolute z-30 mt-2 max-h-72 w-full overflow-auto rounded-[1.4rem] border border-[#e1ebe4] bg-white p-2 shadow-[0_24px_48px_rgba(18,56,36,0.12)]"
|
||||
>
|
||||
<button
|
||||
v-for="item in bankSuggestions"
|
||||
:key="`${item.value}-${item.data?.bic || ''}`"
|
||||
type="button"
|
||||
class="btn btn-ghost mb-1 h-auto min-h-0 w-full justify-start whitespace-normal px-3 py-2 text-left"
|
||||
class="mb-1 w-full rounded-2xl px-4 py-3 text-left transition hover:bg-[#f5faf7]"
|
||||
@click="applyBankSuggestion(item)"
|
||||
>
|
||||
<span>
|
||||
<span class="block text-sm font-semibold">{{ item.value }}</span>
|
||||
<span class="block text-xs opacity-70">БИК: {{ item.data?.bic || '—' }}</span>
|
||||
</span>
|
||||
<span class="block text-sm font-semibold text-[#123824]">{{ item.value }}</span>
|
||||
<span class="mt-1 block text-xs text-[#5c7b69]">БИК: {{ item.data?.bic || '—' }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<fieldset class="fieldset">
|
||||
<legend class="fieldset-legend">Банк</legend>
|
||||
<input v-model="counterpartyForm.bankName" type="text" class="input w-full" >
|
||||
</fieldset>
|
||||
<div class="grid gap-4 md:grid-cols-2">
|
||||
<label class="block space-y-2">
|
||||
<span class="text-sm font-semibold text-[#355947]">Банк</span>
|
||||
<input v-model="counterpartyForm.bankName" type="text" class="input manager-field w-full">
|
||||
</label>
|
||||
|
||||
<div class="grid gap-3 sm:grid-cols-2">
|
||||
<fieldset class="fieldset">
|
||||
<legend class="fieldset-legend">БИК</legend>
|
||||
<input v-model="counterpartyForm.bik" type="text" class="input w-full" >
|
||||
</fieldset>
|
||||
|
||||
<fieldset class="fieldset">
|
||||
<legend class="fieldset-legend">Корр. счет</legend>
|
||||
<input v-model="counterpartyForm.correspondentAccount" type="text" class="input w-full" >
|
||||
</fieldset>
|
||||
<label class="block space-y-2">
|
||||
<span class="text-sm font-semibold text-[#355947]">БИК</span>
|
||||
<input v-model="counterpartyForm.bik" type="text" class="input manager-field w-full">
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<fieldset class="fieldset">
|
||||
<legend class="fieldset-legend">Расчетный счет</legend>
|
||||
<input v-model="counterpartyForm.checkingAccount" type="text" class="input w-full" >
|
||||
</fieldset>
|
||||
</section>
|
||||
<div class="grid gap-4 md:grid-cols-2">
|
||||
<label class="block space-y-2">
|
||||
<span class="text-sm font-semibold text-[#355947]">Корреспондентский счет</span>
|
||||
<input v-model="counterpartyForm.correspondentAccount" type="text" class="input manager-field w-full">
|
||||
</label>
|
||||
|
||||
<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>
|
||||
<h2 class="mb-3 text-base font-bold">3. Подписант и основание</h2>
|
||||
<section class="surface-card rounded-3xl p-5 md:p-6">
|
||||
<div class="mb-5">
|
||||
<h2 class="text-xl font-black tracking-[-0.02em] text-[#123824]">Подписанты и основания</h2>
|
||||
</div>
|
||||
|
||||
<fieldset class="fieldset">
|
||||
<legend class="fieldset-legend">ФИО подписанта</legend>
|
||||
<input v-model="counterpartyForm.signerFullName" type="text" class="input w-full" placeholder="Иванов Иван Иванович" >
|
||||
</fieldset>
|
||||
<div class="grid gap-4">
|
||||
<div class="grid gap-4 md:grid-cols-2">
|
||||
<label class="block space-y-2">
|
||||
<span class="text-sm font-semibold text-[#355947]">ФИО подписанта</span>
|
||||
<input
|
||||
v-model="counterpartyForm.signerFullName"
|
||||
type="text"
|
||||
class="input manager-field w-full"
|
||||
placeholder="Иванов Иван Иванович"
|
||||
>
|
||||
</label>
|
||||
|
||||
<fieldset class="fieldset">
|
||||
<legend class="fieldset-legend">Должность</legend>
|
||||
<input v-model="counterpartyForm.signerPosition" type="text" class="input w-full" placeholder="Генеральный директор" >
|
||||
</fieldset>
|
||||
<label class="block space-y-2">
|
||||
<span class="text-sm font-semibold text-[#355947]">Должность</span>
|
||||
<input
|
||||
v-model="counterpartyForm.signerPosition"
|
||||
type="text"
|
||||
class="input manager-field w-full"
|
||||
placeholder="Генеральный директор"
|
||||
>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<fieldset class="fieldset">
|
||||
<legend class="fieldset-legend">Основание полномочий</legend>
|
||||
<label class="block space-y-2">
|
||||
<span class="text-sm font-semibold text-[#355947]">Основание полномочий</span>
|
||||
<textarea
|
||||
v-model="counterpartyForm.signerBasis"
|
||||
class="textarea min-h-24 w-full"
|
||||
class="textarea manager-field min-h-28 w-full"
|
||||
placeholder="Действует на основании Устава"
|
||||
/>
|
||||
</fieldset>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<button class="btn btn-primary mt-4 w-full" :disabled="saveCounterpartyMutation.loading.value || !profileIsComplete" @click="saveCounterpartyProfile">
|
||||
{{ saveCounterpartyMutation.loading.value ? 'Сохраняем…' : 'Сохранить' }}
|
||||
<div class="mt-6 border-t border-[#edf2ee] pt-5">
|
||||
<button
|
||||
class="btn h-12 w-full rounded-full border-0 bg-[#123824] px-7 text-white shadow-[0_18px_34px_rgba(18,56,36,0.18)] hover:bg-[#0f2f20] disabled:border-0 disabled:bg-[#cfd8d2] disabled:text-[#6f8579] md:w-auto"
|
||||
:disabled="saveCounterpartyMutation.loading.value || !profileIsComplete"
|
||||
@click="saveCounterpartyProfile"
|
||||
>
|
||||
{{ saveCounterpartyMutation.loading.value ? 'Сохраняем...' : 'Сохранить' }}
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<p v-if="profileUpdatedAt" class="mt-2 text-xs opacity-70">Обновлено: {{ new Date(profileUpdatedAt).toLocaleString() }}</p>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div v-if="profileFeedback" class="alert mt-4" :class="profileFeedbackTone === 'success' ? 'alert-success' : 'alert-error'">
|
||||
<div v-if="profileFeedback" class="alert" :class="profileFeedbackTone === 'success' ? 'alert-success' : 'alert-error'">
|
||||
{{ profileFeedback }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { useQuery } from '@vue/apollo-composable';
|
||||
import { useMutation, useQuery } from '@vue/apollo-composable';
|
||||
import {
|
||||
DeleteMyMessengerConnectionDocument,
|
||||
MeDocument,
|
||||
MyMessengerConnectionsDocument,
|
||||
} from '~/composables/graphql/generated';
|
||||
@@ -12,9 +13,11 @@ import {
|
||||
} from '~/composables/useMessengerConnectionPresentation';
|
||||
import { useMessengerStart } from '~/composables/useMessengerStart';
|
||||
|
||||
type MessengerChannel = 'TELEGRAM' | 'MAX';
|
||||
|
||||
type MessengerItem = {
|
||||
id: string;
|
||||
type: 'TELEGRAM' | 'MAX';
|
||||
type: MessengerChannel;
|
||||
isActive: boolean;
|
||||
channelId: string;
|
||||
displayName?: string | null;
|
||||
@@ -22,23 +25,50 @@ type MessengerItem = {
|
||||
avatarAvailable?: boolean | null;
|
||||
};
|
||||
|
||||
type MessengerOption = {
|
||||
channel: MessengerChannel;
|
||||
label: string;
|
||||
buttonClass: string;
|
||||
iconClass: string;
|
||||
unavailableText: string;
|
||||
};
|
||||
|
||||
const config = useRuntimeConfig();
|
||||
const feedback = ref('');
|
||||
const meQuery = useQuery(MeDocument);
|
||||
const connectionsQuery = useQuery(MyMessengerConnectionsDocument);
|
||||
const deleteConnectionMutation = useMutation(DeleteMyMessengerConnectionDocument);
|
||||
const { openMessengerBot, pendingChannel } = useMessengerStart();
|
||||
|
||||
const telegramConnection = computed(() =>
|
||||
connectionsQuery.result.value?.myMessengerConnections?.find(
|
||||
(item: MessengerItem) => item.type === 'TELEGRAM' && item.isActive,
|
||||
),
|
||||
) ?? null,
|
||||
);
|
||||
|
||||
const maxConnection = computed(() =>
|
||||
connectionsQuery.result.value?.myMessengerConnections?.find(
|
||||
(item: MessengerItem) => item.type === 'MAX' && item.isActive,
|
||||
),
|
||||
) ?? null,
|
||||
);
|
||||
|
||||
const messengerOptions: MessengerOption[] = [
|
||||
{
|
||||
channel: 'TELEGRAM',
|
||||
label: 'Telegram',
|
||||
buttonClass: 'bg-[#1a9c63] text-white hover:bg-[#148553]',
|
||||
iconClass: 'bg-[#123824] text-white',
|
||||
unavailableText: 'Telegram пока не настроен в окружении фронта.',
|
||||
},
|
||||
{
|
||||
channel: 'MAX',
|
||||
label: 'MAX',
|
||||
buttonClass: 'bg-[#2b7fff] text-white hover:bg-[#1d6df1]',
|
||||
iconClass: 'bg-[#2b7fff] text-white',
|
||||
unavailableText: 'MAX пока не настроен в окружении фронта.',
|
||||
},
|
||||
];
|
||||
|
||||
function buildBotConnectUrl(baseUrl: string) {
|
||||
const accountEmail = meQuery.result.value?.me?.email?.trim().toLowerCase();
|
||||
if (!accountEmail || !baseUrl) {
|
||||
@@ -51,9 +81,32 @@ function buildBotConnectUrl(baseUrl: string) {
|
||||
const telegramConnectUrl = computed(() => buildBotConnectUrl(config.public.telegramBotUrl || ''));
|
||||
const maxConnectUrl = computed(() => buildBotConnectUrl(config.public.maxBotUrl || ''));
|
||||
|
||||
async function connectMessenger(channel: 'TELEGRAM' | 'MAX') {
|
||||
const baseUrl = channel === 'TELEGRAM' ? telegramConnectUrl.value : maxConnectUrl.value;
|
||||
function connectUrl(channel: MessengerChannel) {
|
||||
return channel === 'TELEGRAM' ? telegramConnectUrl.value : maxConnectUrl.value;
|
||||
}
|
||||
|
||||
function connectionFor(channel: MessengerChannel) {
|
||||
return channel === 'TELEGRAM' ? telegramConnection.value : maxConnection.value;
|
||||
}
|
||||
|
||||
const activeConnections = computed(() => messengerOptions
|
||||
.map((option) => ({
|
||||
option,
|
||||
connection: connectionFor(option.channel),
|
||||
}))
|
||||
.filter((item) => Boolean(item.connection)));
|
||||
|
||||
const availableOptions = computed(() => messengerOptions
|
||||
.filter((option) => !connectionFor(option.channel)));
|
||||
|
||||
async function connectMessenger(channel: MessengerChannel) {
|
||||
feedback.value = '';
|
||||
const baseUrl = connectUrl(channel);
|
||||
|
||||
if (!baseUrl) {
|
||||
feedback.value = channel === 'MAX'
|
||||
? 'MAX не откроется, пока не задан NUXT_PUBLIC_MAX_BOT_URL.'
|
||||
: 'Telegram не откроется, пока не задан NUXT_PUBLIC_TELEGRAM_BOT_URL.';
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -63,91 +116,123 @@ async function connectMessenger(channel: 'TELEGRAM' | 'MAX') {
|
||||
redirectPath: `/profile/notifications/success?connected=${channel.toLowerCase()}`,
|
||||
});
|
||||
}
|
||||
|
||||
async function removeConnection(connectionId: string) {
|
||||
feedback.value = '';
|
||||
const result = await deleteConnectionMutation.mutate({
|
||||
connectionId,
|
||||
});
|
||||
|
||||
if (!result?.data?.deleteMyMessengerConnection) {
|
||||
feedback.value = 'Не удалось отключить аккаунт. Попробуйте еще раз.';
|
||||
return;
|
||||
}
|
||||
|
||||
await connectionsQuery.refetch();
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="space-y-6">
|
||||
<NuxtLink to="/profile" class="link link-hover text-sm">← Назад в профиль</NuxtLink>
|
||||
<h1 class="text-3xl font-extrabold text-[#0f2f20]">Уведомления</h1>
|
||||
<UiBackHeader
|
||||
to="/profile"
|
||||
back-label="Назад в профиль"
|
||||
title="Уведомления"
|
||||
subtitle="Подключите мессенджер, чтобы получать уведомления по заказам."
|
||||
/>
|
||||
|
||||
<div class="surface-card rounded-3xl p-5">
|
||||
<p class="text-sm text-[#355947]">
|
||||
Подключите Telegram и Max, чтобы получать статусы заказов и важные уведомления в удобном канале.
|
||||
</p>
|
||||
<div
|
||||
v-if="feedback"
|
||||
class="rounded-[24px] border px-4 py-3 text-sm font-medium"
|
||||
:class="feedback.includes('Не удалось') || feedback.includes('не откроется')
|
||||
? 'border-[#f1d1c7] bg-[#fff3ef] text-[#9d4426]'
|
||||
: 'border-[#cbe9d6] bg-[#f1fbf5] text-[#1c6b45]'"
|
||||
>
|
||||
{{ feedback }}
|
||||
</div>
|
||||
|
||||
<div class="mt-4 space-y-3">
|
||||
<div class="rounded-2xl bg-[#f8fbf9] p-4 transition hover:shadow-md">
|
||||
<p class="font-semibold">Telegram</p>
|
||||
<div v-if="telegramConnection" class="mt-3 flex items-center gap-3 rounded-2xl bg-white px-3 py-2">
|
||||
<div v-if="messengerConnectionAvatarSrc(telegramConnection)" class="avatar">
|
||||
<div class="h-11 w-11 rounded-full">
|
||||
<img :src="messengerConnectionAvatarSrc(telegramConnection)" :alt="messengerConnectionName(telegramConnection)">
|
||||
<div
|
||||
v-if="activeConnections.length > 0"
|
||||
class="space-y-4"
|
||||
>
|
||||
<article
|
||||
v-for="{ option, connection } in activeConnections"
|
||||
:key="connection!.id"
|
||||
class="rounded-[28px] bg-white px-5 py-4 shadow-[0_18px_38px_rgba(18,56,36,0.08)]"
|
||||
>
|
||||
<div class="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
|
||||
<div class="flex min-w-0 items-center gap-4">
|
||||
<div v-if="messengerConnectionAvatarSrc(connection)" class="avatar">
|
||||
<div class="h-14 w-14 rounded-[20px]">
|
||||
<img :src="messengerConnectionAvatarSrc(connection)" :alt="messengerConnectionName(connection)">
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="avatar placeholder">
|
||||
<div class="h-11 w-11 rounded-full bg-[#123824] text-sm font-bold text-white">
|
||||
<span>{{ messengerConnectionInitials(telegramConnection, 'TG') }}</span>
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
class="flex h-14 w-14 items-center justify-center rounded-[20px] text-sm font-black"
|
||||
:class="option.iconClass"
|
||||
>
|
||||
{{ messengerConnectionInitials(connection, option.channel === 'TELEGRAM' ? 'TG' : 'MX') }}
|
||||
</div>
|
||||
|
||||
<div class="min-w-0">
|
||||
<p class="truncate text-sm font-semibold text-[#123824]">
|
||||
{{ messengerConnectionName(telegramConnection) }}
|
||||
</p>
|
||||
<p class="truncate text-xs text-[#5c7b69]">
|
||||
{{ messengerConnectionHandle(telegramConnection) || 'Подключен' }}
|
||||
</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>
|
||||
<p v-else class="text-sm opacity-80">Не подключен</p>
|
||||
<button
|
||||
class="btn btn-secondary mt-3 w-full"
|
||||
:class="{ 'btn-disabled pointer-events-none': !telegramConnectUrl }"
|
||||
:disabled="pendingChannel === 'TELEGRAM' || !telegramConnectUrl"
|
||||
@click="connectMessenger('TELEGRAM')"
|
||||
>
|
||||
{{
|
||||
pendingChannel === 'TELEGRAM'
|
||||
? 'Открываем Telegram…'
|
||||
: telegramConnection
|
||||
? 'Переподключить Telegram'
|
||||
: 'Подключить Telegram'
|
||||
}}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="rounded-2xl bg-[#f8fbf9] p-4 transition hover:shadow-md">
|
||||
<p class="font-semibold">Max</p>
|
||||
<div v-if="maxConnection" class="mt-3 flex items-center gap-3 rounded-2xl bg-white px-3 py-2">
|
||||
<div class="avatar placeholder">
|
||||
<div class="h-11 w-11 rounded-full bg-[#2b7fff] text-sm font-bold text-white">
|
||||
<span>{{ messengerConnectionInitials(maxConnection, 'MX') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="min-w-0">
|
||||
<p class="truncate text-sm font-semibold text-[#123824]">
|
||||
{{ messengerConnectionName(maxConnection) }}
|
||||
</p>
|
||||
<p class="truncate text-xs text-[#5c7b69]">
|
||||
{{ messengerConnectionHandle(maxConnection) || 'Подключен' }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<p v-else class="text-sm opacity-80">Не подключен</p>
|
||||
<button
|
||||
class="btn btn-accent mt-3 w-full"
|
||||
:class="{ 'btn-disabled pointer-events-none': !maxConnectUrl }"
|
||||
:disabled="pendingChannel === 'MAX' || !maxConnectUrl"
|
||||
@click="connectMessenger('MAX')"
|
||||
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)"
|
||||
>
|
||||
{{
|
||||
pendingChannel === 'MAX'
|
||||
? 'Открываем Max…'
|
||||
: maxConnection
|
||||
? 'Переподключить Max'
|
||||
: 'Подключить Max'
|
||||
}}
|
||||
{{ 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
|
||||
v-for="option in availableOptions.filter((item) => !connectUrl(item.channel))"
|
||||
:key="`${option.channel}-hint`"
|
||||
class="text-sm text-[#8b5a49]"
|
||||
>
|
||||
{{ option.unavailableText }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -48,12 +48,12 @@ const successConnection = computed(() =>
|
||||
|
||||
const profileName = computed(() => meQuery.result.value?.me?.fullName?.trim() || meQuery.result.value?.me?.email || 'Пользователь');
|
||||
const successTitle = computed(() =>
|
||||
connectedChannel.value === 'telegram' ? 'Telegram успешно подключен' : 'Канал успешно подключен',
|
||||
connectedChannel.value === 'telegram' ? 'Telegram успешно подключен' : 'MAX успешно подключен',
|
||||
);
|
||||
const successText = computed(() =>
|
||||
connectedChannel.value === 'telegram'
|
||||
? 'Теперь этот Telegram привязан к вашему личному кабинету. Все важные уведомления и статусы заказов будут приходить сюда.'
|
||||
: 'Канал успешно привязан к вашему личному кабинету.',
|
||||
: 'Теперь этот MAX привязан к вашему личному кабинету. Все важные уведомления и статусы заказов будут приходить сюда.',
|
||||
);
|
||||
const successAvatarSrc = computed(() => messengerConnectionAvatarSrc(successConnection.value));
|
||||
const successAvatarInitials = computed(() =>
|
||||
|
||||
117
app/pages/settings-sync.vue
Normal file
@@ -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>
|
||||
8
app/plugins/max-mini-app.client.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
export default defineNuxtPlugin(() => {
|
||||
const { webApp, isAvailable } = useMaxMiniApp();
|
||||
if (!isAvailable.value || !webApp.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
webApp.value.ready?.();
|
||||
});
|
||||
9
app/plugins/telegram-mini-app.client.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
export default defineNuxtPlugin(() => {
|
||||
const { webApp, isAvailable } = useTelegramMiniApp();
|
||||
if (!isAvailable.value || !webApp.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
webApp.value.ready?.();
|
||||
webApp.value.expand?.();
|
||||
});
|
||||
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
graphql/operations/bonus/request-reward-withdrawal.graphql
Normal file
@@ -0,0 +1,10 @@
|
||||
mutation RequestRewardWithdrawal($input: RequestRewardWithdrawalInput!) {
|
||||
requestRewardWithdrawal(input: $input) {
|
||||
id
|
||||
amount
|
||||
status
|
||||
createdAt
|
||||
updatedAt
|
||||
reviewComment
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,7 @@ query ClientProducts {
|
||||
thicknessMicron
|
||||
sleeveBrand
|
||||
quantityPerBox
|
||||
tags
|
||||
isCustomizable
|
||||
availableInWarehouses {
|
||||
availableQty
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
mutation BlockOrder($input: BlockOrderInput!) {
|
||||
blockOrder(input: $input) {
|
||||
id
|
||||
status
|
||||
blockReason
|
||||
}
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
mutation CompleteOrder($orderId: ID!) {
|
||||
completeOrder(orderId: $orderId) {
|
||||
id
|
||||
status
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,8 @@ mutation CreateReferral($input: CreateReferralInput!) {
|
||||
id
|
||||
referrerId
|
||||
refereeId
|
||||
createdById
|
||||
bonusPercent
|
||||
createdAt
|
||||
}
|
||||
}
|
||||
|
||||
44
graphql/operations/manager/manager-bonus-account.graphql
Normal file
@@ -0,0 +1,44 @@
|
||||
query ManagerBonusAccount($userId: ID!) {
|
||||
managerBonusAccount(userId: $userId) {
|
||||
userId
|
||||
email
|
||||
fullName
|
||||
companyName
|
||||
balance
|
||||
earnedAmount
|
||||
pendingWithdrawalAmount
|
||||
transactionsCount
|
||||
referralsCount
|
||||
referralLinks {
|
||||
id
|
||||
referrerId
|
||||
referrerName
|
||||
referrerEmail
|
||||
referrerCompanyName
|
||||
refereeId
|
||||
refereeName
|
||||
refereeEmail
|
||||
refereeCompanyName
|
||||
createdById
|
||||
bonusPercent
|
||||
createdAt
|
||||
}
|
||||
transactions {
|
||||
id
|
||||
userId
|
||||
amount
|
||||
reason
|
||||
orderId
|
||||
createdAt
|
||||
}
|
||||
pendingWithdrawals {
|
||||
id
|
||||
requesterId
|
||||
amount
|
||||
status
|
||||
reviewComment
|
||||
createdAt
|
||||
updatedAt
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
mutation ManagerFinalizeOrder($orderId: ID!, $decision: Decision!) {
|
||||
managerFinalizeOrder(orderId: $orderId, decision: $decision) {
|
||||
id
|
||||
status
|
||||
managerApproved
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
query ManagerOrders($status: OrderStatus) {
|
||||
managerOrders(status: $status) {
|
||||
query ManagerOrders($status: OrderStatus, $customerId: ID) {
|
||||
managerOrders(status: $status, customerId: $customerId) {
|
||||
id
|
||||
code
|
||||
status
|
||||
@@ -14,6 +14,8 @@ query ManagerOrders($status: OrderStatus) {
|
||||
id
|
||||
productName
|
||||
quantity
|
||||
unitPrice
|
||||
lineTotal
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
16
graphql/operations/manager/manager-referral-links.graphql
Normal file
@@ -0,0 +1,16 @@
|
||||
query ManagerReferralLinks {
|
||||
managerReferralLinks {
|
||||
id
|
||||
referrerId
|
||||
referrerName
|
||||
referrerEmail
|
||||
referrerCompanyName
|
||||
refereeId
|
||||
refereeName
|
||||
refereeEmail
|
||||
refereeCompanyName
|
||||
createdById
|
||||
bonusPercent
|
||||
createdAt
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
mutation ManagerSetOrderStatus($orderId: ID!, $status: OrderStatus!) {
|
||||
managerSetOrderStatus(orderId: $orderId, status: $status) {
|
||||
id
|
||||
status
|
||||
}
|
||||
}
|
||||
20
graphql/operations/manager/manager-users-detail.graphql
Normal file
@@ -0,0 +1,20 @@
|
||||
query ManagerUsersDetail {
|
||||
managerUsers {
|
||||
id
|
||||
email
|
||||
fullName
|
||||
companyName
|
||||
inn
|
||||
createdAt
|
||||
orderCount
|
||||
lastOrderAt
|
||||
telegramConnection {
|
||||
id
|
||||
type
|
||||
channelId
|
||||
displayName
|
||||
username
|
||||
avatarAvailable
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,10 @@
|
||||
query ManagerUsers {
|
||||
managerUsers {
|
||||
id
|
||||
email
|
||||
fullName
|
||||
role
|
||||
companyName
|
||||
telegramConnection {
|
||||
id
|
||||
type
|
||||
|
||||
@@ -4,6 +4,12 @@ mutation ManagerSetOrderOffer($input: SetOrderOfferInput!) {
|
||||
code
|
||||
status
|
||||
deliveryTerms
|
||||
deliveryFee
|
||||
totalPrice
|
||||
items {
|
||||
id
|
||||
unitPrice
|
||||
lineTotal
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
mutation StartOrderWork($orderId: ID!) {
|
||||
startOrderWork(orderId: $orderId) {
|
||||
id
|
||||
status
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
query NotificationTemplates {
|
||||
notificationTemplates {
|
||||
id
|
||||
title
|
||||
channels {
|
||||
channel
|
||||
implemented
|
||||
subject
|
||||
body
|
||||
buttonText
|
||||
buttonUrl
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -7,11 +7,14 @@ query MyOrders {
|
||||
deliveryAddress
|
||||
totalPrice
|
||||
deliveryTerms
|
||||
deliveryFee
|
||||
createdAt
|
||||
items {
|
||||
id
|
||||
productName
|
||||
quantity
|
||||
unitPrice
|
||||
lineTotal
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
22
graphql/operations/orders/order-detail.graphql
Normal file
@@ -0,0 +1,22 @@
|
||||
query OrderDetail($id: ID!) {
|
||||
order(id: $id) {
|
||||
id
|
||||
code
|
||||
kind
|
||||
status
|
||||
customerId
|
||||
deliveryAddress
|
||||
deliveryTerms
|
||||
deliveryFee
|
||||
totalPrice
|
||||
calculationPayload
|
||||
createdAt
|
||||
items {
|
||||
id
|
||||
productName
|
||||
quantity
|
||||
unitPrice
|
||||
lineTotal
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
mutation DeleteMyMessengerConnection($connectionId: ID!) {
|
||||
deleteMyMessengerConnection(connectionId: $connectionId)
|
||||
}
|
||||