diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..bb6ee49 --- /dev/null +++ b/.dockerignore @@ -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 \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..82a1c0c --- /dev/null +++ b/Dockerfile @@ -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"] \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..ae689f8 --- /dev/null +++ b/docker-compose.yml @@ -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 \ No newline at end of file diff --git a/next.config.ts b/next.config.ts index e9ffa30..225e495 100644 --- a/next.config.ts +++ b/next.config.ts @@ -1,7 +1,7 @@ import type { NextConfig } from "next"; const nextConfig: NextConfig = { - /* config options here */ + output: 'standalone', }; export default nextConfig; diff --git a/package-lock.json b/package-lock.json index f6b771d..d9435dc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,6 +14,7 @@ "@as-integrations/next": "^3.2.0", "@aws-sdk/client-s3": "^3.846.0", "@prisma/client": "^6.12.0", + "@radix-ui/react-alert-dialog": "^1.1.14", "@radix-ui/react-avatar": "^1.1.10", "@radix-ui/react-checkbox": "^1.3.2", "@radix-ui/react-dialog": "^1.1.14", @@ -2752,6 +2753,34 @@ "integrity": "sha512-XnbHrrprsNqZKQhStrSwgRUQzoCI1glLzdw79xiZPoofhGICeZRSQ3dIxAKH1gb3OHfNf4d6f+vAv3kil2eggA==", "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": { "version": "1.1.7", "resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.1.7.tgz", diff --git a/package.json b/package.json index 46096b1..726cc72 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,7 @@ "@as-integrations/next": "^3.2.0", "@aws-sdk/client-s3": "^3.846.0", "@prisma/client": "^6.12.0", + "@radix-ui/react-alert-dialog": "^1.1.14", "@radix-ui/react-avatar": "^1.1.10", "@radix-ui/react-checkbox": "^1.3.2", "@radix-ui/react-dialog": "^1.1.14", diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 0226bdb..411e6d8 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -114,6 +114,10 @@ model Organization { sentMessages Message[] @relation("SentMessages") receivedMessages Message[] @relation("ReceivedMessages") + // Услуги и расходники (только для фулфилмент центров) + services Service[] + supplies Supply[] + @@map("organizations") } @@ -235,3 +239,38 @@ enum MessageType { IMAGE 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") +} diff --git a/src/app/api/health/route.ts b/src/app/api/health/route.ts new file mode 100644 index 0000000..af857df --- /dev/null +++ b/src/app/api/health/route.ts @@ -0,0 +1,5 @@ +import { NextResponse } from 'next/server' + +export async function GET() { + return NextResponse.json({ status: 'ok', timestamp: new Date().toISOString() }) +} \ No newline at end of file diff --git a/src/app/api/upload-service-image/route.ts b/src/app/api/upload-service-image/route.ts new file mode 100644 index 0000000..1b2242c --- /dev/null +++ b/src/app/api/upload-service-image/route.ts @@ -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 } + ) + } +} \ No newline at end of file diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 87f2c5f..cbe1825 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -2,6 +2,7 @@ import { ApolloProvider } from '@apollo/client' import { apolloClient } from '@/lib/apollo-client' +import { Toaster } from "@/components/ui/sonner" import "./globals.css" export default function RootLayout({ @@ -15,6 +16,7 @@ export default function RootLayout({ {children} + ) diff --git a/src/app/services/page.tsx b/src/app/services/page.tsx new file mode 100644 index 0000000..c111d1c --- /dev/null +++ b/src/app/services/page.tsx @@ -0,0 +1,10 @@ +import { AuthGuard } from "@/components/auth-guard" +import { ServicesDashboard } from "@/components/services/services-dashboard" + +export default function ServicesPage() { + return ( + + + + ) +} \ No newline at end of file diff --git a/src/components/dashboard/sidebar.tsx b/src/components/dashboard/sidebar.tsx index d8fefa3..d4fc78b 100644 --- a/src/components/dashboard/sidebar.tsx +++ b/src/components/dashboard/sidebar.tsx @@ -11,7 +11,8 @@ import { LogOut, Building2, Store, - MessageCircle + MessageCircle, + Wrench } from 'lucide-react' export function Sidebar() { @@ -63,9 +64,14 @@ export function Sidebar() { router.push('/messenger') } + const handleServicesClick = () => { + router.push('/services') + } + const isSettingsActive = pathname === '/settings' const isMarketActive = pathname.startsWith('/market') const isMessengerActive = pathname.startsWith('/messenger') + const isServicesActive = pathname.startsWith('/services') return (
@@ -131,6 +137,22 @@ export function Sidebar() { Мессенджер + + {/* Услуги - только для фулфилмент центров */} + {user?.organization?.type === 'FULFILLMENT' && ( + + )}
+ ) +} \ No newline at end of file diff --git a/src/components/services/services-dashboard.tsx b/src/components/services/services-dashboard.tsx new file mode 100644 index 0000000..371d989 --- /dev/null +++ b/src/components/services/services-dashboard.tsx @@ -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 ( +
+ +
+
+ {/* Заголовок - фиксированная высота */} +
+
+

Услуги

+

Управление услугами, расходниками и логистикой

+
+
+ + {/* Основной контент с табами */} +
+ + + + Услуги + + + Логистика + + + Расходники + + + + {/* Контент вкладок */} +
+ + + + + + + + + + + +
+
+
+
+
+
+ ) +} \ No newline at end of file diff --git a/src/components/services/services-tab.tsx b/src/components/services/services-tab.tsx new file mode 100644 index 0000000..01cbc1e --- /dev/null +++ b/src/components/services/services-tab.tsx @@ -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(null) + const [formData, setFormData] = useState({ + name: '', + description: '', + price: '', + imageUrl: '' + }) + const [imageFile, setImageFile] = useState(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 => { + 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 ( +
+ + {/* Заголовок и кнопка добавления */} +
+
+

Мои услуги

+

Управление вашими услугами

+
+ + + + + + + + + + {editingService ? 'Редактировать услугу' : 'Добавить услугу'} + + +
+
+ + 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 + /> +
+ +
+ + 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 + /> +
+ +
+ + 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="Описание услуги" + /> +
+ +
+ + { + 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 && ( +
+ Preview +
+ )} +
+ +
+ + +
+
+
+
+
+ + {/* Таблица услуг */} +
+ {loading ? ( +
+
+
+ + + +
+

Загрузка услуг...

+
+
+ ) : error ? ( +
+
+
+ + + +
+

Ошибка загрузки

+

+ Не удалось загрузить услуги +

+ +
+
+ ) : services.length === 0 ? ( +
+
+
+ +
+

Пока нет услуг

+

+ Создайте свою первую услугу, чтобы начать работу +

+ +
+
+ ) : ( +
+ + + + + + + + + + + + + {services.map((service: Service, index: number) => ( + + + + + + + + + ))} + +
ФотоНазваниеЦена за единицуОписаниеДействия
{index + 1} + {service.imageUrl ? ( + {service.name} { + console.error('Image failed to load:', service.imageUrl, e) + }} + onLoad={() => console.log('Image loaded successfully:', service.imageUrl)} + /> + ) : ( +
+ +
+ )} +
{service.name}{service.price.toLocaleString()} ₽{service.description || '—'} +
+ + + + + + + + + Подтвердите удаление + + + Вы действительно хотите удалить услугу “{service.name}”? Это действие необратимо. + + + + + Отмена + + 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" + > + Удалить + + + + +
+
+
+ )} +
+
+
+ ) +} \ No newline at end of file diff --git a/src/components/services/supplies-tab.tsx b/src/components/services/supplies-tab.tsx new file mode 100644 index 0000000..2cc6f8a --- /dev/null +++ b/src/components/services/supplies-tab.tsx @@ -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(null) + const [formData, setFormData] = useState({ + name: '', + description: '', + price: '', + quantity: '', + imageUrl: '' + }) + const [imageFile, setImageFile] = useState(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 => { + 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 ( +
+ + {/* Заголовок и кнопка добавления */} +
+
+

Мои расходники

+

Управление вашими расходными материалами

+
+ + + + + + + + + + {editingSupply ? 'Редактировать расходник' : 'Добавить расходник'} + + +
+
+ + 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 + /> +
+ +
+
+ + 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 + /> +
+ +
+ + 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 + /> +
+
+ +
+ + 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="Описание расходника" + /> +
+ +
+ + { + 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 && ( +
+ Preview +
+ )} +
+ +
+ + +
+
+
+
+
+ + {/* Таблица расходников */} +
+ {loading ? ( +
+
+
+ + + +
+

Загрузка расходников...

+
+
+ ) : error ? ( +
+
+
+ + + +
+

Ошибка загрузки

+

+ Не удалось загрузить расходники +

+ +
+
+ ) : supplies.length === 0 ? ( +
+
+
+ +
+

Пока нет расходников

+

+ Добавьте свой первый расходник для управления складскими запасами +

+ +
+
+ ) : ( +
+ + + + + + + + + + + + + + {supplies.map((supply: Supply, index: number) => ( + + + + + + + + + + ))} + +
ФотоНазваниеЦена за единицуКоличествоОписаниеДействия
{index + 1} + {supply.imageUrl ? ( + {supply.name} + ) : ( +
+ +
+ )} +
{supply.name}{supply.price.toLocaleString()} ₽ +
+ {supply.quantity} шт. + {supply.quantity <= 10 && ( + + Мало + + )} + {supply.quantity === 0 && ( + + Нет в наличии + + )} +
+
{supply.description || '—'} +
+ + + + + + + + + Подтвердите удаление + + + Вы действительно хотите удалить расходник “{supply.name}”? Это действие необратимо. + + + + + Отмена + + 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" + > + Удалить + + + + +
+
+
+ )} +
+
+
+ ) +} \ No newline at end of file diff --git a/src/components/ui/alert-dialog.tsx b/src/components/ui/alert-dialog.tsx new file mode 100644 index 0000000..0863e40 --- /dev/null +++ b/src/components/ui/alert-dialog.tsx @@ -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) { + return +} + +function AlertDialogTrigger({ + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function AlertDialogPortal({ + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function AlertDialogOverlay({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function AlertDialogContent({ + className, + ...props +}: React.ComponentProps) { + return ( + + + + + ) +} + +function AlertDialogHeader({ + className, + ...props +}: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function AlertDialogFooter({ + className, + ...props +}: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function AlertDialogTitle({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function AlertDialogDescription({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function AlertDialogAction({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function AlertDialogCancel({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +export { + AlertDialog, + AlertDialogPortal, + AlertDialogOverlay, + AlertDialogTrigger, + AlertDialogContent, + AlertDialogHeader, + AlertDialogFooter, + AlertDialogTitle, + AlertDialogDescription, + AlertDialogAction, + AlertDialogCancel, +} diff --git a/src/graphql/mutations.ts b/src/graphql/mutations.ts index 7df648c..340693c 100644 --- a/src/graphql/mutations.ts +++ b/src/graphql/mutations.ts @@ -546,4 +546,92 @@ export const MARK_MESSAGES_AS_READ = gql` mutation MarkMessagesAsRead($conversationId: ID!) { 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) + } ` \ No newline at end of file diff --git a/src/graphql/queries.ts b/src/graphql/queries.ts index dd789fc..e2c6dfc 100644 --- a/src/graphql/queries.ts +++ b/src/graphql/queries.ts @@ -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` query SearchOrganizations($type: OrganizationType, $search: String) { diff --git a/src/graphql/resolvers.ts b/src/graphql/resolvers.ts index e10f5e3..17eb957 100644 --- a/src/graphql/resolvers.ts +++ b/src/graphql/resolvers.ts @@ -423,6 +423,64 @@ export const resolvers = { // TODO: Здесь будет логика получения списка чатов // Пока возвращаем пустой массив, так как таблица сообщений еще не создана 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: Здесь будет логика обновления статуса сообщений // Пока возвращаем успешный ответ 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 + } } }, diff --git a/src/graphql/typedefs.ts b/src/graphql/typedefs.ts index 1118ea1..5cc56c2 100644 --- a/src/graphql/typedefs.ts +++ b/src/graphql/typedefs.ts @@ -22,6 +22,12 @@ export const typeDefs = gql` # Список чатов (последние сообщения с каждым контрагентом) conversations: [Conversation!]! + + # Услуги организации + myServices: [Service!]! + + # Расходники организации + mySupplies: [Supply!]! } type Mutation { @@ -61,6 +67,16 @@ export const typeDefs = gql` sendImageMessage(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! + + # Работа с услугами + 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 } + # Типы для услуг + 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 скаляр scalar JSON ` \ No newline at end of file diff --git a/stack.env b/stack.env new file mode 100644 index 0000000..3fc3fe0 --- /dev/null +++ b/stack.env @@ -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 \ No newline at end of file