Build Nuxt 4 manager cabinet workflows

This commit is contained in:
Ruslan Bakiev
2026-03-30 21:41:28 +07:00
parent c0c0842427
commit 325e004aac
50 changed files with 15082 additions and 1 deletions

24
.gitignore vendored Normal file
View File

@@ -0,0 +1,24 @@
# Nuxt dev/build outputs
.output
.data
.nuxt
.nitro
.cache
dist
# Node dependencies
node_modules
# Logs
logs
*.log
# Misc
.DS_Store
.fleet
.idea
# Local env files
.env
.env.*
!.env.example

12
.storybook/main.ts Normal file
View File

@@ -0,0 +1,12 @@
import type { StorybookConfig } from '@storybook/vue3-vite';
const config: StorybookConfig = {
stories: ['../app/**/*.stories.@(ts|tsx)'],
addons: ['@storybook/addon-essentials'],
framework: {
name: '@storybook/vue3-vite',
options: {},
},
};
export default config;

15
.storybook/preview.ts Normal file
View File

@@ -0,0 +1,15 @@
import type { Preview } from '@storybook/vue3-vite';
import '../app/assets/css/main.css';
const preview: Preview = {
parameters: {
controls: {
matchers: {
color: /(background|color)$/i,
date: /Date$/i,
},
},
},
};
export default preview;

View File

@@ -1,2 +1,75 @@
# manager-frontend
# Nuxt Minimal Starter
Look at the [Nuxt documentation](https://nuxt.com/docs/getting-started/introduction) to learn more.
## Setup
Make sure to install dependencies:
```bash
# npm
npm install
# pnpm
pnpm install
# yarn
yarn install
# bun
bun install
```
## Development Server
Start the development server on `http://localhost:3000`:
```bash
# npm
npm run dev
# pnpm
pnpm dev
# yarn
yarn dev
# bun
bun run dev
```
## Production
Build the application for production:
```bash
# npm
npm run build
# pnpm
pnpm build
# yarn
yarn build
# bun
bun run build
```
Locally preview production build:
```bash
# npm
npm run preview
# pnpm
pnpm preview
# yarn
yarn preview
# bun
bun run preview
```
Check out the [deployment documentation](https://nuxt.com/docs/getting-started/deployment) for more information.

8
app/app.vue Normal file
View File

@@ -0,0 +1,8 @@
<template>
<div>
<AppHeader />
<main class="container mx-auto p-4 md:p-6">
<NuxtPage />
</main>
</div>
</template>

12
app/assets/css/main.css Normal file
View File

@@ -0,0 +1,12 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
:root {
--brand-primary: #0f766e;
--brand-secondary: #1d4ed8;
}
body {
@apply bg-base-200 text-base-content;
}

View File

@@ -0,0 +1,19 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite';
import OrderStatusBadge from './OrderStatusBadge.vue';
const meta: Meta<typeof OrderStatusBadge> = {
title: 'Orders/OrderStatusBadge',
component: OrderStatusBadge,
};
export default meta;
type Story = StoryObj<typeof OrderStatusBadge>;
export const InProgress: Story = {
args: { status: 'IN_PROGRESS' },
};
export const Completed: Story = {
args: { status: 'COMPLETED' },
};

View File

@@ -0,0 +1,16 @@
<script setup lang="ts">
const props = defineProps<{
status: string;
}>();
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';
return 'badge badge-info';
});
</script>
<template>
<span :class="className">{{ status }}</span>
</template>

View File

@@ -0,0 +1,13 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite';
import AppHeader from './AppHeader.vue';
const meta: Meta<typeof AppHeader> = {
title: 'UI/AppHeader',
component: AppHeader,
};
export default meta;
type Story = StoryObj<typeof AppHeader>;
export const Default: Story = {};

View File

@@ -0,0 +1,16 @@
<template>
<header class="navbar bg-base-100 border-b border-base-300 sticky top-0 z-10">
<div class="navbar-start">
<NuxtLink to="/" class="btn btn-ghost text-xl">Fregat Manager</NuxtLink>
</div>
<div class="navbar-center hidden md:flex">
<ul class="menu menu-horizontal px-1">
<li><NuxtLink to="/requests">Заявки</NuxtLink></li>
<li><NuxtLink to="/orders">Заказы</NuxtLink></li>
<li><NuxtLink to="/invitations">Инвайты</NuxtLink></li>
<li><NuxtLink to="/referrals">Рефералка</NuxtLink></li>
<li><NuxtLink to="/withdrawals">Выводы</NuxtLink></li>
</ul>
</div>
</header>
</template>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,5 @@
import type { ApolloClient, NormalizedCacheObject } from '@apollo/client/core';
export function useGqlClient(): ApolloClient<NormalizedCacheObject> {
return useNuxtApp().$apollo as ApolloClient<NormalizedCacheObject>;
}

