Добавлены модели услуг и расходников для фулфилмент центров, реализованы соответствующие мутации и запросы в GraphQL. Обновлен конфигурационный файл и добавлен новый компонент Toaster в макет приложения. Обновлены зависимости в package.json и package-lock.json.
This commit is contained in:
17
.dockerignore
Normal file
17
.dockerignore
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
Dockerfile
|
||||||
|
.dockerignore
|
||||||
|
node_modules
|
||||||
|
npm-debug.log
|
||||||
|
README.md
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
.env.production.local
|
||||||
|
.env.development.local
|
||||||
|
.git
|
||||||
|
.gitignore
|
||||||
|
*.md
|
||||||
|
.next
|
||||||
|
.vercel
|
||||||
|
.DS_Store
|
||||||
|
*.tsbuildinfo
|
||||||
|
stack.env
|
62
Dockerfile
Normal file
62
Dockerfile
Normal file
@ -0,0 +1,62 @@
|
|||||||
|
# Используем официальный Node.js образ
|
||||||
|
FROM node:18-alpine AS base
|
||||||
|
|
||||||
|
# Устанавливаем зависимости только когда нужно
|
||||||
|
FROM base AS deps
|
||||||
|
# Проверяем https://github.com/nodejs/docker-node/tree/b4117f9333da4138b03a546ec926ef50a31506c3#nodealpine чтобы понять зачем нужен libc6-compat
|
||||||
|
RUN apk add --no-cache libc6-compat
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Устанавливаем зависимости на основе предпочтительного менеджера пакетов
|
||||||
|
COPY package.json package-lock.json* ./
|
||||||
|
RUN \
|
||||||
|
if [ -f package-lock.json ]; then npm ci; \
|
||||||
|
else echo "Lockfile not found." && exit 1; \
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Собираем исходный код только когда нужно
|
||||||
|
FROM base AS builder
|
||||||
|
WORKDIR /app
|
||||||
|
COPY --from=deps /app/node_modules ./node_modules
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
# Генерируем Prisma клиент
|
||||||
|
RUN npx prisma generate
|
||||||
|
|
||||||
|
# Собираем приложение
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
# Удаляем dev зависимости для production
|
||||||
|
RUN npm prune --production
|
||||||
|
|
||||||
|
# Продакшн образ, копируем все файлы и запускаем next
|
||||||
|
FROM base AS runner
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
ENV NODE_ENV production
|
||||||
|
# Отключаем телеметрию next.js во время runtime
|
||||||
|
ENV NEXT_TELEMETRY_DISABLED 1
|
||||||
|
|
||||||
|
RUN addgroup --system --gid 1001 nodejs
|
||||||
|
RUN adduser --system --uid 1001 nextjs
|
||||||
|
|
||||||
|
COPY --from=builder /app/public ./public
|
||||||
|
|
||||||
|
# Автоматически использовать output traces для уменьшения размера образа
|
||||||
|
# https://nextjs.org/docs/advanced-features/output-file-tracing
|
||||||
|
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
|
||||||
|
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
|
||||||
|
|
||||||
|
# Копируем Prisma схему и клиент для runtime
|
||||||
|
COPY --from=builder --chown=nextjs:nodejs /app/prisma ./prisma
|
||||||
|
COPY --from=builder --chown=nextjs:nodejs /app/node_modules/.prisma ./node_modules/.prisma
|
||||||
|
COPY --from=builder --chown=nextjs:nodejs /app/node_modules/@prisma ./node_modules/@prisma
|
||||||
|
|
||||||
|
USER nextjs
|
||||||
|
|
||||||
|
EXPOSE 3000
|
||||||
|
|
||||||
|
ENV PORT 3000
|
||||||
|
ENV HOSTNAME "0.0.0.0"
|
||||||
|
|
||||||
|
CMD ["node", "server.js"]
|
20
docker-compose.yml
Normal file
20
docker-compose.yml
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
version: '3.8'
|
||||||
|
|
||||||
|
services:
|
||||||
|
app:
|
||||||
|
build: .
|
||||||
|
ports:
|
||||||
|
- "3017:3000"
|
||||||
|
env_file:
|
||||||
|
- stack.env
|
||||||
|
environment:
|
||||||
|
- NODE_ENV=production
|
||||||
|
- PORT=3000
|
||||||
|
- HOSTNAME=0.0.0.0
|
||||||
|
restart: unless-stopped
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:3000/api/health"]
|
||||||
|
timeout: 10s
|
||||||
|
interval: 30s
|
||||||
|
retries: 3
|
||||||
|
start_period: 40s
|
@ -1,7 +1,7 @@
|
|||||||
import type { NextConfig } from "next";
|
import type { NextConfig } from "next";
|
||||||
|
|
||||||
const nextConfig: NextConfig = {
|
const nextConfig: NextConfig = {
|
||||||
/* config options here */
|
output: 'standalone',
|
||||||
};
|
};
|
||||||
|
|
||||||
export default nextConfig;
|
export default nextConfig;
|
||||||
|
29
package-lock.json
generated
29
package-lock.json
generated
@ -14,6 +14,7 @@
|
|||||||
"@as-integrations/next": "^3.2.0",
|
"@as-integrations/next": "^3.2.0",
|
||||||
"@aws-sdk/client-s3": "^3.846.0",
|
"@aws-sdk/client-s3": "^3.846.0",
|
||||||
"@prisma/client": "^6.12.0",
|
"@prisma/client": "^6.12.0",
|
||||||
|
"@radix-ui/react-alert-dialog": "^1.1.14",
|
||||||
"@radix-ui/react-avatar": "^1.1.10",
|
"@radix-ui/react-avatar": "^1.1.10",
|
||||||
"@radix-ui/react-checkbox": "^1.3.2",
|
"@radix-ui/react-checkbox": "^1.3.2",
|
||||||
"@radix-ui/react-dialog": "^1.1.14",
|
"@radix-ui/react-dialog": "^1.1.14",
|
||||||
@ -2752,6 +2753,34 @@
|
|||||||
"integrity": "sha512-XnbHrrprsNqZKQhStrSwgRUQzoCI1glLzdw79xiZPoofhGICeZRSQ3dIxAKH1gb3OHfNf4d6f+vAv3kil2eggA==",
|
"integrity": "sha512-XnbHrrprsNqZKQhStrSwgRUQzoCI1glLzdw79xiZPoofhGICeZRSQ3dIxAKH1gb3OHfNf4d6f+vAv3kil2eggA==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@radix-ui/react-alert-dialog": {
|
||||||
|
"version": "1.1.14",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-alert-dialog/-/react-alert-dialog-1.1.14.tgz",
|
||||||
|
"integrity": "sha512-IOZfZ3nPvN6lXpJTBCunFQPRSvK8MDgSc1FB85xnIpUKOw9en0dJj8JmCAxV7BiZdtYlUpmrQjoTFkVYtdoWzQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@radix-ui/primitive": "1.1.2",
|
||||||
|
"@radix-ui/react-compose-refs": "1.1.2",
|
||||||
|
"@radix-ui/react-context": "1.1.2",
|
||||||
|
"@radix-ui/react-dialog": "1.1.14",
|
||||||
|
"@radix-ui/react-primitive": "2.1.3",
|
||||||
|
"@radix-ui/react-slot": "1.2.3"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"@types/react-dom": "*",
|
||||||
|
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||||
|
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@types/react-dom": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@radix-ui/react-arrow": {
|
"node_modules/@radix-ui/react-arrow": {
|
||||||
"version": "1.1.7",
|
"version": "1.1.7",
|
||||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.1.7.tgz",
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.1.7.tgz",
|
||||||
|
@ -15,6 +15,7 @@
|
|||||||
"@as-integrations/next": "^3.2.0",
|
"@as-integrations/next": "^3.2.0",
|
||||||
"@aws-sdk/client-s3": "^3.846.0",
|
"@aws-sdk/client-s3": "^3.846.0",
|
||||||
"@prisma/client": "^6.12.0",
|
"@prisma/client": "^6.12.0",
|
||||||
|
"@radix-ui/react-alert-dialog": "^1.1.14",
|
||||||
"@radix-ui/react-avatar": "^1.1.10",
|
"@radix-ui/react-avatar": "^1.1.10",
|
||||||
"@radix-ui/react-checkbox": "^1.3.2",
|
"@radix-ui/react-checkbox": "^1.3.2",
|
||||||
"@radix-ui/react-dialog": "^1.1.14",
|
"@radix-ui/react-dialog": "^1.1.14",
|
||||||
|
@ -114,6 +114,10 @@ model Organization {
|
|||||||
sentMessages Message[] @relation("SentMessages")
|
sentMessages Message[] @relation("SentMessages")
|
||||||
receivedMessages Message[] @relation("ReceivedMessages")
|
receivedMessages Message[] @relation("ReceivedMessages")
|
||||||
|
|
||||||
|
// Услуги и расходники (только для фулфилмент центров)
|
||||||
|
services Service[]
|
||||||
|
supplies Supply[]
|
||||||
|
|
||||||
@@map("organizations")
|
@@map("organizations")
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -235,3 +239,38 @@ enum MessageType {
|
|||||||
IMAGE
|
IMAGE
|
||||||
FILE
|
FILE
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Модель услуг (для фулфилмент центров)
|
||||||
|
model Service {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
name String
|
||||||
|
description String?
|
||||||
|
price Decimal @db.Decimal(10,2) // Цена за единицу
|
||||||
|
imageUrl String? // URL фотографии в S3
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
// Связь с организацией
|
||||||
|
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
|
||||||
|
organizationId String
|
||||||
|
|
||||||
|
@@map("services")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Модель расходников (для фулфилмент центров)
|
||||||
|
model Supply {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
name String
|
||||||
|
description String?
|
||||||
|
price Decimal @db.Decimal(10,2) // Цена за единицу
|
||||||
|
quantity Int @default(0) // Количество в наличии
|
||||||
|
imageUrl String? // URL фотографии в S3
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
// Связь с организацией
|
||||||
|
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
|
||||||
|
organizationId String
|
||||||
|
|
||||||
|
@@map("supplies")
|
||||||
|
}
|
||||||
|
5
src/app/api/health/route.ts
Normal file
5
src/app/api/health/route.ts
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
import { NextResponse } from 'next/server'
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
return NextResponse.json({ status: 'ok', timestamp: new Date().toISOString() })
|
||||||
|
}
|
163
src/app/api/upload-service-image/route.ts
Normal file
163
src/app/api/upload-service-image/route.ts
Normal file
@ -0,0 +1,163 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
|
import { S3Client, PutObjectCommand } from '@aws-sdk/client-s3'
|
||||||
|
|
||||||
|
const s3Client = new S3Client({
|
||||||
|
region: 'ru-1',
|
||||||
|
endpoint: 'https://s3.twcstorage.ru',
|
||||||
|
credentials: {
|
||||||
|
accessKeyId: 'I6XD2OR7YO2ZN6L6Z629',
|
||||||
|
secretAccessKey: '9xCOoafisG0aB9lJNvdLO1UuK73fBvMcpHMdijrJ'
|
||||||
|
},
|
||||||
|
forcePathStyle: true
|
||||||
|
})
|
||||||
|
|
||||||
|
const BUCKET_NAME = '617774af-sfera'
|
||||||
|
|
||||||
|
// Разрешенные типы изображений
|
||||||
|
const ALLOWED_IMAGE_TYPES = [
|
||||||
|
'image/jpeg',
|
||||||
|
'image/jpg',
|
||||||
|
'image/png',
|
||||||
|
'image/webp',
|
||||||
|
'image/gif'
|
||||||
|
]
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const formData = await request.formData()
|
||||||
|
const file = formData.get('file') as File
|
||||||
|
const userId = formData.get('userId') as string
|
||||||
|
const type = formData.get('type') as string // 'service' или 'supply'
|
||||||
|
|
||||||
|
if (!file || !userId || !type) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'File, userId and type are required' },
|
||||||
|
{ status: 400 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Проверяем тип (services или supplies)
|
||||||
|
if (!['service', 'supply'].includes(type)) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Type must be either "service" or "supply"' },
|
||||||
|
{ status: 400 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Проверяем, что файл не пустой
|
||||||
|
if (file.size === 0) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'File is empty' },
|
||||||
|
{ status: 400 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Проверяем имя файла
|
||||||
|
if (!file.name || file.name.trim().length === 0) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Invalid file name' },
|
||||||
|
{ status: 400 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Проверяем тип файла
|
||||||
|
if (!ALLOWED_IMAGE_TYPES.includes(file.type)) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: `File type ${file.type} is not allowed. Only images are supported.` },
|
||||||
|
{ status: 400 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ограничиваем размер файла (5MB для изображений)
|
||||||
|
if (file.size > 5 * 1024 * 1024) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'File size must be less than 5MB' },
|
||||||
|
{ status: 400 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Генерируем уникальное имя файла
|
||||||
|
const timestamp = Date.now()
|
||||||
|
// Более безопасная очистка имени файла
|
||||||
|
const safeFileName = file.name
|
||||||
|
.replace(/[^\w\s.-]/g, '_') // Заменяем недопустимые символы
|
||||||
|
.replace(/\s+/g, '_') // Заменяем пробелы на подчеркивания
|
||||||
|
.replace(/_{2,}/g, '_') // Убираем множественные подчеркивания
|
||||||
|
.toLowerCase() // Приводим к нижнему регистру
|
||||||
|
|
||||||
|
const folder = type === 'service' ? 'services' : 'supplies'
|
||||||
|
const key = `${folder}/${userId}/${timestamp}-${safeFileName}`
|
||||||
|
|
||||||
|
// Конвертируем файл в Buffer
|
||||||
|
const buffer = Buffer.from(await file.arrayBuffer())
|
||||||
|
|
||||||
|
// Очищаем метаданные от недопустимых символов
|
||||||
|
const cleanOriginalName = file.name.replace(/[^\w\s.-]/g, '_')
|
||||||
|
const cleanUserId = userId.replace(/[^\w-]/g, '')
|
||||||
|
const cleanType = type.replace(/[^\w]/g, '')
|
||||||
|
|
||||||
|
// Загружаем в S3
|
||||||
|
const command = new PutObjectCommand({
|
||||||
|
Bucket: BUCKET_NAME,
|
||||||
|
Key: key,
|
||||||
|
Body: buffer,
|
||||||
|
ContentType: file.type,
|
||||||
|
ACL: 'public-read',
|
||||||
|
Metadata: {
|
||||||
|
originalname: cleanOriginalName,
|
||||||
|
uploadedby: cleanUserId,
|
||||||
|
type: cleanType
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
await s3Client.send(command)
|
||||||
|
|
||||||
|
// Возвращаем URL файла и метаданные
|
||||||
|
const url = `https://s3.twcstorage.ru/${BUCKET_NAME}/${key}`
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
url,
|
||||||
|
key,
|
||||||
|
originalName: file.name,
|
||||||
|
size: file.size,
|
||||||
|
type: file.type
|
||||||
|
})
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error uploading service image:', error)
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Failed to upload image' },
|
||||||
|
{ status: 500 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function DELETE(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const { key } = await request.json()
|
||||||
|
|
||||||
|
if (!key) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Key is required' },
|
||||||
|
{ status: 400 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: Добавить удаление из S3
|
||||||
|
// const command = new DeleteObjectCommand({
|
||||||
|
// Bucket: BUCKET_NAME,
|
||||||
|
// Key: key
|
||||||
|
// })
|
||||||
|
// await s3Client.send(command)
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true })
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error deleting service image:', error)
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Failed to delete image' },
|
||||||
|
{ status: 500 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import { ApolloProvider } from '@apollo/client'
|
import { ApolloProvider } from '@apollo/client'
|
||||||
import { apolloClient } from '@/lib/apollo-client'
|
import { apolloClient } from '@/lib/apollo-client'
|
||||||
|
import { Toaster } from "@/components/ui/sonner"
|
||||||
import "./globals.css"
|
import "./globals.css"
|
||||||
|
|
||||||
export default function RootLayout({
|
export default function RootLayout({
|
||||||
@ -15,6 +16,7 @@ export default function RootLayout({
|
|||||||
<ApolloProvider client={apolloClient}>
|
<ApolloProvider client={apolloClient}>
|
||||||
{children}
|
{children}
|
||||||
</ApolloProvider>
|
</ApolloProvider>
|
||||||
|
<Toaster />
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
)
|
)
|
||||||
|
10
src/app/services/page.tsx
Normal file
10
src/app/services/page.tsx
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
import { AuthGuard } from "@/components/auth-guard"
|
||||||
|
import { ServicesDashboard } from "@/components/services/services-dashboard"
|
||||||
|
|
||||||
|
export default function ServicesPage() {
|
||||||
|
return (
|
||||||
|
<AuthGuard>
|
||||||
|
<ServicesDashboard />
|
||||||
|
</AuthGuard>
|
||||||
|
)
|
||||||
|
}
|
@ -11,7 +11,8 @@ import {
|
|||||||
LogOut,
|
LogOut,
|
||||||
Building2,
|
Building2,
|
||||||
Store,
|
Store,
|
||||||
MessageCircle
|
MessageCircle,
|
||||||
|
Wrench
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
|
|
||||||
export function Sidebar() {
|
export function Sidebar() {
|
||||||
@ -63,9 +64,14 @@ export function Sidebar() {
|
|||||||
router.push('/messenger')
|
router.push('/messenger')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleServicesClick = () => {
|
||||||
|
router.push('/services')
|
||||||
|
}
|
||||||
|
|
||||||
const isSettingsActive = pathname === '/settings'
|
const isSettingsActive = pathname === '/settings'
|
||||||
const isMarketActive = pathname.startsWith('/market')
|
const isMarketActive = pathname.startsWith('/market')
|
||||||
const isMessengerActive = pathname.startsWith('/messenger')
|
const isMessengerActive = pathname.startsWith('/messenger')
|
||||||
|
const isServicesActive = pathname.startsWith('/services')
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed left-0 top-0 h-full w-56 bg-white/10 backdrop-blur-xl border-r border-white/20 p-3">
|
<div className="fixed left-0 top-0 h-full w-56 bg-white/10 backdrop-blur-xl border-r border-white/20 p-3">
|
||||||
@ -132,6 +138,22 @@ export function Sidebar() {
|
|||||||
Мессенджер
|
Мессенджер
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
|
{/* Услуги - только для фулфилмент центров */}
|
||||||
|
{user?.organization?.type === 'FULFILLMENT' && (
|
||||||
|
<Button
|
||||||
|
variant={isServicesActive ? "secondary" : "ghost"}
|
||||||
|
className={`w-full justify-start text-left transition-all duration-200 h-8 text-xs ${
|
||||||
|
isServicesActive
|
||||||
|
? 'bg-white/20 text-white hover:bg-white/30'
|
||||||
|
: 'text-white/80 hover:bg-white/10 hover:text-white'
|
||||||
|
} cursor-pointer`}
|
||||||
|
onClick={handleServicesClick}
|
||||||
|
>
|
||||||
|
<Wrench className="h-3 w-3 mr-2" />
|
||||||
|
Услуги
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
variant={isSettingsActive ? "secondary" : "ghost"}
|
variant={isSettingsActive ? "secondary" : "ghost"}
|
||||||
className={`w-full justify-start text-left transition-all duration-200 h-8 text-xs ${
|
className={`w-full justify-start text-left transition-all duration-200 h-8 text-xs ${
|
||||||
|
36
src/components/services/logistics-tab.tsx
Normal file
36
src/components/services/logistics-tab.tsx
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { Card } from '@/components/ui/card'
|
||||||
|
|
||||||
|
export function LogisticsTab() {
|
||||||
|
return (
|
||||||
|
<div className="h-full">
|
||||||
|
<Card className="h-full bg-white/5 backdrop-blur border-white/10 p-6">
|
||||||
|
<div className="h-full flex items-center justify-center">
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="w-16 h-16 bg-white/10 rounded-full flex items-center justify-center mx-auto mb-4">
|
||||||
|
<svg
|
||||||
|
className="w-8 h-8 text-white/50"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M8 7h12m0 0l-4-4m4 4l-4 4m0 6H4m0 0l4 4m-4-4l4-4"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<h3 className="text-lg font-semibold text-white mb-2">Логистика</h3>
|
||||||
|
<p className="text-white/70 text-sm max-w-md">
|
||||||
|
Раздел логистики находится в разработке.
|
||||||
|
Здесь будут инструменты для управления доставкой и складскими операциями.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
67
src/components/services/services-dashboard.tsx
Normal file
67
src/components/services/services-dashboard.tsx
Normal file
@ -0,0 +1,67 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||||
|
import { Sidebar } from '@/components/dashboard/sidebar'
|
||||||
|
import { ServicesTab } from './services-tab'
|
||||||
|
import { SuppliesTab } from './supplies-tab'
|
||||||
|
import { LogisticsTab } from './logistics-tab'
|
||||||
|
|
||||||
|
export function ServicesDashboard() {
|
||||||
|
return (
|
||||||
|
<div className="h-screen bg-gradient-smooth flex overflow-hidden">
|
||||||
|
<Sidebar />
|
||||||
|
<main className="flex-1 ml-56 px-6 py-4 overflow-hidden">
|
||||||
|
<div className="h-full w-full flex flex-col">
|
||||||
|
{/* Заголовок - фиксированная высота */}
|
||||||
|
<div className="flex items-center justify-between mb-4 flex-shrink-0">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-xl font-bold text-white mb-1">Услуги</h1>
|
||||||
|
<p className="text-white/70 text-sm">Управление услугами, расходниками и логистикой</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Основной контент с табами */}
|
||||||
|
<div className="flex-1 overflow-hidden">
|
||||||
|
<Tabs defaultValue="services" className="h-full flex flex-col">
|
||||||
|
<TabsList className="grid w-full grid-cols-3 bg-white/5 backdrop-blur border-white/10 flex-shrink-0">
|
||||||
|
<TabsTrigger
|
||||||
|
value="services"
|
||||||
|
className="data-[state=active]:bg-white/20 data-[state=active]:text-white text-white/70"
|
||||||
|
>
|
||||||
|
Услуги
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger
|
||||||
|
value="logistics"
|
||||||
|
className="data-[state=active]:bg-white/20 data-[state=active]:text-white text-white/70"
|
||||||
|
>
|
||||||
|
Логистика
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger
|
||||||
|
value="supplies"
|
||||||
|
className="data-[state=active]:bg-white/20 data-[state=active]:text-white text-white/70"
|
||||||
|
>
|
||||||
|
Расходники
|
||||||
|
</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
|
||||||
|
{/* Контент вкладок */}
|
||||||
|
<div className="flex-1 overflow-hidden mt-4">
|
||||||
|
<TabsContent value="services" className="h-full m-0 overflow-hidden">
|
||||||
|
<ServicesTab />
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="logistics" className="h-full m-0 overflow-hidden">
|
||||||
|
<LogisticsTab />
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="supplies" className="h-full m-0 overflow-hidden">
|
||||||
|
<SuppliesTab />
|
||||||
|
</TabsContent>
|
||||||
|
</div>
|
||||||
|
</Tabs>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
461
src/components/services/services-tab.tsx
Normal file
461
src/components/services/services-tab.tsx
Normal file
@ -0,0 +1,461 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
import { useQuery, useMutation } from '@apollo/client'
|
||||||
|
import { Card } from '@/components/ui/card'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Input } from '@/components/ui/input'
|
||||||
|
import { Label } from '@/components/ui/label'
|
||||||
|
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog'
|
||||||
|
import {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogTitle,
|
||||||
|
AlertDialogTrigger
|
||||||
|
} from '@/components/ui/alert-dialog'
|
||||||
|
import { Plus, Edit, Trash2, Upload } from 'lucide-react'
|
||||||
|
import { toast } from "sonner"
|
||||||
|
import { useAuth } from '@/hooks/useAuth'
|
||||||
|
import { GET_MY_SERVICES } from '@/graphql/queries'
|
||||||
|
import { CREATE_SERVICE, UPDATE_SERVICE, DELETE_SERVICE } from '@/graphql/mutations'
|
||||||
|
|
||||||
|
interface Service {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
description?: string
|
||||||
|
price: number
|
||||||
|
imageUrl?: string
|
||||||
|
createdAt: string
|
||||||
|
updatedAt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ServicesTab() {
|
||||||
|
const { user } = useAuth()
|
||||||
|
const [isDialogOpen, setIsDialogOpen] = useState(false)
|
||||||
|
const [editingService, setEditingService] = useState<Service | null>(null)
|
||||||
|
const [formData, setFormData] = useState({
|
||||||
|
name: '',
|
||||||
|
description: '',
|
||||||
|
price: '',
|
||||||
|
imageUrl: ''
|
||||||
|
})
|
||||||
|
const [imageFile, setImageFile] = useState<File | null>(null)
|
||||||
|
const [isUploading, setIsUploading] = useState(false)
|
||||||
|
|
||||||
|
// GraphQL запросы и мутации
|
||||||
|
const { data, loading, error, refetch } = useQuery(GET_MY_SERVICES, {
|
||||||
|
skip: user?.organization?.type !== 'FULFILLMENT'
|
||||||
|
})
|
||||||
|
const [createService] = useMutation(CREATE_SERVICE)
|
||||||
|
const [updateService] = useMutation(UPDATE_SERVICE)
|
||||||
|
const [deleteService] = useMutation(DELETE_SERVICE)
|
||||||
|
|
||||||
|
const services = data?.myServices || []
|
||||||
|
|
||||||
|
// Логирование для отладки
|
||||||
|
console.log('Services data:', services)
|
||||||
|
|
||||||
|
const resetForm = () => {
|
||||||
|
setFormData({
|
||||||
|
name: '',
|
||||||
|
description: '',
|
||||||
|
price: '',
|
||||||
|
imageUrl: ''
|
||||||
|
})
|
||||||
|
setImageFile(null)
|
||||||
|
setEditingService(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleEdit = (service: Service) => {
|
||||||
|
setEditingService(service)
|
||||||
|
setFormData({
|
||||||
|
name: service.name,
|
||||||
|
description: service.description || '',
|
||||||
|
price: service.price.toString(),
|
||||||
|
imageUrl: service.imageUrl || ''
|
||||||
|
})
|
||||||
|
setIsDialogOpen(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDelete = async (serviceId: string) => {
|
||||||
|
try {
|
||||||
|
await deleteService({
|
||||||
|
variables: { id: serviceId }
|
||||||
|
})
|
||||||
|
await refetch()
|
||||||
|
toast.success('Услуга успешно удалена')
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error deleting service:', error)
|
||||||
|
toast.error('Ошибка при удалении услуги')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleImageUpload = async (file: File) => {
|
||||||
|
if (!user?.id) return
|
||||||
|
|
||||||
|
setIsUploading(true)
|
||||||
|
try {
|
||||||
|
const formData = new FormData()
|
||||||
|
formData.append('file', file)
|
||||||
|
formData.append('userId', user.id)
|
||||||
|
formData.append('type', 'service')
|
||||||
|
|
||||||
|
const response = await fetch('/api/upload-service-image', {
|
||||||
|
method: 'POST',
|
||||||
|
body: formData
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Failed to upload image')
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await response.json()
|
||||||
|
console.log('Upload result:', result)
|
||||||
|
setFormData(prev => ({ ...prev, imageUrl: result.url }))
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error uploading image:', error)
|
||||||
|
toast.error('Ошибка при загрузке изображения')
|
||||||
|
} finally {
|
||||||
|
setIsUploading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const uploadImageAndGetUrl = async (file: File): Promise<string> => {
|
||||||
|
if (!user?.id) throw new Error('User not found')
|
||||||
|
|
||||||
|
const formData = new FormData()
|
||||||
|
formData.append('file', file)
|
||||||
|
formData.append('userId', user.id)
|
||||||
|
formData.append('type', 'service')
|
||||||
|
|
||||||
|
const response = await fetch('/api/upload-service-image', {
|
||||||
|
method: 'POST',
|
||||||
|
body: formData
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Failed to upload image')
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await response.json()
|
||||||
|
console.log('Upload result:', result)
|
||||||
|
return result.url
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
|
||||||
|
if (!formData.name.trim() || !formData.price) {
|
||||||
|
toast.error('Заполните обязательные поля')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
let imageUrl = formData.imageUrl
|
||||||
|
|
||||||
|
// Загружаем изображение если выбрано
|
||||||
|
if (imageFile) {
|
||||||
|
const uploadResult = await uploadImageAndGetUrl(imageFile)
|
||||||
|
imageUrl = uploadResult
|
||||||
|
}
|
||||||
|
|
||||||
|
const input = {
|
||||||
|
name: formData.name,
|
||||||
|
description: formData.description || undefined,
|
||||||
|
price: parseFloat(formData.price),
|
||||||
|
imageUrl: imageUrl || undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('Submitting service with data:', input)
|
||||||
|
|
||||||
|
if (editingService) {
|
||||||
|
await updateService({
|
||||||
|
variables: { id: editingService.id, input }
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
await createService({
|
||||||
|
variables: { input }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
await refetch()
|
||||||
|
setIsDialogOpen(false)
|
||||||
|
resetForm()
|
||||||
|
|
||||||
|
toast.success(editingService ? 'Услуга успешно обновлена' : 'Услуга успешно создана')
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error saving service:', error)
|
||||||
|
toast.error('Ошибка при сохранении услуги')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="h-full flex flex-col">
|
||||||
|
<Card className="flex-1 bg-white/5 backdrop-blur border-white/10 p-6 overflow-hidden">
|
||||||
|
{/* Заголовок и кнопка добавления */}
|
||||||
|
<div className="flex items-center justify-between mb-6">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-lg font-semibold text-white mb-1">Мои услуги</h2>
|
||||||
|
<p className="text-white/70 text-sm">Управление вашими услугами</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
<Button
|
||||||
|
className="bg-gradient-to-r from-purple-600 to-pink-600 hover:from-purple-700 hover:to-pink-700 text-white border-0 shadow-lg shadow-purple-500/25 transition-all duration-300"
|
||||||
|
onClick={() => {
|
||||||
|
resetForm()
|
||||||
|
setIsDialogOpen(true)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Plus className="w-4 h-4 mr-2" />
|
||||||
|
Добавить услугу
|
||||||
|
</Button>
|
||||||
|
</DialogTrigger>
|
||||||
|
|
||||||
|
<DialogContent className="max-w-md bg-gradient-to-br from-purple-900/95 via-purple-800/95 to-pink-900/95 backdrop-blur-xl border border-purple-500/30 text-white shadow-2xl shadow-purple-500/20">
|
||||||
|
<DialogHeader className="pb-6">
|
||||||
|
<DialogTitle className="text-2xl font-bold bg-gradient-to-r from-purple-300 to-pink-300 bg-clip-text text-transparent">
|
||||||
|
{editingService ? 'Редактировать услугу' : 'Добавить услугу'}
|
||||||
|
</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-6">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="name" className="text-purple-200 text-sm font-medium">Название услуги *</Label>
|
||||||
|
<Input
|
||||||
|
id="name"
|
||||||
|
value={formData.name}
|
||||||
|
onChange={(e) => setFormData(prev => ({ ...prev, name: e.target.value }))}
|
||||||
|
className="bg-white/5 border-purple-400/30 text-white placeholder:text-purple-300/50 focus:border-purple-300 focus:ring-2 focus:ring-purple-500/20 transition-all"
|
||||||
|
placeholder="Введите название услуги"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="price" className="text-purple-200 text-sm font-medium">Цена за единицу (₽) *</Label>
|
||||||
|
<Input
|
||||||
|
id="price"
|
||||||
|
type="number"
|
||||||
|
step="0.01"
|
||||||
|
min="0"
|
||||||
|
value={formData.price}
|
||||||
|
onChange={(e) => setFormData(prev => ({ ...prev, price: e.target.value }))}
|
||||||
|
className="bg-white/5 border-purple-400/30 text-white placeholder:text-purple-300/50 focus:border-purple-300 focus:ring-2 focus:ring-purple-500/20 transition-all"
|
||||||
|
placeholder="0.00"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="description" className="text-purple-200 text-sm font-medium">Описание</Label>
|
||||||
|
<Input
|
||||||
|
id="description"
|
||||||
|
value={formData.description}
|
||||||
|
onChange={(e) => setFormData(prev => ({ ...prev, description: e.target.value }))}
|
||||||
|
className="bg-white/5 border-purple-400/30 text-white placeholder:text-purple-300/50 focus:border-purple-300 focus:ring-2 focus:ring-purple-500/20 transition-all"
|
||||||
|
placeholder="Описание услуги"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-purple-200 text-sm font-medium">Изображение</Label>
|
||||||
|
<Input
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
onChange={(e) => {
|
||||||
|
const file = e.target.files?.[0]
|
||||||
|
if (file) {
|
||||||
|
setImageFile(file)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="bg-white/5 border-purple-400/30 text-white file:bg-gradient-to-r file:from-purple-500 file:to-pink-500 file:text-white file:border-0 file:rounded-lg file:px-4 file:py-2 file:mr-3 focus:border-purple-300 focus:ring-2 focus:ring-purple-500/20 transition-all"
|
||||||
|
/>
|
||||||
|
{formData.imageUrl && (
|
||||||
|
<div className="mt-3">
|
||||||
|
<img
|
||||||
|
src={formData.imageUrl}
|
||||||
|
alt="Preview"
|
||||||
|
className="w-20 h-20 object-cover rounded-lg border border-purple-400/30 shadow-lg"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-3 pt-8">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => {
|
||||||
|
setIsDialogOpen(false)
|
||||||
|
resetForm()
|
||||||
|
}}
|
||||||
|
className="flex-1 border-purple-400/30 text-purple-200 hover:bg-purple-500/10 hover:border-purple-300 transition-all duration-300"
|
||||||
|
>
|
||||||
|
Отмена
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
disabled={loading || isUploading}
|
||||||
|
className="flex-1 bg-gradient-to-r from-purple-600 to-pink-600 hover:from-purple-500 hover:to-pink-500 text-white border-0 shadow-lg shadow-purple-500/25 hover:shadow-purple-500/40 transition-all duration-300"
|
||||||
|
>
|
||||||
|
{loading || isUploading ? 'Сохранение...' : 'Сохранить'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Таблица услуг */}
|
||||||
|
<div className="overflow-auto flex-1">
|
||||||
|
{loading ? (
|
||||||
|
<div className="h-full flex items-center justify-center">
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="w-16 h-16 bg-white/10 rounded-full flex items-center justify-center mx-auto mb-4 animate-spin">
|
||||||
|
<svg className="w-8 h-8 text-white/50" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<p className="text-white/70 text-sm">Загрузка услуг...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : error ? (
|
||||||
|
<div className="h-full flex items-center justify-center">
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="w-16 h-16 bg-red-500/20 rounded-full flex items-center justify-center mx-auto mb-4">
|
||||||
|
<svg className="w-8 h-8 text-red-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<h3 className="text-lg font-semibold text-white mb-2">Ошибка загрузки</h3>
|
||||||
|
<p className="text-white/70 text-sm mb-4">
|
||||||
|
Не удалось загрузить услуги
|
||||||
|
</p>
|
||||||
|
<Button
|
||||||
|
onClick={() => refetch()}
|
||||||
|
className="bg-gradient-to-r from-purple-500 to-purple-600 hover:from-purple-600 hover:to-purple-700 text-white"
|
||||||
|
>
|
||||||
|
Попробовать снова
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : services.length === 0 ? (
|
||||||
|
<div className="h-full flex items-center justify-center">
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="w-16 h-16 bg-white/10 rounded-full flex items-center justify-center mx-auto mb-4">
|
||||||
|
<Plus className="w-8 h-8 text-white/50" />
|
||||||
|
</div>
|
||||||
|
<h3 className="text-lg font-semibold text-white mb-2">Пока нет услуг</h3>
|
||||||
|
<p className="text-white/70 text-sm mb-4">
|
||||||
|
Создайте свою первую услугу, чтобы начать работу
|
||||||
|
</p>
|
||||||
|
<Button
|
||||||
|
onClick={() => {
|
||||||
|
resetForm()
|
||||||
|
setIsDialogOpen(true)
|
||||||
|
}}
|
||||||
|
className="bg-gradient-to-r from-purple-500 to-purple-600 hover:from-purple-600 hover:to-purple-700 text-white"
|
||||||
|
>
|
||||||
|
<Plus className="w-4 h-4 mr-2" />
|
||||||
|
Добавить услугу
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="bg-white/5 rounded-lg border border-white/10 overflow-hidden">
|
||||||
|
<table className="w-full">
|
||||||
|
<thead className="bg-white/5">
|
||||||
|
<tr>
|
||||||
|
<th className="text-left p-4 text-white font-medium">№</th>
|
||||||
|
<th className="text-left p-4 text-white font-medium">Фото</th>
|
||||||
|
<th className="text-left p-4 text-white font-medium">Название</th>
|
||||||
|
<th className="text-left p-4 text-white font-medium">Цена за единицу</th>
|
||||||
|
<th className="text-left p-4 text-white font-medium">Описание</th>
|
||||||
|
<th className="text-left p-4 text-white font-medium">Действия</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{services.map((service: Service, index: number) => (
|
||||||
|
<tr key={service.id} className="border-t border-white/10 hover:bg-white/5">
|
||||||
|
<td className="p-4 text-white/80">{index + 1}</td>
|
||||||
|
<td className="p-4">
|
||||||
|
{service.imageUrl ? (
|
||||||
|
<img
|
||||||
|
src={service.imageUrl}
|
||||||
|
alt={service.name}
|
||||||
|
className="w-12 h-12 object-cover rounded border border-white/20"
|
||||||
|
onError={(e) => {
|
||||||
|
console.error('Image failed to load:', service.imageUrl, e)
|
||||||
|
}}
|
||||||
|
onLoad={() => console.log('Image loaded successfully:', service.imageUrl)}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="w-12 h-12 bg-white/10 rounded flex items-center justify-center">
|
||||||
|
<Upload className="w-5 h-5 text-white/50" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="p-4 text-white font-medium">{service.name}</td>
|
||||||
|
<td className="p-4 text-white/80">{service.price.toLocaleString()} ₽</td>
|
||||||
|
<td className="p-4 text-white/80">{service.description || '—'}</td>
|
||||||
|
<td className="p-4">
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => handleEdit(service)}
|
||||||
|
className="border-white/20 text-white hover:bg-white/10"
|
||||||
|
>
|
||||||
|
<Edit className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
<AlertDialog>
|
||||||
|
<AlertDialogTrigger asChild>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
className="border-red-500/50 text-red-400 hover:bg-red-500/10"
|
||||||
|
>
|
||||||
|
<Trash2 className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
</AlertDialogTrigger>
|
||||||
|
<AlertDialogContent className="bg-gradient-to-br from-red-900/95 via-red-800/95 to-red-900/95 backdrop-blur-xl border border-red-500/30 text-white shadow-2xl shadow-red-500/20">
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle className="text-xl font-bold bg-gradient-to-r from-red-300 to-red-300 bg-clip-text text-transparent">
|
||||||
|
Подтвердите удаление
|
||||||
|
</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription className="text-red-200">
|
||||||
|
Вы действительно хотите удалить услугу “{service.name}”? Это действие необратимо.
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter className="gap-3">
|
||||||
|
<AlertDialogCancel className="border-red-400/30 text-red-200 hover:bg-red-500/10 hover:border-red-300 transition-all duration-300">
|
||||||
|
Отмена
|
||||||
|
</AlertDialogCancel>
|
||||||
|
<AlertDialogAction
|
||||||
|
onClick={() => handleDelete(service.id)}
|
||||||
|
className="bg-gradient-to-r from-red-600 to-red-700 hover:from-red-700 hover:to-red-800 text-white border-0 shadow-lg shadow-red-500/25 hover:shadow-red-500/40 transition-all duration-300"
|
||||||
|
>
|
||||||
|
Удалить
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
495
src/components/services/supplies-tab.tsx
Normal file
495
src/components/services/supplies-tab.tsx
Normal file
@ -0,0 +1,495 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { useQuery, useMutation } from '@apollo/client'
|
||||||
|
import { Card } from '@/components/ui/card'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Input } from '@/components/ui/input'
|
||||||
|
import { Label } from '@/components/ui/label'
|
||||||
|
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog'
|
||||||
|
import {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogTitle,
|
||||||
|
AlertDialogTrigger
|
||||||
|
} from '@/components/ui/alert-dialog'
|
||||||
|
import { Plus, Edit, Trash2, Upload, Package } from 'lucide-react'
|
||||||
|
import { toast } from "sonner"
|
||||||
|
import { useAuth } from '@/hooks/useAuth'
|
||||||
|
import { GET_MY_SUPPLIES } from '@/graphql/queries'
|
||||||
|
import { CREATE_SUPPLY, UPDATE_SUPPLY, DELETE_SUPPLY } from '@/graphql/mutations'
|
||||||
|
|
||||||
|
interface Supply {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
description?: string
|
||||||
|
price: number
|
||||||
|
quantity: number
|
||||||
|
imageUrl?: string
|
||||||
|
createdAt: string
|
||||||
|
updatedAt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SuppliesTab() {
|
||||||
|
const { user } = useAuth()
|
||||||
|
const [isDialogOpen, setIsDialogOpen] = useState(false)
|
||||||
|
const [editingSupply, setEditingSupply] = useState<Supply | null>(null)
|
||||||
|
const [formData, setFormData] = useState({
|
||||||
|
name: '',
|
||||||
|
description: '',
|
||||||
|
price: '',
|
||||||
|
quantity: '',
|
||||||
|
imageUrl: ''
|
||||||
|
})
|
||||||
|
const [imageFile, setImageFile] = useState<File | null>(null)
|
||||||
|
const [isUploading, setIsUploading] = useState(false)
|
||||||
|
|
||||||
|
// GraphQL запросы и мутации
|
||||||
|
const { data, loading, error, refetch } = useQuery(GET_MY_SUPPLIES, {
|
||||||
|
skip: user?.organization?.type !== 'FULFILLMENT'
|
||||||
|
})
|
||||||
|
const [createSupply] = useMutation(CREATE_SUPPLY)
|
||||||
|
const [updateSupply] = useMutation(UPDATE_SUPPLY)
|
||||||
|
const [deleteSupply] = useMutation(DELETE_SUPPLY)
|
||||||
|
|
||||||
|
const supplies = data?.mySupplies || []
|
||||||
|
|
||||||
|
const resetForm = () => {
|
||||||
|
setFormData({
|
||||||
|
name: '',
|
||||||
|
description: '',
|
||||||
|
price: '',
|
||||||
|
quantity: '',
|
||||||
|
imageUrl: ''
|
||||||
|
})
|
||||||
|
setImageFile(null)
|
||||||
|
setEditingSupply(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleEdit = (supply: Supply) => {
|
||||||
|
setEditingSupply(supply)
|
||||||
|
setFormData({
|
||||||
|
name: supply.name,
|
||||||
|
description: supply.description || '',
|
||||||
|
price: supply.price.toString(),
|
||||||
|
quantity: supply.quantity.toString(),
|
||||||
|
imageUrl: supply.imageUrl || ''
|
||||||
|
})
|
||||||
|
setIsDialogOpen(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDelete = async (supplyId: string) => {
|
||||||
|
try {
|
||||||
|
await deleteSupply({
|
||||||
|
variables: { id: supplyId }
|
||||||
|
})
|
||||||
|
await refetch()
|
||||||
|
toast.success('Расходник успешно удален')
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error deleting supply:', error)
|
||||||
|
toast.error('Ошибка при удалении расходника')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleImageUpload = async (file: File) => {
|
||||||
|
if (!user?.id) return
|
||||||
|
|
||||||
|
setIsUploading(true)
|
||||||
|
try {
|
||||||
|
const formData = new FormData()
|
||||||
|
formData.append('file', file)
|
||||||
|
formData.append('userId', user.id)
|
||||||
|
formData.append('type', 'supply')
|
||||||
|
|
||||||
|
const response = await fetch('/api/upload-service-image', {
|
||||||
|
method: 'POST',
|
||||||
|
body: formData
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Failed to upload image')
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await response.json()
|
||||||
|
console.log('Upload result:', result)
|
||||||
|
setFormData(prev => ({ ...prev, imageUrl: result.url }))
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error uploading image:', error)
|
||||||
|
toast.error('Ошибка при загрузке изображения')
|
||||||
|
} finally {
|
||||||
|
setIsUploading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const uploadImageAndGetUrl = async (file: File): Promise<string> => {
|
||||||
|
if (!user?.id) throw new Error('User not found')
|
||||||
|
|
||||||
|
const formData = new FormData()
|
||||||
|
formData.append('file', file)
|
||||||
|
formData.append('userId', user.id)
|
||||||
|
formData.append('type', 'supply')
|
||||||
|
|
||||||
|
const response = await fetch('/api/upload-service-image', {
|
||||||
|
method: 'POST',
|
||||||
|
body: formData
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Failed to upload image')
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await response.json()
|
||||||
|
console.log('Upload result:', result)
|
||||||
|
return result.url
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
|
||||||
|
if (!formData.name.trim() || !formData.price || !formData.quantity) {
|
||||||
|
toast.error('Заполните обязательные поля')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const quantity = parseInt(formData.quantity)
|
||||||
|
if (quantity < 0) {
|
||||||
|
toast.error('Количество не может быть отрицательным')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
let imageUrl = formData.imageUrl
|
||||||
|
|
||||||
|
// Загружаем изображение если выбрано
|
||||||
|
if (imageFile) {
|
||||||
|
const uploadResult = await uploadImageAndGetUrl(imageFile)
|
||||||
|
imageUrl = uploadResult
|
||||||
|
}
|
||||||
|
|
||||||
|
const input = {
|
||||||
|
name: formData.name,
|
||||||
|
description: formData.description || undefined,
|
||||||
|
price: parseFloat(formData.price),
|
||||||
|
quantity: quantity,
|
||||||
|
imageUrl: imageUrl || undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
if (editingSupply) {
|
||||||
|
await updateSupply({
|
||||||
|
variables: { id: editingSupply.id, input }
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
await createSupply({
|
||||||
|
variables: { input }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
await refetch()
|
||||||
|
setIsDialogOpen(false)
|
||||||
|
resetForm()
|
||||||
|
|
||||||
|
toast.success(editingSupply ? 'Расходник успешно обновлен' : 'Расходник успешно создан')
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error saving supply:', error)
|
||||||
|
toast.error('Ошибка при сохранении расходника')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="h-full flex flex-col">
|
||||||
|
<Card className="flex-1 bg-white/5 backdrop-blur border-white/10 p-6 overflow-hidden">
|
||||||
|
{/* Заголовок и кнопка добавления */}
|
||||||
|
<div className="flex items-center justify-between mb-6">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-lg font-semibold text-white mb-1">Мои расходники</h2>
|
||||||
|
<p className="text-white/70 text-sm">Управление вашими расходными материалами</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
<Button
|
||||||
|
className="bg-gradient-to-r from-purple-600 to-pink-600 hover:from-purple-700 hover:to-pink-700 text-white border-0 shadow-lg shadow-purple-500/25 transition-all duration-300"
|
||||||
|
onClick={() => {
|
||||||
|
resetForm()
|
||||||
|
setIsDialogOpen(true)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Plus className="w-4 h-4 mr-2" />
|
||||||
|
Добавить расходник
|
||||||
|
</Button>
|
||||||
|
</DialogTrigger>
|
||||||
|
|
||||||
|
<DialogContent className="max-w-md bg-gradient-to-br from-purple-900/95 via-purple-800/95 to-pink-900/95 backdrop-blur-xl border border-purple-500/30 text-white shadow-2xl shadow-purple-500/20">
|
||||||
|
<DialogHeader className="pb-6">
|
||||||
|
<DialogTitle className="text-2xl font-bold bg-gradient-to-r from-purple-300 to-pink-300 bg-clip-text text-transparent">
|
||||||
|
{editingSupply ? 'Редактировать расходник' : 'Добавить расходник'}
|
||||||
|
</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-6">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="name" className="text-purple-200 text-sm font-medium">Название расходника *</Label>
|
||||||
|
<Input
|
||||||
|
id="name"
|
||||||
|
value={formData.name}
|
||||||
|
onChange={(e) => setFormData(prev => ({ ...prev, name: e.target.value }))}
|
||||||
|
className="bg-white/5 border-purple-400/30 text-white placeholder:text-purple-300/50 focus:border-purple-300 focus:ring-2 focus:ring-purple-500/20 transition-all"
|
||||||
|
placeholder="Введите название расходника"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="price" className="text-purple-200 text-sm font-medium">Цена за единицу (₽) *</Label>
|
||||||
|
<Input
|
||||||
|
id="price"
|
||||||
|
type="number"
|
||||||
|
step="0.01"
|
||||||
|
min="0"
|
||||||
|
value={formData.price}
|
||||||
|
onChange={(e) => setFormData(prev => ({ ...prev, price: e.target.value }))}
|
||||||
|
className="bg-white/5 border-purple-400/30 text-white placeholder:text-purple-300/50 focus:border-purple-300 focus:ring-2 focus:ring-purple-500/20 transition-all"
|
||||||
|
placeholder="0.00"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="quantity" className="text-purple-200 text-sm font-medium">Количество *</Label>
|
||||||
|
<Input
|
||||||
|
id="quantity"
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
value={formData.quantity}
|
||||||
|
onChange={(e) => setFormData(prev => ({ ...prev, quantity: e.target.value }))}
|
||||||
|
className="bg-white/5 border-purple-400/30 text-white placeholder:text-purple-300/50 focus:border-purple-300 focus:ring-2 focus:ring-purple-500/20 transition-all"
|
||||||
|
placeholder="0"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="description" className="text-purple-200 text-sm font-medium">Описание</Label>
|
||||||
|
<Input
|
||||||
|
id="description"
|
||||||
|
value={formData.description}
|
||||||
|
onChange={(e) => setFormData(prev => ({ ...prev, description: e.target.value }))}
|
||||||
|
className="bg-white/5 border-purple-400/30 text-white placeholder:text-purple-300/50 focus:border-purple-300 focus:ring-2 focus:ring-purple-500/20 transition-all"
|
||||||
|
placeholder="Описание расходника"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-purple-200 text-sm font-medium">Изображение</Label>
|
||||||
|
<Input
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
onChange={(e) => {
|
||||||
|
const file = e.target.files?.[0]
|
||||||
|
if (file) {
|
||||||
|
setImageFile(file)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="bg-white/5 border-purple-400/30 text-white file:bg-gradient-to-r file:from-purple-500 file:to-pink-500 file:text-white file:border-0 file:rounded-lg file:px-4 file:py-2 file:mr-3 focus:border-purple-300 focus:ring-2 focus:ring-purple-500/20 transition-all"
|
||||||
|
/>
|
||||||
|
{formData.imageUrl && (
|
||||||
|
<div className="mt-3">
|
||||||
|
<img
|
||||||
|
src={formData.imageUrl}
|
||||||
|
alt="Preview"
|
||||||
|
className="w-20 h-20 object-cover rounded-lg border border-purple-400/30 shadow-lg"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-3 pt-8">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => {
|
||||||
|
setIsDialogOpen(false)
|
||||||
|
resetForm()
|
||||||
|
}}
|
||||||
|
className="flex-1 border-purple-400/30 text-purple-200 hover:bg-purple-500/10 hover:border-purple-300 transition-all duration-300"
|
||||||
|
>
|
||||||
|
Отмена
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
disabled={loading || isUploading}
|
||||||
|
className="flex-1 bg-gradient-to-r from-purple-600 to-pink-600 hover:from-purple-500 hover:to-pink-500 text-white border-0 shadow-lg shadow-purple-500/25 hover:shadow-purple-500/40 transition-all duration-300"
|
||||||
|
>
|
||||||
|
{loading || isUploading ? 'Сохранение...' : 'Сохранить'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Таблица расходников */}
|
||||||
|
<div className="overflow-auto flex-1">
|
||||||
|
{loading ? (
|
||||||
|
<div className="h-full flex items-center justify-center">
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="w-16 h-16 bg-white/10 rounded-full flex items-center justify-center mx-auto mb-4 animate-spin">
|
||||||
|
<svg className="w-8 h-8 text-white/50" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<p className="text-white/70 text-sm">Загрузка расходников...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : error ? (
|
||||||
|
<div className="h-full flex items-center justify-center">
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="w-16 h-16 bg-red-500/20 rounded-full flex items-center justify-center mx-auto mb-4">
|
||||||
|
<svg className="w-8 h-8 text-red-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<h3 className="text-lg font-semibold text-white mb-2">Ошибка загрузки</h3>
|
||||||
|
<p className="text-white/70 text-sm mb-4">
|
||||||
|
Не удалось загрузить расходники
|
||||||
|
</p>
|
||||||
|
<Button
|
||||||
|
onClick={() => refetch()}
|
||||||
|
className="bg-gradient-to-r from-purple-500 to-purple-600 hover:from-purple-600 hover:to-purple-700 text-white"
|
||||||
|
>
|
||||||
|
Попробовать снова
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : supplies.length === 0 ? (
|
||||||
|
<div className="h-full flex items-center justify-center">
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="w-16 h-16 bg-white/10 rounded-full flex items-center justify-center mx-auto mb-4">
|
||||||
|
<Package className="w-8 h-8 text-white/50" />
|
||||||
|
</div>
|
||||||
|
<h3 className="text-lg font-semibold text-white mb-2">Пока нет расходников</h3>
|
||||||
|
<p className="text-white/70 text-sm mb-4">
|
||||||
|
Добавьте свой первый расходник для управления складскими запасами
|
||||||
|
</p>
|
||||||
|
<Button
|
||||||
|
onClick={() => {
|
||||||
|
resetForm()
|
||||||
|
setIsDialogOpen(true)
|
||||||
|
}}
|
||||||
|
className="bg-gradient-to-r from-purple-500 to-purple-600 hover:from-purple-600 hover:to-purple-700 text-white"
|
||||||
|
>
|
||||||
|
<Plus className="w-4 h-4 mr-2" />
|
||||||
|
Добавить расходник
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="bg-white/5 rounded-lg border border-white/10 overflow-hidden">
|
||||||
|
<table className="w-full">
|
||||||
|
<thead className="bg-white/5">
|
||||||
|
<tr>
|
||||||
|
<th className="text-left p-4 text-white font-medium">№</th>
|
||||||
|
<th className="text-left p-4 text-white font-medium">Фото</th>
|
||||||
|
<th className="text-left p-4 text-white font-medium">Название</th>
|
||||||
|
<th className="text-left p-4 text-white font-medium">Цена за единицу</th>
|
||||||
|
<th className="text-left p-4 text-white font-medium">Количество</th>
|
||||||
|
<th className="text-left p-4 text-white font-medium">Описание</th>
|
||||||
|
<th className="text-left p-4 text-white font-medium">Действия</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{supplies.map((supply: Supply, index: number) => (
|
||||||
|
<tr key={supply.id} className="border-t border-white/10 hover:bg-white/5">
|
||||||
|
<td className="p-4 text-white/80">{index + 1}</td>
|
||||||
|
<td className="p-4">
|
||||||
|
{supply.imageUrl ? (
|
||||||
|
<img
|
||||||
|
src={supply.imageUrl}
|
||||||
|
alt={supply.name}
|
||||||
|
className="w-12 h-12 object-cover rounded border border-white/20"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="w-12 h-12 bg-white/10 rounded flex items-center justify-center">
|
||||||
|
<Upload className="w-5 h-5 text-white/50" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="p-4 text-white font-medium">{supply.name}</td>
|
||||||
|
<td className="p-4 text-white/80">{supply.price.toLocaleString()} ₽</td>
|
||||||
|
<td className="p-4">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-white/80">{supply.quantity} шт.</span>
|
||||||
|
{supply.quantity <= 10 && (
|
||||||
|
<span className="text-xs bg-yellow-500/20 text-yellow-400 px-2 py-1 rounded-full">
|
||||||
|
Мало
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{supply.quantity === 0 && (
|
||||||
|
<span className="text-xs bg-red-500/20 text-red-400 px-2 py-1 rounded-full">
|
||||||
|
Нет в наличии
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="p-4 text-white/80">{supply.description || '—'}</td>
|
||||||
|
<td className="p-4">
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => handleEdit(supply)}
|
||||||
|
className="border-white/20 text-white hover:bg-white/10"
|
||||||
|
>
|
||||||
|
<Edit className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
<AlertDialog>
|
||||||
|
<AlertDialogTrigger asChild>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
className="border-red-500/50 text-red-400 hover:bg-red-500/10"
|
||||||
|
>
|
||||||
|
<Trash2 className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
</AlertDialogTrigger>
|
||||||
|
<AlertDialogContent className="bg-gradient-to-br from-red-900/95 via-red-800/95 to-red-900/95 backdrop-blur-xl border border-red-500/30 text-white shadow-2xl shadow-red-500/20">
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle className="text-xl font-bold bg-gradient-to-r from-red-300 to-red-300 bg-clip-text text-transparent">
|
||||||
|
Подтвердите удаление
|
||||||
|
</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription className="text-red-200">
|
||||||
|
Вы действительно хотите удалить расходник “{supply.name}”? Это действие необратимо.
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter className="gap-3">
|
||||||
|
<AlertDialogCancel className="border-red-400/30 text-red-200 hover:bg-red-500/10 hover:border-red-300 transition-all duration-300">
|
||||||
|
Отмена
|
||||||
|
</AlertDialogCancel>
|
||||||
|
<AlertDialogAction
|
||||||
|
onClick={() => handleDelete(supply.id)}
|
||||||
|
className="bg-gradient-to-r from-red-600 to-red-700 hover:from-red-700 hover:to-red-800 text-white border-0 shadow-lg shadow-red-500/25 hover:shadow-red-500/40 transition-all duration-300"
|
||||||
|
>
|
||||||
|
Удалить
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
157
src/components/ui/alert-dialog.tsx
Normal file
157
src/components/ui/alert-dialog.tsx
Normal file
@ -0,0 +1,157 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import { buttonVariants } from "@/components/ui/button"
|
||||||
|
|
||||||
|
function AlertDialog({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AlertDialogPrimitive.Root>) {
|
||||||
|
return <AlertDialogPrimitive.Root data-slot="alert-dialog" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogTrigger({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AlertDialogPrimitive.Trigger>) {
|
||||||
|
return (
|
||||||
|
<AlertDialogPrimitive.Trigger data-slot="alert-dialog-trigger" {...props} />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogPortal({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AlertDialogPrimitive.Portal>) {
|
||||||
|
return (
|
||||||
|
<AlertDialogPrimitive.Portal data-slot="alert-dialog-portal" {...props} />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogOverlay({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AlertDialogPrimitive.Overlay>) {
|
||||||
|
return (
|
||||||
|
<AlertDialogPrimitive.Overlay
|
||||||
|
data-slot="alert-dialog-overlay"
|
||||||
|
className={cn(
|
||||||
|
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogContent({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AlertDialogPrimitive.Content>) {
|
||||||
|
return (
|
||||||
|
<AlertDialogPortal>
|
||||||
|
<AlertDialogOverlay />
|
||||||
|
<AlertDialogPrimitive.Content
|
||||||
|
data-slot="alert-dialog-content"
|
||||||
|
className={cn(
|
||||||
|
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</AlertDialogPortal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogHeader({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="alert-dialog-header"
|
||||||
|
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogFooter({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="alert-dialog-footer"
|
||||||
|
className={cn(
|
||||||
|
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogTitle({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AlertDialogPrimitive.Title>) {
|
||||||
|
return (
|
||||||
|
<AlertDialogPrimitive.Title
|
||||||
|
data-slot="alert-dialog-title"
|
||||||
|
className={cn("text-lg font-semibold", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogDescription({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AlertDialogPrimitive.Description>) {
|
||||||
|
return (
|
||||||
|
<AlertDialogPrimitive.Description
|
||||||
|
data-slot="alert-dialog-description"
|
||||||
|
className={cn("text-muted-foreground text-sm", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogAction({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AlertDialogPrimitive.Action>) {
|
||||||
|
return (
|
||||||
|
<AlertDialogPrimitive.Action
|
||||||
|
className={cn(buttonVariants(), className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogCancel({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AlertDialogPrimitive.Cancel>) {
|
||||||
|
return (
|
||||||
|
<AlertDialogPrimitive.Cancel
|
||||||
|
className={cn(buttonVariants({ variant: "outline" }), className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogPortal,
|
||||||
|
AlertDialogOverlay,
|
||||||
|
AlertDialogTrigger,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogTitle,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
}
|
@ -547,3 +547,91 @@ export const MARK_MESSAGES_AS_READ = gql`
|
|||||||
markMessagesAsRead(conversationId: $conversationId)
|
markMessagesAsRead(conversationId: $conversationId)
|
||||||
}
|
}
|
||||||
`
|
`
|
||||||
|
|
||||||
|
// Мутации для услуг
|
||||||
|
export const CREATE_SERVICE = gql`
|
||||||
|
mutation CreateService($input: ServiceInput!) {
|
||||||
|
createService(input: $input) {
|
||||||
|
success
|
||||||
|
message
|
||||||
|
service {
|
||||||
|
id
|
||||||
|
name
|
||||||
|
description
|
||||||
|
price
|
||||||
|
imageUrl
|
||||||
|
createdAt
|
||||||
|
updatedAt
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`
|
||||||
|
|
||||||
|
export const UPDATE_SERVICE = gql`
|
||||||
|
mutation UpdateService($id: ID!, $input: ServiceInput!) {
|
||||||
|
updateService(id: $id, input: $input) {
|
||||||
|
success
|
||||||
|
message
|
||||||
|
service {
|
||||||
|
id
|
||||||
|
name
|
||||||
|
description
|
||||||
|
price
|
||||||
|
imageUrl
|
||||||
|
createdAt
|
||||||
|
updatedAt
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`
|
||||||
|
|
||||||
|
export const DELETE_SERVICE = gql`
|
||||||
|
mutation DeleteService($id: ID!) {
|
||||||
|
deleteService(id: $id)
|
||||||
|
}
|
||||||
|
`
|
||||||
|
|
||||||
|
// Мутации для расходников
|
||||||
|
export const CREATE_SUPPLY = gql`
|
||||||
|
mutation CreateSupply($input: SupplyInput!) {
|
||||||
|
createSupply(input: $input) {
|
||||||
|
success
|
||||||
|
message
|
||||||
|
supply {
|
||||||
|
id
|
||||||
|
name
|
||||||
|
description
|
||||||
|
price
|
||||||
|
quantity
|
||||||
|
imageUrl
|
||||||
|
createdAt
|
||||||
|
updatedAt
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`
|
||||||
|
|
||||||
|
export const UPDATE_SUPPLY = gql`
|
||||||
|
mutation UpdateSupply($id: ID!, $input: SupplyInput!) {
|
||||||
|
updateSupply(id: $id, input: $input) {
|
||||||
|
success
|
||||||
|
message
|
||||||
|
supply {
|
||||||
|
id
|
||||||
|
name
|
||||||
|
description
|
||||||
|
price
|
||||||
|
quantity
|
||||||
|
imageUrl
|
||||||
|
createdAt
|
||||||
|
updatedAt
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`
|
||||||
|
|
||||||
|
export const DELETE_SUPPLY = gql`
|
||||||
|
mutation DeleteSupply($id: ID!) {
|
||||||
|
deleteSupply(id: $id)
|
||||||
|
}
|
||||||
|
`
|
@ -48,6 +48,35 @@ export const GET_ME = gql`
|
|||||||
}
|
}
|
||||||
`
|
`
|
||||||
|
|
||||||
|
export const GET_MY_SERVICES = gql`
|
||||||
|
query GetMyServices {
|
||||||
|
myServices {
|
||||||
|
id
|
||||||
|
name
|
||||||
|
description
|
||||||
|
price
|
||||||
|
imageUrl
|
||||||
|
createdAt
|
||||||
|
updatedAt
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`
|
||||||
|
|
||||||
|
export const GET_MY_SUPPLIES = gql`
|
||||||
|
query GetMySupplies {
|
||||||
|
mySupplies {
|
||||||
|
id
|
||||||
|
name
|
||||||
|
description
|
||||||
|
price
|
||||||
|
quantity
|
||||||
|
imageUrl
|
||||||
|
createdAt
|
||||||
|
updatedAt
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`
|
||||||
|
|
||||||
// Запросы для контрагентов
|
// Запросы для контрагентов
|
||||||
export const SEARCH_ORGANIZATIONS = gql`
|
export const SEARCH_ORGANIZATIONS = gql`
|
||||||
query SearchOrganizations($type: OrganizationType, $search: String) {
|
query SearchOrganizations($type: OrganizationType, $search: String) {
|
||||||
|
@ -423,6 +423,64 @@ export const resolvers = {
|
|||||||
// TODO: Здесь будет логика получения списка чатов
|
// TODO: Здесь будет логика получения списка чатов
|
||||||
// Пока возвращаем пустой массив, так как таблица сообщений еще не создана
|
// Пока возвращаем пустой массив, так как таблица сообщений еще не создана
|
||||||
return []
|
return []
|
||||||
|
},
|
||||||
|
|
||||||
|
// Мои услуги
|
||||||
|
myServices: async (_: unknown, __: unknown, context: Context) => {
|
||||||
|
if (!context.user) {
|
||||||
|
throw new GraphQLError('Требуется авторизация', {
|
||||||
|
extensions: { code: 'UNAUTHENTICATED' }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentUser = await prisma.user.findUnique({
|
||||||
|
where: { id: context.user.id },
|
||||||
|
include: { organization: true }
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!currentUser?.organization) {
|
||||||
|
throw new GraphQLError('У пользователя нет организации')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Проверяем, что это фулфилмент центр
|
||||||
|
if (currentUser.organization.type !== 'FULFILLMENT') {
|
||||||
|
throw new GraphQLError('Услуги доступны только для фулфилмент центров')
|
||||||
|
}
|
||||||
|
|
||||||
|
return await prisma.service.findMany({
|
||||||
|
where: { organizationId: currentUser.organization.id },
|
||||||
|
include: { organization: true },
|
||||||
|
orderBy: { createdAt: 'desc' }
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
// Мои расходники
|
||||||
|
mySupplies: async (_: unknown, __: unknown, context: Context) => {
|
||||||
|
if (!context.user) {
|
||||||
|
throw new GraphQLError('Требуется авторизация', {
|
||||||
|
extensions: { code: 'UNAUTHENTICATED' }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentUser = await prisma.user.findUnique({
|
||||||
|
where: { id: context.user.id },
|
||||||
|
include: { organization: true }
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!currentUser?.organization) {
|
||||||
|
throw new GraphQLError('У пользователя нет организации')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Проверяем, что это фулфилмент центр
|
||||||
|
if (currentUser.organization.type !== 'FULFILLMENT') {
|
||||||
|
throw new GraphQLError('Расходники доступны только для фулфилмент центров')
|
||||||
|
}
|
||||||
|
|
||||||
|
return await prisma.supply.findMany({
|
||||||
|
where: { organizationId: currentUser.organization.id },
|
||||||
|
include: { organization: true },
|
||||||
|
orderBy: { createdAt: 'desc' }
|
||||||
|
})
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
@ -1746,6 +1804,296 @@ export const resolvers = {
|
|||||||
// TODO: Здесь будет логика обновления статуса сообщений
|
// TODO: Здесь будет логика обновления статуса сообщений
|
||||||
// Пока возвращаем успешный ответ
|
// Пока возвращаем успешный ответ
|
||||||
return true
|
return true
|
||||||
|
},
|
||||||
|
|
||||||
|
// Создать услугу
|
||||||
|
createService: async (_: unknown, args: { input: { name: string; description?: string; price: number; imageUrl?: string } }, context: Context) => {
|
||||||
|
if (!context.user) {
|
||||||
|
throw new GraphQLError('Требуется авторизация', {
|
||||||
|
extensions: { code: 'UNAUTHENTICATED' }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentUser = await prisma.user.findUnique({
|
||||||
|
where: { id: context.user.id },
|
||||||
|
include: { organization: true }
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!currentUser?.organization) {
|
||||||
|
throw new GraphQLError('У пользователя нет организации')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Проверяем, что это фулфилмент центр
|
||||||
|
if (currentUser.organization.type !== 'FULFILLMENT') {
|
||||||
|
throw new GraphQLError('Услуги доступны только для фулфилмент центров')
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const service = await prisma.service.create({
|
||||||
|
data: {
|
||||||
|
name: args.input.name,
|
||||||
|
description: args.input.description,
|
||||||
|
price: args.input.price,
|
||||||
|
imageUrl: args.input.imageUrl,
|
||||||
|
organizationId: currentUser.organization.id
|
||||||
|
},
|
||||||
|
include: { organization: true }
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
message: 'Услуга успешно создана',
|
||||||
|
service
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error creating service:', error)
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: 'Ошибка при создании услуги'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// Обновить услугу
|
||||||
|
updateService: async (_: unknown, args: { id: string; input: { name: string; description?: string; price: number; imageUrl?: string } }, context: Context) => {
|
||||||
|
if (!context.user) {
|
||||||
|
throw new GraphQLError('Требуется авторизация', {
|
||||||
|
extensions: { code: 'UNAUTHENTICATED' }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentUser = await prisma.user.findUnique({
|
||||||
|
where: { id: context.user.id },
|
||||||
|
include: { organization: true }
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!currentUser?.organization) {
|
||||||
|
throw new GraphQLError('У пользователя нет организации')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Проверяем, что услуга принадлежит текущей организации
|
||||||
|
const existingService = await prisma.service.findFirst({
|
||||||
|
where: {
|
||||||
|
id: args.id,
|
||||||
|
organizationId: currentUser.organization.id
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!existingService) {
|
||||||
|
throw new GraphQLError('Услуга не найдена или нет доступа')
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const service = await prisma.service.update({
|
||||||
|
where: { id: args.id },
|
||||||
|
data: {
|
||||||
|
name: args.input.name,
|
||||||
|
description: args.input.description,
|
||||||
|
price: args.input.price,
|
||||||
|
imageUrl: args.input.imageUrl
|
||||||
|
},
|
||||||
|
include: { organization: true }
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
message: 'Услуга успешно обновлена',
|
||||||
|
service
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error updating service:', error)
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: 'Ошибка при обновлении услуги'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// Удалить услугу
|
||||||
|
deleteService: async (_: unknown, args: { id: string }, context: Context) => {
|
||||||
|
if (!context.user) {
|
||||||
|
throw new GraphQLError('Требуется авторизация', {
|
||||||
|
extensions: { code: 'UNAUTHENTICATED' }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentUser = await prisma.user.findUnique({
|
||||||
|
where: { id: context.user.id },
|
||||||
|
include: { organization: true }
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!currentUser?.organization) {
|
||||||
|
throw new GraphQLError('У пользователя нет организации')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Проверяем, что услуга принадлежит текущей организации
|
||||||
|
const existingService = await prisma.service.findFirst({
|
||||||
|
where: {
|
||||||
|
id: args.id,
|
||||||
|
organizationId: currentUser.organization.id
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!existingService) {
|
||||||
|
throw new GraphQLError('Услуга не найдена или нет доступа')
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await prisma.service.delete({
|
||||||
|
where: { id: args.id }
|
||||||
|
})
|
||||||
|
|
||||||
|
return true
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error deleting service:', error)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// Создать расходник
|
||||||
|
createSupply: async (_: unknown, args: { input: { name: string; description?: string; price: number; quantity: number; imageUrl?: string } }, context: Context) => {
|
||||||
|
if (!context.user) {
|
||||||
|
throw new GraphQLError('Требуется авторизация', {
|
||||||
|
extensions: { code: 'UNAUTHENTICATED' }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentUser = await prisma.user.findUnique({
|
||||||
|
where: { id: context.user.id },
|
||||||
|
include: { organization: true }
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!currentUser?.organization) {
|
||||||
|
throw new GraphQLError('У пользователя нет организации')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Проверяем, что это фулфилмент центр
|
||||||
|
if (currentUser.organization.type !== 'FULFILLMENT') {
|
||||||
|
throw new GraphQLError('Расходники доступны только для фулфилмент центров')
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const supply = await prisma.supply.create({
|
||||||
|
data: {
|
||||||
|
name: args.input.name,
|
||||||
|
description: args.input.description,
|
||||||
|
price: args.input.price,
|
||||||
|
quantity: args.input.quantity,
|
||||||
|
imageUrl: args.input.imageUrl,
|
||||||
|
organizationId: currentUser.organization.id
|
||||||
|
},
|
||||||
|
include: { organization: true }
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
message: 'Расходник успешно создан',
|
||||||
|
supply
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error creating supply:', error)
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: 'Ошибка при создании расходника'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// Обновить расходник
|
||||||
|
updateSupply: async (_: unknown, args: { id: string; input: { name: string; description?: string; price: number; quantity: number; imageUrl?: string } }, context: Context) => {
|
||||||
|
if (!context.user) {
|
||||||
|
throw new GraphQLError('Требуется авторизация', {
|
||||||
|
extensions: { code: 'UNAUTHENTICATED' }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentUser = await prisma.user.findUnique({
|
||||||
|
where: { id: context.user.id },
|
||||||
|
include: { organization: true }
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!currentUser?.organization) {
|
||||||
|
throw new GraphQLError('У пользователя нет организации')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Проверяем, что расходник принадлежит текущей организации
|
||||||
|
const existingSupply = await prisma.supply.findFirst({
|
||||||
|
where: {
|
||||||
|
id: args.id,
|
||||||
|
organizationId: currentUser.organization.id
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!existingSupply) {
|
||||||
|
throw new GraphQLError('Расходник не найден или нет доступа')
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const supply = await prisma.supply.update({
|
||||||
|
where: { id: args.id },
|
||||||
|
data: {
|
||||||
|
name: args.input.name,
|
||||||
|
description: args.input.description,
|
||||||
|
price: args.input.price,
|
||||||
|
quantity: args.input.quantity,
|
||||||
|
imageUrl: args.input.imageUrl
|
||||||
|
},
|
||||||
|
include: { organization: true }
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
message: 'Расходник успешно обновлен',
|
||||||
|
supply
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error updating supply:', error)
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: 'Ошибка при обновлении расходника'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// Удалить расходник
|
||||||
|
deleteSupply: async (_: unknown, args: { id: string }, context: Context) => {
|
||||||
|
if (!context.user) {
|
||||||
|
throw new GraphQLError('Требуется авторизация', {
|
||||||
|
extensions: { code: 'UNAUTHENTICATED' }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentUser = await prisma.user.findUnique({
|
||||||
|
where: { id: context.user.id },
|
||||||
|
include: { organization: true }
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!currentUser?.organization) {
|
||||||
|
throw new GraphQLError('У пользователя нет организации')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Проверяем, что расходник принадлежит текущей организации
|
||||||
|
const existingSupply = await prisma.supply.findFirst({
|
||||||
|
where: {
|
||||||
|
id: args.id,
|
||||||
|
organizationId: currentUser.organization.id
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!existingSupply) {
|
||||||
|
throw new GraphQLError('Расходник не найден или нет доступа')
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await prisma.supply.delete({
|
||||||
|
where: { id: args.id }
|
||||||
|
})
|
||||||
|
|
||||||
|
return true
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error deleting supply:', error)
|
||||||
|
return false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
@ -22,6 +22,12 @@ export const typeDefs = gql`
|
|||||||
|
|
||||||
# Список чатов (последние сообщения с каждым контрагентом)
|
# Список чатов (последние сообщения с каждым контрагентом)
|
||||||
conversations: [Conversation!]!
|
conversations: [Conversation!]!
|
||||||
|
|
||||||
|
# Услуги организации
|
||||||
|
myServices: [Service!]!
|
||||||
|
|
||||||
|
# Расходники организации
|
||||||
|
mySupplies: [Supply!]!
|
||||||
}
|
}
|
||||||
|
|
||||||
type Mutation {
|
type Mutation {
|
||||||
@ -61,6 +67,16 @@ export const typeDefs = gql`
|
|||||||
sendImageMessage(receiverOrganizationId: ID!, fileUrl: String!, fileName: String!, fileSize: Int!, fileType: String!): MessageResponse!
|
sendImageMessage(receiverOrganizationId: ID!, fileUrl: String!, fileName: String!, fileSize: Int!, fileType: String!): MessageResponse!
|
||||||
sendFileMessage(receiverOrganizationId: ID!, fileUrl: String!, fileName: String!, fileSize: Int!, fileType: String!): MessageResponse!
|
sendFileMessage(receiverOrganizationId: ID!, fileUrl: String!, fileName: String!, fileSize: Int!, fileType: String!): MessageResponse!
|
||||||
markMessagesAsRead(conversationId: ID!): Boolean!
|
markMessagesAsRead(conversationId: ID!): Boolean!
|
||||||
|
|
||||||
|
# Работа с услугами
|
||||||
|
createService(input: ServiceInput!): ServiceResponse!
|
||||||
|
updateService(id: ID!, input: ServiceInput!): ServiceResponse!
|
||||||
|
deleteService(id: ID!): Boolean!
|
||||||
|
|
||||||
|
# Работа с расходниками
|
||||||
|
createSupply(input: SupplyInput!): SupplyResponse!
|
||||||
|
updateSupply(id: ID!, input: SupplyInput!): SupplyResponse!
|
||||||
|
deleteSupply(id: ID!): Boolean!
|
||||||
}
|
}
|
||||||
|
|
||||||
# Типы данных
|
# Типы данных
|
||||||
@ -281,6 +297,58 @@ export const typeDefs = gql`
|
|||||||
messageData: Message
|
messageData: Message
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Типы для услуг
|
||||||
|
type Service {
|
||||||
|
id: ID!
|
||||||
|
name: String!
|
||||||
|
description: String
|
||||||
|
price: Float!
|
||||||
|
imageUrl: String
|
||||||
|
createdAt: String!
|
||||||
|
updatedAt: String!
|
||||||
|
organization: Organization!
|
||||||
|
}
|
||||||
|
|
||||||
|
input ServiceInput {
|
||||||
|
name: String!
|
||||||
|
description: String
|
||||||
|
price: Float!
|
||||||
|
imageUrl: String
|
||||||
|
}
|
||||||
|
|
||||||
|
type ServiceResponse {
|
||||||
|
success: Boolean!
|
||||||
|
message: String!
|
||||||
|
service: Service
|
||||||
|
}
|
||||||
|
|
||||||
|
# Типы для расходников
|
||||||
|
type Supply {
|
||||||
|
id: ID!
|
||||||
|
name: String!
|
||||||
|
description: String
|
||||||
|
price: Float!
|
||||||
|
quantity: Int!
|
||||||
|
imageUrl: String
|
||||||
|
createdAt: String!
|
||||||
|
updatedAt: String!
|
||||||
|
organization: Organization!
|
||||||
|
}
|
||||||
|
|
||||||
|
input SupplyInput {
|
||||||
|
name: String!
|
||||||
|
description: String
|
||||||
|
price: Float!
|
||||||
|
quantity: Int!
|
||||||
|
imageUrl: String
|
||||||
|
}
|
||||||
|
|
||||||
|
type SupplyResponse {
|
||||||
|
success: Boolean!
|
||||||
|
message: String!
|
||||||
|
supply: Supply
|
||||||
|
}
|
||||||
|
|
||||||
# JSON скаляр
|
# JSON скаляр
|
||||||
scalar JSON
|
scalar JSON
|
||||||
`
|
`
|
27
stack.env
Normal file
27
stack.env
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
# Приложение
|
||||||
|
EXTERNAL_PORT=3000
|
||||||
|
|
||||||
|
# База данных
|
||||||
|
DATABASE_URL=postgresql://username:password@host:5432/database_name
|
||||||
|
|
||||||
|
# JWT секрет для авторизации
|
||||||
|
JWT_SECRET=your_jwt_secret_key_here
|
||||||
|
|
||||||
|
# SMS сервис
|
||||||
|
SMS_AERO_EMAIL=your_sms_aero_email
|
||||||
|
SMS_AERO_API_KEY=your_sms_aero_api_key
|
||||||
|
SMS_DEV_MODE=false
|
||||||
|
|
||||||
|
# Маркетплейсы API
|
||||||
|
WILDBERRIES_API_URL=https://common-api.wildberries.ru
|
||||||
|
OZON_API_URL=https://api-seller.ozon.ru
|
||||||
|
|
||||||
|
# DaData API
|
||||||
|
DADATA_API_KEY=your_dadata_api_key
|
||||||
|
DADATA_API_URL=https://suggestions.dadata.ru
|
||||||
|
|
||||||
|
# AWS S3 настройки
|
||||||
|
AWS_ACCESS_KEY_ID=your_aws_access_key
|
||||||
|
AWS_SECRET_ACCESS_KEY=your_aws_secret_key
|
||||||
|
AWS_REGION=us-west-2
|
||||||
|
S3_BUCKET_NAME=your_s3_bucket_name
|
Reference in New Issue
Block a user