73
app/pages/cart.vue Normal file
View File

@@ -0,0 +1,73 @@
<script setup lang="ts">
import { useMutation } from '@vue/apollo-composable';
import { SubmitCalculationOrderDocument } from '~/composables/graphql/generated';
const productName = ref('');
const quantity = ref(1);
const width = ref(100);
const thickness = ref(50);
const color = ref('прозрачный');
const { mutate, loading, onDone, onError } = useMutation(SubmitCalculationOrderDocument);
const success = ref('');
const errorMessage = ref('');
onDone((result) => {
success.value = `Заявка ${result.data?.submitCalculationOrder.code} отправлена`;
errorMessage.value = '';
});
onError((error) => {
errorMessage.value = error.message;
success.value = '';
});
function submit() {
mutate({
input: {
productName: productName.value,
quantity: Number(quantity.value),
parameters: {
width: Number(width.value),
thickness: Number(thickness.value),
color: color.value,
},
},
});
}
</script>
<template>
<section class="space-y-4 max-w-2xl">
<h1 class="text-2xl font-bold">Корзина / заявка на расчет</h1>
<div class="card bg-base-100 border border-base-300">
<div class="card-body space-y-3">
<label class="form-control">
<span class="label-text">Название позиции</span>
<input v-model="productName" type="text" class="input input-bordered" />
</label>
<label class="form-control">
<span class="label-text">Количество</span>
<input v-model="quantity" type="number" min="1" class="input input-bordered" />
</label>
<label class="form-control">
<span class="label-text">Ширина</span>
<input v-model="width" type="number" min="1" class="input input-bordered" />
</label>
<label class="form-control">
<span class="label-text">Толщина</span>
<input v-model="thickness" type="number" min="1" class="input input-bordered" />
</label>
<label class="form-control">
<span class="label-text">Цвет</span>
<input v-model="color" type="text" class="input input-bordered" />
</label>
<button class="btn btn-primary" :disabled="loading" @click="submit">Отправить менеджеру</button>
</div>
</div>
<div v-if="success" class="alert alert-success">{{ success }}</div>
<div v-if="errorMessage" class="alert alert-error">{{ errorMessage }}</div>
</section>
</template>

13
app/pages/index.vue Normal file
View File

@@ -0,0 +1,13 @@
<template>
<section class="space-y-6">
<h1 class="text-3xl font-bold">Кабинет менеджера</h1>
<p class="text-base-content/80">Рабочие разделы: заявки, согласование заказов, инвайты, рефералка и выводы.</p>
<div class="grid gap-4 md:grid-cols-2 xl:grid-cols-5">
<NuxtLink to="/requests" class="card bg-base-100 border border-base-300 shadow-sm"><div class="card-body"><h2 class="card-title">Заявки</h2></div></NuxtLink>
<NuxtLink to="/orders" class="card bg-base-100 border border-base-300 shadow-sm"><div class="card-body"><h2 class="card-title">Заказы</h2></div></NuxtLink>
<NuxtLink to="/invitations" class="card bg-base-100 border border-base-300 shadow-sm"><div class="card-body"><h2 class="card-title">Инвайты</h2></div></NuxtLink>
<NuxtLink to="/referrals" class="card bg-base-100 border border-base-300 shadow-sm"><div class="card-body"><h2 class="card-title">Рефералка</h2></div></NuxtLink>
<NuxtLink to="/withdrawals" class="card bg-base-100 border border-base-300 shadow-sm"><div class="card-body"><h2 class="card-title">Выводы</h2></div></NuxtLink>
</div>
</section>
</template>

29
app/pages/invitations.vue Normal file
View File

@@ -0,0 +1,29 @@
<script setup lang="ts">
import { useMutation } from '@vue/apollo-composable';
import { CreateInvitationDocument } from '~/composables/graphql/generated';
const email = ref('');
const companyName = ref('');
const token = ref('');
const createInvitation = useMutation(CreateInvitationDocument);
async function submit() {
const result = await createInvitation.mutate({ input: { email: email.value, companyName: companyName.value, expiresInDays: 7 } });
token.value = result?.data?.createInvitation.token ?? '';
}
</script>
<template>
<section class="space-y-4 max-w-2xl">
<h1 class="text-2xl font-bold">Инвайты клиентам</h1>
<div class="card bg-base-100 border border-base-300">
<div class="card-body space-y-3">
<input v-model="email" type="email" class="input input-bordered" placeholder="Email клиента" />
<input v-model="companyName" type="text" class="input input-bordered" placeholder="Компания" />
<button class="btn btn-primary" @click="submit">Создать инвайт</button>
</div>
</div>
<div v-if="token" class="alert alert-success">Токен: {{ token }}</div>
</section>
</template>

66
app/pages/orders.vue Normal file
View File

@@ -0,0 +1,66 @@
<script setup lang="ts">
import { useMutation, useQuery } from '@vue/apollo-composable';
import OrderStatusBadge from '~/components/orders/OrderStatusBadge.vue';
import {
BlockOrderDocument,
ManagerFinalizeOrderDocument,
ManagerOrdersDocument,
ManagerSetOrderOfferDocument,
} from '~/composables/graphql/generated';
const { result, refetch } = useQuery(ManagerOrdersDocument, { status: null });
const setOffer = useMutation(ManagerSetOrderOfferDocument);
const finalize = useMutation(ManagerFinalizeOrderDocument);
const block = useMutation(BlockOrderDocument);
async function publishOffer(orderId: string) {
await setOffer.mutate({
input: {
orderId,
deliveryTerms: 'Доставка 3-5 дней',
deliveryFee: 1000,
totalPrice: 12500,
},
});
await refetch();
}
async function approve(orderId: string) {
await finalize.mutate({ orderId, decision: 'APPROVE' });
await refetch();
}
async function reject(orderId: string) {
await finalize.mutate({ orderId, decision: 'REJECT' });
await refetch();
}
async function blockOrder(orderId: string) {
await block.mutate({ input: { orderId, reason: 'Нужно уточнение параметров' } });
await refetch();
}
</script>
<template>
<section class="space-y-4">
<h1 class="text-2xl font-bold">Заявки и согласования заказов</h1>
<article v-for="order in result?.managerOrders ?? []" :key="order.id" class="card bg-base-100 border border-base-300">
<div class="card-body space-y-3">
<div class="flex items-center justify-between">
<h2 class="card-title">{{ order.code }}</h2>
<OrderStatusBadge :status="order.status" />
</div>
<ul class="text-sm">
<li v-for="item in order.items" :key="item.id">{{ item.productName }} × {{ item.quantity }}</li>
</ul>
<div class="flex gap-2 flex-wrap">
<button class="btn btn-primary" @click="publishOffer(order.id)">Публиковать оффер</button>
<button class="btn btn-success" @click="approve(order.id)">Approve</button>
<button class="btn btn-error" @click="reject(order.id)">Reject</button>
<button class="btn btn-warning" @click="blockOrder(order.id)">Блокировать</button>
</div>
</div>
</article>
</section>
</template>

26
app/pages/products.vue Normal file
View File

@@ -0,0 +1,26 @@
<script setup lang="ts">
import { useQuery } from '@vue/apollo-composable';
import { ClientProductsDocument } from '~/composables/graphql/generated';
const { result, loading, error } = useQuery(ClientProductsDocument);
</script>
<template>
<section class="space-y-4">
<h1 class="text-2xl font-bold">Витрина товаров</h1>
<div v-if="loading" class="alert">Загрузка...</div>
<div v-else-if="error" class="alert alert-error">{{ error.message }}</div>
<div v-else class="grid gap-4 lg:grid-cols-2">
<article v-for="product in result?.clientProducts ?? []" :key="product.id" class="card bg-base-100 border border-base-300">
<div class="card-body gap-2">
<h2 class="card-title">{{ product.name }}</h2>
<p class="text-sm opacity-80">{{ product.description }}</p>
<p class="text-xs">SKU: {{ product.sku }}</p>
<ul class="text-sm space-y-1">
<li v-for="stock in product.availableInWarehouses" :key="stock.warehouse.id">{{ stock.warehouse.name }}: {{ stock.availableQty }}</li>
</ul>
</div>
</article>
</div>
</section>
</template>

71
app/pages/profile.vue Normal file
View File

@@ -0,0 +1,71 @@
<script setup lang="ts">
import { useMutation } from '@vue/apollo-composable';
import { ConnectMessengerDocument, RegisterSelfDocument } from '~/composables/graphql/generated';
const companyName = ref('');
const inn = ref('');
const contactName = ref('');
const email = ref('');
const channelId = ref('');
const channelType = ref<'TELEGRAM' | 'MAX'>('TELEGRAM');
const registerMutation = useMutation(RegisterSelfDocument);
const messengerMutation = useMutation(ConnectMessengerDocument);
const message = ref('');
function register() {
registerMutation.mutate({
input: {
companyName: companyName.value,
inn: inn.value || null,
contactName: contactName.value,
email: email.value,
},
}).then(() => {
message.value = 'Заявка на регистрацию отправлена менеджеру';
});
}
function connectMessenger() {
messengerMutation.mutate({
input: {
type: channelType.value,
channelId: channelId.value,
},
}).then(() => {
message.value = 'Канал уведомлений подключен';
});
}
</script>
<template>
<section class="space-y-6 max-w-2xl">
<h1 class="text-2xl font-bold">Профиль и каналы уведомлений</h1>
<div class="card bg-base-100 border border-base-300">
<div class="card-body space-y-3">
<h2 class="card-title">Самостоятельная регистрация</h2>
<input v-model="companyName" type="text" placeholder="Компания" class="input input-bordered" />
<input v-model="inn" type="text" placeholder="ИНН" class="input input-bordered" />
<input v-model="contactName" type="text" placeholder="Контактное лицо" class="input input-bordered" />
<input v-model="email" type="email" placeholder="Email" class="input input-bordered" />
<button class="btn btn-primary" @click="register">Отправить заявку</button>
</div>
</div>
<div class="card bg-base-100 border border-base-300">
<div class="card-body space-y-3">
<h2 class="card-title">Подключение мессенджера</h2>
<select v-model="channelType" class="select select-bordered">
<option value="TELEGRAM">Telegram</option>
<option value="MAX">Max</option>
</select>
<input v-model="channelId" type="text" placeholder="ID канала" class="input input-bordered" />
<button class="btn btn-secondary" @click="connectMessenger">Подключить канал</button>
</div>
</div>
<div v-if="message" class="alert alert-success">{{ message }}</div>
</section>
</template>

50
app/pages/referrals.vue Normal file
View File

@@ -0,0 +1,50 @@
<script setup lang="ts">
import { useMutation } from '@vue/apollo-composable';
import { AddBonusTransactionDocument, CreateReferralDocument } from '~/composables/graphql/generated';
const refereeUserId = ref('');
const referralCreated = ref('');
const bonusUserId = ref('');
const bonusAmount = ref(100);
const bonusReason = ref('Реферальный бонус');
const bonusResult = ref('');
const createReferral = useMutation(CreateReferralDocument);
const addBonus = useMutation(AddBonusTransactionDocument);
async function submitReferral() {
const result = await createReferral.mutate({ input: { refereeUserId: refereeUserId.value } });
referralCreated.value = result?.data?.createReferral.id ?? '';
}
async function submitBonus() {
const result = await addBonus.mutate({ input: { userId: bonusUserId.value, amount: Number(bonusAmount.value), reason: bonusReason.value } });
bonusResult.value = result?.data?.addBonusTransaction.id ?? '';
}
</script>
<template>
<section class="space-y-6 max-w-2xl">
<h1 class="text-2xl font-bold">Реферальная программа</h1>
<div class="card bg-base-100 border border-base-300">
<div class="card-body space-y-3">
<h2 class="card-title">Создать реферальную связь</h2>
<input v-model="refereeUserId" class="input input-bordered" placeholder="ID приглашенного пользователя" />
<button class="btn btn-primary" @click="submitReferral">Создать</button>
<p v-if="referralCreated" class="text-sm">Создано: {{ referralCreated }}</p>
</div>
</div>
<div class="card bg-base-100 border border-base-300">
<div class="card-body space-y-3">
<h2 class="card-title">Начислить бонус</h2>
<input v-model="bonusUserId" class="input input-bordered" placeholder="ID пользователя" />
<input v-model="bonusAmount" type="number" class="input input-bordered" placeholder="Сумма" />
<input v-model="bonusReason" class="input input-bordered" placeholder="Причина" />
<button class="btn btn-secondary" @click="submitBonus">Начислить</button>
<p v-if="bonusResult" class="text-sm">Транзакция: {{ bonusResult }}</p>
</div>
</div>
</section>
</template>

33
app/pages/requests.vue Normal file
View File

@@ -0,0 +1,33 @@
<script setup lang="ts">
import { useMutation, useQuery } from '@vue/apollo-composable';
import { RegistrationRequestsDocument, ReviewRegistrationRequestDocument } from '~/composables/graphql/generated';
const { result, refetch } = useQuery(RegistrationRequestsDocument, { status: 'PENDING' });
const review = useMutation(ReviewRegistrationRequestDocument);
async function approve(requestId: string) {
await review.mutate({ input: { requestId, decision: 'APPROVE' } });
await refetch();
}
async function reject(requestId: string) {
await review.mutate({ input: { requestId, decision: 'REJECT', rejectionReason: 'Не хватает данных' } });
await refetch();
}
</script>
<template>
<section class="space-y-4">
<h1 class="text-2xl font-bold">Заявки на регистрацию</h1>
<article v-for="request in result?.registrationRequests ?? []" :key="request.id" class="card bg-base-100 border border-base-300">
<div class="card-body">
<h2 class="card-title">{{ request.companyName }}</h2>
<p>{{ request.contactName }} {{ request.email }}</p>
<div class="flex gap-2">
<button class="btn btn-success" @click="approve(request.id)">Approve</button>
<button class="btn btn-error" @click="reject(request.id)">Reject</button>
</div>
</div>
</article>
</section>
</template>

34
app/pages/withdrawals.vue Normal file
View File

@@ -0,0 +1,34 @@
<script setup lang="ts">
import { useMutation } from '@vue/apollo-composable';
import { ReviewRewardWithdrawalDocument } from '~/composables/graphql/generated';
const withdrawalId = ref('');
const decision = ref<'APPROVE' | 'REJECT'>('APPROVE');
const reviewComment = ref('');
const resultStatus = ref('');
const review = useMutation(ReviewRewardWithdrawalDocument);
async function submit() {
const result = await review.mutate({ input: { withdrawalId: withdrawalId.value, decision: decision.value, reviewComment: reviewComment.value } });
resultStatus.value = result?.data?.reviewRewardWithdrawal.status ?? '';
}
</script>
<template>
<section class="space-y-4 max-w-2xl">
<h1 class="text-2xl font-bold">Заявки на вывод</h1>
<div class="card bg-base-100 border border-base-300">
<div class="card-body space-y-3">
<input v-model="withdrawalId" class="input input-bordered" placeholder="ID заявки" />
<select v-model="decision" class="select select-bordered">
<option value="APPROVE">Approve</option>
<option value="REJECT">Reject</option>
</select>
<input v-model="reviewComment" class="input input-bordered" placeholder="Комментарий" />
<button class="btn btn-primary" @click="submit">Подтвердить</button>
</div>
</div>
<div v-if="resultStatus" class="alert alert-success">Статус: {{ resultStatus }}</div>
</section>
</template>

23
app/plugins/apollo.ts Normal file
View File

@@ -0,0 +1,23 @@
import { ApolloClient, HttpLink, InMemoryCache } from '@apollo/client/core';
import { provideApolloClient } from '@vue/apollo-composable';
export default defineNuxtPlugin(() => {
const config = useRuntimeConfig();
const client = new ApolloClient({
link: new HttpLink({
uri: config.public.graphqlEndpoint,
fetch,
}),
cache: new InMemoryCache(),
connectToDevTools: import.meta.dev,
});
provideApolloClient(client);
return {
provide: {
apollo: client,
},
};
});

View File

@@ -0,0 +1,18 @@
import * as Sentry from '@sentry/vue';
export default defineNuxtPlugin((nuxtApp) => {
const config = useRuntimeConfig();
const dsn = config.public.sentryDsn;
if (!dsn) {
return;
}
Sentry.init({
app: nuxtApp.vueApp,
dsn,
environment: config.public.sentryEnvironment,
release: config.public.sentryRelease,
tracesSampleRate: 0.1,
});
});

18
codegen.ts Normal file
View File

@@ -0,0 +1,18 @@
import type { CodegenConfig } from '@graphql-codegen/cli';
const config: CodegenConfig = {
schema: './graphql/schema.graphql',
documents: ['./graphql/operations/**/*.graphql'],
generates: {
'./app/composables/graphql/generated.ts': {
plugins: ['typescript', 'typescript-operations', 'typescript-vue-apollo'],
config: {
withCompositionFunctions: true,
vueApolloComposableImportFrom: '@vue/apollo-composable',
useTypeImports: true,
},
},
},
};
export default config;

6
eslint.config.mjs Normal file
View File

@@ -0,0 +1,6 @@
// @ts-check
import withNuxt from './.nuxt/eslint.config.mjs'
export default withNuxt(
// Your custom configs here
)

View File

@@ -0,0 +1,10 @@
mutation RegisterSelf($input: RegisterSelfInput!) {
registerSelf(input: $input) {
id
companyName
contactName
email
status
createdAt
}
}

View File

@@ -0,0 +1,17 @@
query ClientProducts {
clientProducts {
id
sku
name
description
isCustomizable
availableInWarehouses {
availableQty
warehouse {
id
code
name
}
}
}
}

View File

@@ -0,0 +1,9 @@
mutation AddBonusTransaction($input: AddBonusTransactionInput!) {
addBonusTransaction(input: $input) {
id
userId
amount
reason
createdAt
}
}

View File

@@ -0,0 +1,7 @@
mutation BlockOrder($input: BlockOrderInput!) {
blockOrder(input: $input) {
id
status
blockReason
}
}

View File

@@ -0,0 +1,9 @@
mutation CreateInvitation($input: CreateInvitationInput!) {
createInvitation(input: $input) {
id
token
email
companyName
expiresAt
}
}

View File

@@ -0,0 +1,8 @@
mutation CreateReferral($input: CreateReferralInput!) {
createReferral(input: $input) {
id
referrerId
refereeId
createdAt
}
}

View File

@@ -0,0 +1,7 @@
mutation ManagerFinalizeOrder($orderId: ID!, $decision: Decision!) {
managerFinalizeOrder(orderId: $orderId, decision: $decision) {
id
status
managerApproved
}
}

View File

@@ -0,0 +1,17 @@
query ManagerOrders($status: OrderStatus) {
managerOrders(status: $status) {
id
code
status
kind
customerId
deliveryTerms
totalPrice
createdAt
items {
id
productName
quantity
}
}
}

View File

@@ -0,0 +1,10 @@
query RegistrationRequests($status: RegistrationStatus) {
registrationRequests(status: $status) {
id
companyName
contactName
email
status
createdAt
}
}

View File

@@ -0,0 +1,8 @@
mutation ReviewRegistrationRequest($input: ReviewRegistrationRequestInput!) {
reviewRegistrationRequest(input: $input) {
id
status
rejectionReason
reviewedById
}
}

View File

@@ -0,0 +1,8 @@
mutation ReviewRewardWithdrawal($input: ReviewRewardWithdrawalInput!) {
reviewRewardWithdrawal(input: $input) {
id
status
reviewComment
reviewedById
}
}

View File

@@ -0,0 +1,9 @@
mutation ManagerSetOrderOffer($input: SetOrderOfferInput!) {
managerSetOrderOffer(input: $input) {
id
code
status
deliveryTerms
totalPrice
}
}

View File

@@ -0,0 +1,14 @@
query MyCurrentOrders {
myCurrentOrders {
id
code
kind
status
createdAt
items {
id
productName
quantity
}
}
}

View File

@@ -0,0 +1,16 @@
query MyOrders {
myOrders {
id
code
kind
status
totalPrice
deliveryTerms
createdAt
items {
id
productName
quantity
}
}
}

View File

@@ -0,0 +1,8 @@
mutation SubmitCalculationOrder($input: SubmitCalculationOrderInput!) {
submitCalculationOrder(input: $input) {
id
code
status
createdAt
}
}

View File

@@ -0,0 +1,8 @@
mutation SubmitReadyOrder($input: SubmitReadyOrderInput!) {
submitReadyOrder(input: $input) {
id
code
status
createdAt
}
}

View File

@@ -0,0 +1,8 @@
mutation ConnectMessenger($input: ConnectMessengerInput!) {
connectMessenger(input: $input) {
id
type
channelId
isActive
}
}

294
graphql/schema.graphql Normal file
View File

@@ -0,0 +1,294 @@
scalar DateTime
scalar JSON
enum UserRole {
CLIENT
MANAGER
}
enum MessengerType {
TELEGRAM
MAX
}
enum RegistrationStatus {
PENDING
APPROVED
REJECTED
}
enum OrderKind {
READY
CALCULATION
}
enum OrderStatus {
NEW
MANAGER_PROCESSING
WAITING_DOUBLE_CONFIRM
CLIENT_REJECTED
MANAGER_REJECTED
MANAGER_BLOCKED
CONFIRMED
IN_PROGRESS
COMPLETED
}
enum WithdrawalStatus {
PENDING
APPROVED
REJECTED
}
enum Decision {
APPROVE
REJECT
}
type Company {
id: ID!
name: String!
inn: String
}
type User {
id: ID!
email: String!
fullName: String!
role: UserRole!
company: Company
}
type Invitation {
id: ID!
token: String!
email: String!
companyName: String!
managerId: ID!
acceptedById: ID
expiresAt: DateTime!
acceptedAt: DateTime
createdAt: DateTime!
}
type RegistrationRequest {
id: ID!
companyName: String!
inn: String
contactName: String!
email: String!
status: RegistrationStatus!
rejectionReason: String
reviewedById: ID
createdAt: DateTime!
updatedAt: DateTime!
}
type MessengerConnection {
id: ID!
userId: ID!
type: MessengerType!
channelId: String!
isActive: Boolean!
}
type Warehouse {
id: ID!
code: String!
name: String!
}
type ProductWarehouseBalance {
warehouse: Warehouse!
availableQty: Float!
}
type Product {
id: ID!
sku: String!
name: String!
description: String
isCustomizable: Boolean!
isActive: Boolean!
availableInWarehouses: [ProductWarehouseBalance!]!
}
type OrderItem {
id: ID!
productId: ID
productName: String!
quantity: Float!
}
type OrderStatusEvent {
id: ID!
status: OrderStatus!
actorUserId: ID!
note: String
createdAt: DateTime!
}
type Order {
id: ID!
code: String!
kind: OrderKind!
status: OrderStatus!
customerId: ID!
managerId: ID
clientApproved: Boolean
managerApproved: Boolean
blockReason: String
deliveryTerms: String
deliveryFee: Float
totalPrice: Float
calculationPayload: JSON
items: [OrderItem!]!
history: [OrderStatusEvent!]!
createdAt: DateTime!
updatedAt: DateTime!
}
type ReferralLink {
id: ID!
referrerId: ID!
refereeId: ID!
createdAt: DateTime!
}
type BonusTransaction {
id: ID!
userId: ID!
amount: Float!
reason: String!
orderId: ID
createdAt: DateTime!
}
type RewardWithdrawalRequest {
id: ID!
requesterId: ID!
amount: Float!
status: WithdrawalStatus!
reviewedById: ID
reviewComment: String
createdAt: DateTime!
updatedAt: DateTime!
}
type ReferralStats {
referrerId: ID!
availableBalance: Float!
referralsCount: Int!
transactions: [BonusTransaction!]!
pendingWithdrawals: [RewardWithdrawalRequest!]!
}
type Query {
healthcheck: String!
me: User
clientProducts: [Product!]!
myOrders: [Order!]!
myCurrentOrders: [Order!]!
managerOrders(status: OrderStatus): [Order!]!
registrationRequests(status: RegistrationStatus): [RegistrationRequest!]!
referralStats: ReferralStats!
}
input RegisterSelfInput {
companyName: String!
inn: String
contactName: String!
email: String!
}
input ReviewRegistrationRequestInput {
requestId: ID!
decision: Decision!
rejectionReason: String
}
input CreateInvitationInput {
email: String!
companyName: String!
expiresInDays: Int = 7
}
input AcceptInvitationInput {
token: String!
fullName: String!
}
input ConnectMessengerInput {
type: MessengerType!
channelId: String!
}
input ReadyOrderItemInput {
productId: ID!
quantity: Float!
}
input SubmitReadyOrderInput {
items: [ReadyOrderItemInput!]!
}
input SubmitCalculationOrderInput {
productName: String!
quantity: Float!
parameters: JSON!
}
input SetOrderOfferInput {
orderId: ID!
deliveryTerms: String!
deliveryFee: Float!
totalPrice: Float!
}
input BlockOrderInput {
orderId: ID!
reason: String!
}
input CreateReferralInput {
refereeUserId: ID!
}
input AddBonusTransactionInput {
userId: ID!
amount: Float!
reason: String!
orderId: ID
}
input RequestRewardWithdrawalInput {
amount: Float!
}
input ReviewRewardWithdrawalInput {
withdrawalId: ID!
decision: Decision!
reviewComment: String
}
type Mutation {
registerSelf(input: RegisterSelfInput!): RegistrationRequest!
reviewRegistrationRequest(input: ReviewRegistrationRequestInput!): RegistrationRequest!
createInvitation(input: CreateInvitationInput!): Invitation!
acceptInvitation(input: AcceptInvitationInput!): User!
connectMessenger(input: ConnectMessengerInput!): MessengerConnection!
submitReadyOrder(input: SubmitReadyOrderInput!): Order!
submitCalculationOrder(input: SubmitCalculationOrderInput!): Order!
managerSetOrderOffer(input: SetOrderOfferInput!): Order!
clientReviewOrder(orderId: ID!, decision: Decision!): Order!
managerFinalizeOrder(orderId: ID!, decision: Decision!): Order!
blockOrder(input: BlockOrderInput!): Order!
startOrderWork(orderId: ID!): Order!
completeOrder(orderId: ID!): Order!
createReferral(input: CreateReferralInput!): ReferralLink!
addBonusTransaction(input: AddBonusTransactionInput!): BonusTransaction!
requestRewardWithdrawal(input: RequestRewardWithdrawalInput!): RewardWithdrawalRequest!
reviewRewardWithdrawal(input: ReviewRewardWithdrawalInput!): RewardWithdrawalRequest!
}

21
nuxt.config.ts Normal file
View File

@@ -0,0 +1,21 @@
// https://nuxt.com/docs/api/configuration/nuxt-config
export default defineNuxtConfig({
compatibilityDate: '2025-07-15',
devtools: { enabled: true },
modules: ['@nuxt/eslint', '@nuxtjs/tailwindcss'],
css: ['~/assets/css/main.css'],
runtimeConfig: {
public: {
graphqlEndpoint: process.env.NUXT_PUBLIC_GRAPHQL_ENDPOINT ?? 'http://localhost:4000/graphql',
sentryDsn: process.env.NUXT_PUBLIC_SENTRY_DSN ?? '',
sentryEnvironment: process.env.NUXT_PUBLIC_SENTRY_ENVIRONMENT ?? 'development',
sentryRelease: process.env.NUXT_PUBLIC_SENTRY_RELEASE ?? 'dev',
},
},
app: {
head: {
title: 'Fregat Manager Cabinet',
meta: [{ name: 'viewport', content: 'width=device-width, initial-scale=1' }],
},
},
});

38
package.json Normal file
View File

@@ -0,0 +1,38 @@
{
"name": "manager-frontend",
"type": "module",
"private": true,
"scripts": {
"build": "nuxt build",
"dev": "nuxt dev",
"generate": "nuxt generate",
"preview": "nuxt preview",
"postinstall": "nuxt prepare",
"codegen": "graphql-codegen --config codegen.ts",
"storybook": "storybook dev -p 6006",
"build-storybook": "storybook build"
},
"dependencies": {
"@apollo/client": "^3.14.1",
"@nuxt/eslint": "1.15.2",
"@nuxtjs/tailwindcss": "6.14.0",
"@sentry/vue": "^10.46.0",
"@vue/apollo-composable": "^4.2.2",
"daisyui": "^5.5.19",
"graphql": "^16.13.2",
"nuxt": "^4.4.2",
"vue": "^3.5.30",
"vue-router": "^5.0.4"
},
"devDependencies": {
"@graphql-codegen/cli": "^6.2.1",
"@graphql-codegen/typed-document-node": "^6.1.7",
"@graphql-codegen/typescript": "^5.0.9",
"@graphql-codegen/typescript-operations": "^5.0.9",
"@graphql-codegen/typescript-vue-apollo": "^5.0.0",
"@storybook/addon-essentials": "8.6.14",
"@storybook/vue3-vite": "^8.6.14",
"storybook": "^8.6.14",
"typescript": "5.9.2"
}
}

12726
pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

BIN
public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

2
public/robots.txt Normal file
View File

@@ -0,0 +1,2 @@
User-Agent: *
Disallow:

17
tailwind.config.ts Normal file
View File

@@ -0,0 +1,17 @@
import type { Config } from 'tailwindcss';
import daisyui from 'daisyui';
export default {
content: [
'./app/**/*.{vue,ts,js}',
'./components/**/*.{vue,ts,js}',
'./pages/**/*.vue',
],
theme: {
extend: {},
},
plugins: [daisyui],
daisyui: {
themes: ['light', 'corporate'],
},
} satisfies Config;

18
tsconfig.json Normal file
View File

@@ -0,0 +1,18 @@
{
// https://nuxt.com/docs/guide/concepts/typescript
"files": [],
"references": [
{
"path": "./.nuxt/tsconfig.app.json"
},
{
"path": "./.nuxt/tsconfig.server.json"
},
{
"path": "./.nuxt/tsconfig.shared.json"
},
{
"path": "./.nuxt/tsconfig.node.json"
}
]
}