Merge: Объединение изменений навигации сайдбара с удаленными обновлениями

- Решен конфликт в fulfillment-supplies-dashboard.tsx
- Сохранены изменения персистентного сайдбара без импорта Sidebar
- Объединены обновления GraphQL схемы и других компонентов

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Bivekich
2025-08-07 19:27:27 +03:00
34 changed files with 4062 additions and 1482 deletions

View File

@ -21,11 +21,7 @@ const handler = startServerAndCreateNextHandler<NextRequest, Context>(server, {
const authHeader = req.headers.get('authorization')
const token = authHeader?.replace('Bearer ', '')
console.warn('GraphQL Context - Auth header:', authHeader)
console.warn('GraphQL Context - Token:', token ? `${token.substring(0, 20)}...` : 'No token')
if (!token) {
console.warn('GraphQL Context - No token provided')
return { user: null, admin: null, prisma }
}
@ -46,10 +42,6 @@ const handler = startServerAndCreateNextHandler<NextRequest, Context>(server, {
// Проверяем тип токена
if (decoded.type === 'admin' && decoded.adminId && decoded.username) {
console.warn('GraphQL Context - Decoded admin:', {
id: decoded.adminId,
username: decoded.username,
})
return {
admin: {
id: decoded.adminId,
@ -59,10 +51,6 @@ const handler = startServerAndCreateNextHandler<NextRequest, Context>(server, {
prisma,
}
} else if (decoded.userId && decoded.phone) {
console.warn('GraphQL Context - Decoded user:', {
id: decoded.userId,
phone: decoded.phone,
})
return {
user: {
id: decoded.userId,

View File

@ -1,8 +1,6 @@
'use client'
import {
ChevronDown,
ChevronUp,
Plus,
Minus,
Star,
@ -63,7 +61,6 @@ export function InteractiveDemo() {
const [counter, setCounter] = useState(5)
const [showPassword, setShowPassword] = useState(false)
const [notifications, setNotifications] = useState(true)
const [expandedCard, setExpandedCard] = useState<number | null>(null)
const [selectedItems, setSelectedItems] = useState<number[]>([])
const [copied, setCopied] = useState(false)
@ -579,19 +576,16 @@ export function InteractiveDemo() {
{/* Расширяемые элементы */}
<Card className="glass-card border-white/10">
<CardHeader>
<CardTitle className="text-white">Расширяемые элементы</CardTitle>
<CardTitle className="text-white">Статичные элементы</CardTitle>
</CardHeader>
<CardContent className="space-y-6">
{/* Expandable Cards */}
{/* Static Cards */}
<div>
<h4 className="text-white/90 text-sm font-medium mb-3">Расширяемые карточки</h4>
<h4 className="text-white/90 text-sm font-medium mb-3">Статичные карточки</h4>
<div className="space-y-3">
{[1, 2, 3].map((card) => (
<div key={card} className="glass-card rounded-lg border border-white/10 overflow-hidden">
<div
className="p-4 cursor-pointer flex items-center justify-between hover:bg-white/5 transition-colors"
onClick={() => setExpandedCard(expandedCard === card ? null : card)}
>
<div className="p-4 flex items-center justify-between">
<div className="flex items-center space-x-3">
<div className="w-10 h-10 bg-blue-500/20 rounded-lg flex items-center justify-center">
<Settings className="h-5 w-5 text-blue-400" />
@ -601,36 +595,10 @@ export function InteractiveDemo() {
<div className="text-white/60 text-sm">Описание настройки {card}</div>
</div>
</div>
{expandedCard === card ? (
<ChevronUp className="h-5 w-5 text-white/60" />
) : (
<ChevronDown className="h-5 w-5 text-white/60" />
)}
<Button variant="ghost" size="sm">
<Edit3 className="h-4 w-4" />
</Button>
</div>
{expandedCard === card && (
<div className="px-4 pb-4 border-t border-white/10">
<div className="mt-4 space-y-3">
<div className="flex items-center justify-between">
<Label className="text-white">Включить функцию</Label>
<Switch />
</div>
<div className="space-y-2">
<Label className="text-white">Уровень</Label>
<Slider defaultValue={[50]} max={100} step={1} />
</div>
<div className="flex justify-end space-x-2">
<Button variant="ghost" size="sm">
Сбросить
</Button>
<Button variant="glass" size="sm">
Применить
</Button>
</div>
</div>
</div>
)}
</div>
))}
</div>

View File

@ -19,29 +19,17 @@ export function AuthGuard({ children, fallback }: AuthGuardProps) {
useEffect(() => {
const initAuth = async () => {
if (initRef.current) {
console.warn('AuthGuard - Already initialized, skipping')
return
}
initRef.current = true
console.warn('AuthGuard - Initializing auth check')
await checkAuth()
setIsChecking(false)
console.warn('AuthGuard - Auth check completed, authenticated:', isAuthenticated, 'user:', !!user)
}
initAuth()
}, [checkAuth, isAuthenticated, user]) // Добавляем зависимости как требует линтер
// Дополнительное логирование состояний
useEffect(() => {
console.warn('AuthGuard - State update:', {
isChecking,
isLoading,
isAuthenticated,
hasUser: !!user,
})
}, [isChecking, isLoading, isAuthenticated, user])
// Показываем лоадер пока проверяем авторизацию
if (isChecking || isLoading) {
@ -57,11 +45,9 @@ export function AuthGuard({ children, fallback }: AuthGuardProps) {
// Если не авторизован, показываем форму авторизации
if (!isAuthenticated) {
console.warn('AuthGuard - User not authenticated, showing auth flow')
return fallback || <AuthFlow />
}
// Если авторизован, показываем защищенный контент
console.warn('AuthGuard - User authenticated, showing dashboard')
return <>{children}</>
}

View File

@ -83,8 +83,6 @@ export function MarketplaceApiStep({ onNext, onBack }: MarketplaceApiStepProps)
},
})
console.warn(`🎯 Client received response for ${marketplace}:`, data)
setValidationStates((prev) => ({
...prev,
[marketplace]: {
@ -113,8 +111,7 @@ export function MarketplaceApiStep({ onNext, onBack }: MarketplaceApiStepProps)
})
}
}
} catch (error) {
console.warn(`🔴 Client validation error for ${marketplace}:`, error)
} catch {
setValidationStates((prev) => ({
...prev,
[marketplace]: {

View File

@ -84,11 +84,8 @@ export function SmsStep({ phone, onNext, onBack }: SmsStepProps) {
const result = await verifySmsCode(formattedPhone, fullCode)
if (result.success) {
console.warn('SmsStep - SMS verification successful, user:', result.user)
// Проверяем есть ли у пользователя уже организация
if (result.user?.organization) {
console.warn('SmsStep - User already has organization, redirecting to dashboard')
// Если организация уже есть, перенаправляем прямо в кабинет
router.push('/dashboard')
return

View File

@ -31,6 +31,7 @@ import { Button } from '@/components/ui/button'
import { Card } from '@/components/ui/card'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { UPDATE_USER_PROFILE, UPDATE_ORGANIZATION_BY_INN } from '@/graphql/mutations'
import { GET_ME } from '@/graphql/queries'
@ -86,6 +87,9 @@ export function UserSettings() {
// API ключи маркетплейсов
wildberriesApiKey: '',
ozonApiKey: '',
// Рынок для поставщиков
market: '',
})
// Загружаем данные организации при монтировании компонента
@ -129,10 +133,13 @@ export function UserSettings() {
} = {}
try {
if (org.managementPost && typeof org.managementPost === 'string') {
customContacts = JSON.parse(org.managementPost)
// Проверяем, что строка начинается с { или [, иначе это не JSON
if (org.managementPost.trim().startsWith('{') || org.managementPost.trim().startsWith('[')) {
customContacts = JSON.parse(org.managementPost)
}
}
} catch (e) {
console.warn('Ошибка парсинга managementPost:', e)
} catch {
// Игнорируем ошибки парсинга
}
setFormData({
@ -153,6 +160,7 @@ export function UserSettings() {
corrAccount: customContacts?.bankDetails?.corrAccount || '',
wildberriesApiKey: '',
ozonApiKey: '',
market: org.market || 'none',
})
}
}, [user])
@ -289,7 +297,6 @@ export function UserSettings() {
})
// TODO: Сохранить партнерский код в базе данных
console.warn('Partner code generated:', partnerCode)
} catch (error) {
console.error('Error generating partner link:', error)
setSaveMessage({ type: 'error', text: 'Ошибка при генерации ссылки' })
@ -341,7 +348,7 @@ export function UserSettings() {
avatar: avatarUrl,
},
},
update: (cache, { data }) => {
update: (cache, { data }: { data?: any }) => {
if (data?.updateUserProfile?.success) {
// Обновляем кеш Apollo Client
try {
@ -357,8 +364,8 @@ export function UserSettings() {
},
})
}
} catch (error) {
console.warn('Cache update error:', error)
} catch {
// Игнорируем ошибки обновления кеша
}
}
},
@ -517,6 +524,7 @@ export function UserSettings() {
const handleInputChange = (field: string, value: string) => {
let processedValue = value
// Применяем маски и валидации
switch (field) {
case 'orgPhone':
@ -581,6 +589,59 @@ export function UserSettings() {
}
}
// Проверка наличия изменений в форме
const hasFormChanges = () => {
if (!user?.organization) return false
const org = user.organization
// Извлекаем текущий телефон из organization.phones
let currentOrgPhone = '+7'
if (org.phones && Array.isArray(org.phones) && org.phones.length > 0) {
currentOrgPhone = org.phones[0].value || org.phones[0] || '+7'
}
// Извлекаем текущий email из organization.emails
let currentEmail = ''
if (org.emails && Array.isArray(org.emails) && org.emails.length > 0) {
currentEmail = org.emails[0].value || org.emails[0] || ''
}
// Извлекаем дополнительные данные из managementPost
let customContacts: any = {}
try {
if (org.managementPost && typeof org.managementPost === 'string') {
// Проверяем, что строка начинается с { или [, иначе это не JSON
if (org.managementPost.trim().startsWith('{') || org.managementPost.trim().startsWith('[')) {
customContacts = JSON.parse(org.managementPost)
}
}
} catch {
// ignore parse errors
}
// Нормализуем значения для сравнения
const normalizeValue = (value: string | null | undefined) => value || ''
const normalizeMarketValue = (value: string | null | undefined) => value || 'none'
// Проверяем изменения в полях
const changes = [
normalizeValue(formData.orgPhone) !== normalizeValue(currentOrgPhone),
normalizeValue(formData.managerName) !== normalizeValue(user?.managerName),
normalizeValue(formData.telegram) !== normalizeValue(customContacts?.telegram),
normalizeValue(formData.whatsapp) !== normalizeValue(customContacts?.whatsapp),
normalizeValue(formData.email) !== normalizeValue(currentEmail),
normalizeMarketValue(formData.market) !== normalizeMarketValue(org.market),
normalizeValue(formData.bankName) !== normalizeValue(customContacts?.bankDetails?.bankName),
normalizeValue(formData.bik) !== normalizeValue(customContacts?.bankDetails?.bik),
normalizeValue(formData.accountNumber) !== normalizeValue(customContacts?.bankDetails?.accountNumber),
normalizeValue(formData.corrAccount) !== normalizeValue(customContacts?.bankDetails?.corrAccount),
]
const hasChanges = changes.some(changed => changed)
return hasChanges
}
// Проверка наличия ошибок валидации
const hasValidationErrors = () => {
const fields = [
@ -657,6 +718,7 @@ export function UserSettings() {
bik?: string
accountNumber?: string
corrAccount?: string
market?: string
} = {}
// orgName больше не редактируется - устанавливается только при регистрации
@ -669,6 +731,7 @@ export function UserSettings() {
if (formData.bik?.trim()) inputData.bik = formData.bik.trim()
if (formData.accountNumber?.trim()) inputData.accountNumber = formData.accountNumber.trim()
if (formData.corrAccount?.trim()) inputData.corrAccount = formData.corrAccount.trim()
if (formData.market) inputData.market = formData.market
const result = await updateUserProfile({
variables: {
@ -714,7 +777,6 @@ export function UserSettings() {
}
if (isNaN(date.getTime())) {
console.warn('Invalid date string:', dateString)
return 'Неверная дата'
}
@ -723,8 +785,7 @@ export function UserSettings() {
month: 'long',
day: 'numeric',
})
} catch (error) {
console.error('Error formatting date:', error, dateString)
} catch {
return 'Ошибка даты'
}
}
@ -831,9 +892,9 @@ export function UserSettings() {
<Button
size="sm"
onClick={handleSave}
disabled={hasValidationErrors() || isSaving}
disabled={hasValidationErrors() || isSaving || !hasFormChanges()}
className={`glass-button text-white cursor-pointer ${
hasValidationErrors() || isSaving ? 'opacity-50 cursor-not-allowed' : ''
hasValidationErrors() || isSaving || !hasFormChanges() ? 'opacity-50 cursor-not-allowed' : ''
}`}
>
<Save className="h-4 w-4 mr-2" />
@ -1068,9 +1129,9 @@ export function UserSettings() {
<Button
size="sm"
onClick={handleSave}
disabled={hasValidationErrors() || isSaving}
disabled={hasValidationErrors() || isSaving || !hasFormChanges()}
className={`glass-button text-white cursor-pointer ${
hasValidationErrors() || isSaving ? 'opacity-50 cursor-not-allowed' : ''
hasValidationErrors() || isSaving || !hasFormChanges() ? 'opacity-50 cursor-not-allowed' : ''
}`}
>
<Save className="h-4 w-4 mr-2" />
@ -1254,6 +1315,41 @@ export function UserSettings() {
</div>
</div>
)}
{/* Настройка рынка для поставщиков */}
{user?.organization?.type === 'WHOLESALE' && (
<div className="grid grid-cols-2 gap-4">
<div>
<Label className="text-white/80 text-sm mb-2 flex items-center gap-2">
🏪 Физический рынок
</Label>
{isEditing ? (
<Select value={formData.market || 'none'} onValueChange={(value) => handleInputChange('market', value)}>
<SelectTrigger className="glass-input text-white h-10 text-sm">
<SelectValue placeholder="Выберите рынок" />
</SelectTrigger>
<SelectContent className="glass-card">
<SelectItem value="none">Не указан</SelectItem>
<SelectItem value="sadovod" className="text-white">Садовод</SelectItem>
<SelectItem value="tyak-moscow" className="text-white">ТЯК Москва</SelectItem>
</SelectContent>
</Select>
) : (
<Input
value={formData.market && formData.market !== 'none' ?
(formData.market === 'sadovod' ? 'Садовод' :
formData.market === 'tyak-moscow' ? 'ТЯК Москва' :
formData.market) : 'Не указан'}
readOnly
className="glass-input text-white h-10 read-only:opacity-70"
/>
)}
<p className="text-white/50 text-xs mt-1">
Физический рынок, где работает поставщик. Товары наследуют рынок от организации.
</p>
</div>
</div>
)}
</div>
</Card>
</TabsContent>
@ -1295,7 +1391,7 @@ export function UserSettings() {
<Button
size="sm"
onClick={handleSave}
disabled={hasValidationErrors() || isSaving}
disabled={hasValidationErrors() || isSaving || !hasFormChanges()}
className={`glass-button text-white cursor-pointer ${
hasValidationErrors() || isSaving ? 'opacity-50 cursor-not-allowed' : ''
}`}
@ -1402,7 +1498,7 @@ export function UserSettings() {
<Button
size="sm"
onClick={handleSave}
disabled={hasValidationErrors() || isSaving}
disabled={hasValidationErrors() || isSaving || !hasFormChanges()}
className={`glass-button text-white cursor-pointer ${
hasValidationErrors() || isSaving ? 'opacity-50 cursor-not-allowed' : ''
}`}

View File

@ -12,10 +12,8 @@ import {
Phone,
Mail,
Briefcase,
DollarSign,
Calendar,
MessageCircle,
User,
} from 'lucide-react'
import { useState } from 'react'

View File

@ -21,7 +21,6 @@ import {
Truck,
Warehouse,
Eye,
EyeOff,
} from 'lucide-react'
import { useState } from 'react'

View File

@ -4,21 +4,16 @@ import { useQuery, useMutation } from '@apollo/client'
import {
ArrowLeft,
Building2,
MapPin,
Phone,
Mail,
Star,
Search,
Package,
Plus,
Minus,
ShoppingCart,
Wrench,
Box,
} from 'lucide-react'
import Image from 'next/image'
import { useRouter } from 'next/navigation'
import React, { useState, useEffect } from 'react'
import React, { useState } from 'react'
import { toast } from 'sonner'
import { OrganizationAvatar } from '@/components/market/organization-avatar'
@ -114,7 +109,7 @@ export function CreateFulfillmentConsumablesSupplyPage() {
} = useQuery(GET_ORGANIZATION_PRODUCTS, {
skip: !selectedSupplier,
variables: {
organizationId: selectedSupplier.id,
organizationId: selectedSupplier?.id,
search: productSearchQuery || null,
category: null,
type: 'CONSUMABLE', // Фильтруем только расходники согласно rules2.md
@ -122,7 +117,7 @@ export function CreateFulfillmentConsumablesSupplyPage() {
onCompleted: (data) => {
console.warn('✅ GET_ORGANIZATION_PRODUCTS COMPLETED:', {
totalProducts: data?.organizationProducts?.length || 0,
organizationId: selectedSupplier.id,
organizationId: selectedSupplier?.id,
type: 'CONSUMABLE',
products:
data?.organizationProducts?.map((p) => ({
@ -203,14 +198,6 @@ export function CreateFulfillmentConsumablesSupplyPage() {
}).format(amount)
}
const renderStars = (rating: number = 4.5) => {
return Array.from({ length: 5 }, (_, i) => (
<Star
key={i}
className={`h-3 w-3 ${i < Math.floor(rating) ? 'text-yellow-400 fill-current' : 'text-gray-400'}`}
/>
))
}
const updateConsumableQuantity = (productId: string, quantity: number) => {
const product = supplierProducts.find((p: FulfillmentConsumableProduct) => p.id === productId)

View File

@ -12,9 +12,7 @@ import { useSidebar } from '@/hooks/useSidebar'
// Импорты компонентов подразделов
import { FulfillmentConsumablesOrdersTab } from './fulfillment-supplies/fulfillment-consumables-orders-tab'
import { FulfillmentDetailedSuppliesTab } from './fulfillment-supplies/fulfillment-detailed-supplies-tab'
import { FulfillmentSuppliesTab } from './fulfillment-supplies/fulfillment-supplies-tab'
import { PvzReturnsTab } from './fulfillment-supplies/pvz-returns-tab'
import { MarketplaceSuppliesTab } from './marketplace-supplies/marketplace-supplies-tab'
// Компонент для отображения бейджа с уведомлениями
function NotificationBadge({ count }: { count: number }) {

View File

@ -5,10 +5,8 @@ import {
Calendar,
Package,
Truck,
User,
CheckCircle,
Clock,
AlertCircle,
XCircle,
MapPin,
Phone,
@ -17,20 +15,18 @@ import {
Building,
Hash,
Store,
Bell,
AlertTriangle,
UserPlus,
Settings,
} from 'lucide-react'
import React, { useState } from 'react'
import { toast } from 'sonner'
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar'
import { Avatar, AvatarFallback } from '@/components/ui/avatar'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Card } from '@/components/ui/card'
import { Separator } from '@/components/ui/separator'
import { UPDATE_SUPPLY_ORDER_STATUS, ASSIGN_LOGISTICS_TO_SUPPLY, FULFILLMENT_RECEIVE_ORDER } from '@/graphql/mutations'
import { ASSIGN_LOGISTICS_TO_SUPPLY, FULFILLMENT_RECEIVE_ORDER } from '@/graphql/mutations'
import {
GET_SUPPLY_ORDERS,
GET_MY_SUPPLIES,

View File

@ -4,7 +4,6 @@ import { Calendar, Package, MapPin, Building2, TrendingUp, AlertTriangle, Dollar
import React, { useState } from 'react'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Card } from '@/components/ui/card'
// Типы данных для товаров ФФ

View File

@ -1,6 +1,6 @@
'use client'
import { Plus, Send, Trash2 } from 'lucide-react'
import { Plus, Send, Trash2, MapPin, Calendar, Phone, Mail, User } from 'lucide-react'
import { useState } from 'react'
import { Badge } from '@/components/ui/badge'

View File

@ -1,26 +1,16 @@
'use client'
import { useQuery, useMutation } from '@apollo/client'
import { Plus, Trash2, Save, X, Edit, Upload, Check } from 'lucide-react'
import { Package, Save, X, Edit, Check } from 'lucide-react'
import Image from 'next/image'
import { useState, useEffect, useMemo } from 'react'
import { toast } from 'sonner'
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from '@/components/ui/alert-dialog'
// Alert dialog no longer needed for supplies
import { Button } from '@/components/ui/button'
import { Card } from '@/components/ui/card'
import { Input } from '@/components/ui/input'
import { CREATE_SUPPLY, UPDATE_SUPPLY, DELETE_SUPPLY } from '@/graphql/mutations'
import { UPDATE_SUPPLY_PRICE } from '@/graphql/mutations'
import { GET_MY_SUPPLIES } from '@/graphql/queries'
import { useAuth } from '@/hooks/useAuth'
@ -28,42 +18,58 @@ interface Supply {
id: string
name: string
description?: string
price: number
pricePerUnit?: number | null
unit: string
imageUrl?: string
warehouseStock: number
isAvailable: boolean
warehouseConsumableId: string
createdAt: string
updatedAt: string
organization: {
id: string
name: string
}
}
interface EditableSupply {
id?: string // undefined для новых записей
id: string
name: string
description: string
price: string
pricePerUnit: string // Цена за единицу - единственное редактируемое поле
unit: string
imageUrl: string
imageFile?: File
isNew: boolean
warehouseStock: number
isAvailable: boolean
isEditing: boolean
hasChanges: boolean
}
interface PendingChange extends EditableSupply {
isDeleted?: boolean
}
// PendingChange interface no longer needed
export function SuppliesTab() {
const { user } = useAuth()
const [editableSupplies, setEditableSupplies] = useState<EditableSupply[]>([])
const [pendingChanges, setPendingChanges] = useState<PendingChange[]>([])
// No longer need pending changes tracking
const [isSaving, setIsSaving] = useState(false)
const [isInitialized, setIsInitialized] = useState(false)
// Debug информация
console.log('SuppliesTab - User:', user?.phone, 'Type:', user?.organization?.type)
// GraphQL запросы и мутации
const { data, loading, error, refetch } = useQuery(GET_MY_SUPPLIES, {
skip: user?.organization?.type !== 'FULFILLMENT',
skip: !user || user?.organization?.type !== 'FULFILLMENT',
})
const [updateSupplyPrice] = useMutation(UPDATE_SUPPLY_PRICE)
// Debug GraphQL запроса
console.log('SuppliesTab - Query:', {
skip: !user || user?.organization?.type !== 'FULFILLMENT',
loading,
error: error?.message,
dataLength: data?.mySupplies?.length,
})
const [createSupply] = useMutation(CREATE_SUPPLY)
const [updateSupply] = useMutation(UPDATE_SUPPLY)
const [deleteSupply] = useMutation(DELETE_SUPPLY)
const supplies = data?.mySupplies || []
@ -74,68 +80,25 @@ export function SuppliesTab() {
id: supply.id,
name: supply.name,
description: supply.description || '',
price: supply.price.toString(),
pricePerUnit: supply.pricePerUnit ? supply.pricePerUnit.toString() : '',
unit: supply.unit,
imageUrl: supply.imageUrl || '',
isNew: false,
warehouseStock: supply.warehouseStock,
isAvailable: supply.isAvailable,
isEditing: false,
hasChanges: false,
}))
setEditableSupplies(convertedSupplies)
setPendingChanges([])
setIsInitialized(true)
}
}, [data, isInitialized])
// Добавить новую строку
const addNewRow = () => {
const tempId = `temp-${Date.now()}-${Math.random()}`
const newRow: EditableSupply = {
id: tempId,
name: '',
description: '',
price: '',
imageUrl: '',
isNew: true,
isEditing: true,
hasChanges: false,
}
setEditableSupplies((prev) => [...prev, newRow])
}
// Расходники нельзя создавать - они появляются автоматически со склада
// const addNewRow = () => { ... } - REMOVED
// Удалить строку
const removeRow = async (supplyId: string, isNew: boolean) => {
if (isNew) {
// Просто удаляем из массива если это новая строка
setEditableSupplies((prev) => prev.filter((s) => s.id !== supplyId))
} else {
// Удаляем существующую запись сразу
try {
await deleteSupply({
variables: { id: supplyId },
update: (cache, { data }) => {
// Обновляем кэш Apollo Client
const existingData = cache.readQuery({ query: GET_MY_SUPPLIES }) as { mySupplies: Supply[] } | null
if (existingData && existingData.mySupplies) {
cache.writeQuery({
query: GET_MY_SUPPLIES,
data: {
mySupplies: existingData.mySupplies.filter((s: Supply) => s.id !== supplyId),
},
})
}
},
})
// Удаляем из локального состояния по ID, а не по индексу
setEditableSupplies((prev) => prev.filter((s) => s.id !== supplyId))
toast.success('Расходник успешно удален')
} catch (error) {
console.error('Error deleting supply:', error)
toast.error('Ошибка при удалении расходника')
}
}
}
// Расходники нельзя удалять - они управляются через склад
// const removeRow = async (supplyId: string, isNew: boolean) => { ... } - REMOVED
// Начать редактирование существующей строки
const startEditing = (supplyId: string) => {
@ -149,172 +112,106 @@ export function SuppliesTab() {
const supply = editableSupplies.find((s) => s.id === supplyId)
if (!supply) return
if (supply.isNew) {
// Удаляем новую строку
setEditableSupplies((prev) => prev.filter((s) => s.id !== supplyId))
} else {
// Возвращаем к исходному состоянию
const originalSupply = supplies.find((s: Supply) => s.id === supply.id)
if (originalSupply) {
setEditableSupplies((prev) =>
prev.map((s) =>
s.id === supplyId
? {
id: originalSupply.id,
name: originalSupply.name,
description: originalSupply.description || '',
price: originalSupply.price.toString(),
imageUrl: originalSupply.imageUrl || '',
isNew: false,
isEditing: false,
hasChanges: false,
}
: s,
),
)
}
// Возвращаем к исходному состоянию
const originalSupply = supplies.find((s: Supply) => s.id === supply.id)
if (originalSupply) {
setEditableSupplies((prev) =>
prev.map((s) =>
s.id === supplyId
? {
id: originalSupply.id,
name: originalSupply.name,
description: originalSupply.description || '',
pricePerUnit: originalSupply.pricePerUnit ? originalSupply.pricePerUnit.toString() : '',
unit: originalSupply.unit,
imageUrl: originalSupply.imageUrl || '',
warehouseStock: originalSupply.warehouseStock,
isAvailable: originalSupply.isAvailable,
isEditing: false,
hasChanges: false,
}
: s,
),
)
}
}
// Обновить поле
const updateField = (supplyId: string, field: keyof EditableSupply, value: string | File) => {
// Обновить поле (только цену можно редактировать)
const updateField = (supplyId: string, field: keyof EditableSupply, value: string) => {
if (field !== 'pricePerUnit') {
return // Только цену можно редактировать
}
setEditableSupplies((prev) =>
prev.map((supply) => {
if (supply.id !== supplyId) return supply
const updated = { ...supply, hasChanges: true }
if (field === 'imageFile' && value instanceof File) {
updated.imageFile = value
updated.imageUrl = URL.createObjectURL(value)
} else if (typeof value === 'string') {
if (field === 'name') updated.name = value
else if (field === 'description') updated.description = value
else if (field === 'price') updated.price = value
else if (field === 'imageUrl') updated.imageUrl = value
return {
...supply,
pricePerUnit: value,
hasChanges: true,
}
return updated
}),
)
}
// Загрузка изображения
const uploadImageAndGetUrl = async (file: File): Promise<string> => {
if (!user?.id) throw new Error('User not found')
// Image upload no longer needed - supplies are readonly except price
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()
return result.url
}
// Сохранить все изменения
// Сохранить все изменения (только цены)
const saveAllChanges = async () => {
setIsSaving(true)
try {
const suppliesToSave = editableSupplies.filter((s) => {
if (s.isNew) {
// Для новых записей проверяем что обязательные поля заполнены
return s.name.trim() && s.price
}
// Для существующих записей проверяем флаг изменений
return s.hasChanges
})
console.warn('Supplies to save:', suppliesToSave.length, suppliesToSave)
const suppliesToSave = editableSupplies.filter((s) => s.hasChanges)
for (const supply of suppliesToSave) {
if (!supply.name.trim() || !supply.price) {
toast.error('Заполните обязательные поля для всех расходников')
// Проверяем валидность цены (может быть пустой)
const pricePerUnit = supply.pricePerUnit.trim() ? parseFloat(supply.pricePerUnit) : null
if (supply.pricePerUnit.trim() && (isNaN(pricePerUnit!) || pricePerUnit! <= 0)) {
toast.error('Введите корректную цену')
setIsSaving(false)
return
}
let imageUrl = supply.imageUrl
// Загружаем изображение если выбрано
if (supply.imageFile) {
imageUrl = await uploadImageAndGetUrl(supply.imageFile)
}
const input = {
name: supply.name,
description: supply.description || undefined,
price: parseFloat(supply.price),
imageUrl: imageUrl || undefined,
pricePerUnit: pricePerUnit,
}
if (supply.isNew) {
await createSupply({
variables: { input },
update: (cache, { data }) => {
if (data?.createSupply?.supply) {
const existingData = cache.readQuery({ query: GET_MY_SUPPLIES }) as { mySupplies: Supply[] } | null
if (existingData) {
cache.writeQuery({
query: GET_MY_SUPPLIES,
data: {
mySupplies: [...existingData.mySupplies, data.createSupply.supply],
},
})
}
await updateSupplyPrice({
variables: { id: supply.id, input },
update: (cache, { data }) => {
if (data?.updateSupplyPrice?.supply) {
const existingData = cache.readQuery({ query: GET_MY_SUPPLIES }) as { mySupplies: Supply[] } | null
if (existingData) {
cache.writeQuery({
query: GET_MY_SUPPLIES,
data: {
mySupplies: existingData.mySupplies.map((s: Supply) =>
s.id === data.updateSupplyPrice.supply.id ? data.updateSupplyPrice.supply : s,
),
},
})
}
},
})
} else if (supply.id) {
await updateSupply({
variables: { id: supply.id, input },
update: (cache, { data }) => {
if (data?.updateSupply?.supply) {
const existingData = cache.readQuery({ query: GET_MY_SUPPLIES }) as { mySupplies: Supply[] } | null
if (existingData) {
cache.writeQuery({
query: GET_MY_SUPPLIES,
data: {
mySupplies: existingData.mySupplies.map((s: Supply) =>
s.id === data.updateSupply.supply.id ? data.updateSupply.supply : s,
),
},
})
}
}
},
})
}
}
},
})
}
// Удаления теперь происходят сразу в removeRow, так что здесь обрабатываем только обновления
// Сбрасываем флаги изменений
setEditableSupplies((prev) => prev.map((s) => ({ ...s, hasChanges: false, isEditing: false })))
toast.success('Все изменения успешно сохранены')
setPendingChanges([])
toast.success('Цены успешно обновлены')
} catch (error) {
console.error('Error saving changes:', error)
toast.error('Ошибка при сохранении изменений')
toast.error('Ошибка при сохранении цен')
} finally {
setIsSaving(false)
}
}
// Проверяем есть ли несохраненные изменения
// Проверяем есть ли несохраненные изменения (только цены)
const hasUnsavedChanges = useMemo(() => {
return editableSupplies.some((s) => {
if (s.isNew) {
// Для новых записей проверяем что есть данные для сохранения
return s.name.trim() || s.price || s.description.trim()
}
return s.hasChanges
})
return editableSupplies.some((s) => s.hasChanges)
}, [editableSupplies])
return (
@ -323,19 +220,13 @@ export function SuppliesTab() {
{/* Заголовок и кнопки */}
<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>
<h2 className="text-lg font-semibold text-white mb-1">Расходники со склада</h2>
<p className="text-white/70 text-sm">
Расходники появляются автоматически из поставок. Можно только установить цену.
</p>
</div>
<div className="flex gap-3">
<Button
onClick={addNewRow}
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 hover:shadow-purple-500/40 transition-all duration-300 hover:scale-105"
>
<Plus className="w-4 h-4 mr-2" />
Добавить расходник
</Button>
{hasUnsavedChanges && (
<Button
onClick={saveAllChanges}
@ -343,7 +234,7 @@ export function SuppliesTab() {
className="bg-gradient-to-r from-green-600 to-emerald-600 hover:from-green-700 hover:to-emerald-700 text-white border-0 shadow-lg shadow-green-500/25 hover:shadow-green-500/40 transition-all duration-300 hover:scale-105 disabled:hover:scale-100"
>
<Save className="w-4 h-4 mr-2" />
{isSaving ? 'Сохранение...' : 'Сохранить все'}
{isSaving ? 'Обновление цен...' : 'Сохранить цены'}
</Button>
)}
</div>
@ -381,7 +272,19 @@ export function SuppliesTab() {
</svg>
</div>
<h3 className="text-lg font-semibold text-white mb-2">Ошибка загрузки</h3>
<p className="text-white/70 text-sm mb-4">Не удалось загрузить расходники</p>
<p className="text-white/70 text-sm mb-4">
Не удалось загрузить расходники
{process.env.NODE_ENV === 'development' && (
<>
<br />
<span className="text-xs text-red-300">
Debug: {error.message}
<br />
User type: {user?.organization?.type}
</span>
</>
)}
</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"
@ -394,17 +297,10 @@ export function SuppliesTab() {
<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" />
<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={addNewRow}
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>
<p className="text-white/70 text-sm mb-4">Расходники появятся автоматически при получении поставок</p>
</div>
</div>
) : (
@ -414,8 +310,10 @@ export function SuppliesTab() {
<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>
<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>
@ -424,51 +322,15 @@ export function SuppliesTab() {
{editableSupplies.map((supply, index) => (
<tr
key={supply.id || index}
className={`border-t border-white/10 hover:bg-white/5 ${supply.isNew || supply.hasChanges ? 'bg-blue-500/10' : ''}`}
className={`border-t border-white/10 hover:bg-white/5 ${
supply.hasChanges ? 'bg-blue-500/10' : ''
} ${supply.isAvailable ? '' : 'opacity-60'}`}
>
<td className="p-4 text-white/80">{index + 1}</td>
{/* Фото */}
<td className="p-4 relative">
{supply.isEditing ? (
<div className="flex items-center gap-3">
<Input
type="file"
accept="image/*"
onChange={(e) => {
const file = e.target.files?.[0]
if (file) {
updateField(supply.id!, 'imageFile', file)
}
}}
className="bg-white/5 border-white/20 text-white text-xs file:bg-gradient-to-r file:from-purple-500 file:to-pink-500 file:text-white file:border-0 file:rounded file:px-2 file:py-1 file:mr-2 file:text-xs flex-1"
/>
{supply.imageUrl && (
<div className="relative group w-12 h-12 flex-shrink-0">
<Image
src={supply.imageUrl}
alt="Preview"
width={48}
height={48}
className="w-12 h-12 object-cover rounded border border-white/20 cursor-pointer transition-all duration-300 group-hover:ring-2 group-hover:ring-purple-400/50"
/>
{/* Увеличенная версия при hover */}
<div className="absolute top-0 left-0 opacity-0 group-hover:opacity-100 transition-all duration-300 pointer-events-none z-50 transform group-hover:scale-100 scale-75">
<div className="relative">
<Image
src={supply.imageUrl}
alt="Preview"
width={200}
height={200}
className="w-50 h-50 object-cover rounded-lg border-2 border-purple-400 shadow-2xl shadow-purple-500/30 bg-black/90 backdrop-blur"
/>
<div className="absolute inset-0 bg-gradient-to-t from-black/20 to-transparent rounded-lg"></div>
</div>
</div>
</div>
)}
</div>
) : supply.imageUrl ? (
{supply.imageUrl ? (
<div className="relative group w-12 h-12">
<Image
src={supply.imageUrl}
@ -496,56 +358,61 @@ export function SuppliesTab() {
</div>
) : (
<div className="w-12 h-12 bg-white/10 rounded flex items-center justify-center">
<Upload className="w-5 h-5 text-white/50" />
<Package className="w-5 h-5 text-white/50" />
</div>
)}
</td>
{/* Название */}
<td className="p-4">
{supply.isEditing ? (
<Input
value={supply.name}
onChange={(e) => updateField(supply.id!, 'name', e.target.value)}
className="bg-white/5 border-white/20 text-white"
placeholder="Название расходника"
/>
) : (
<span className="text-white font-medium">{supply.name}</span>
)}
<span className="text-white font-medium">{supply.name}</span>
</td>
{/* Цена */}
{/* Остаток на складе */}
<td className="p-4">
<div className="flex items-center gap-2">
<span
className={`text-sm font-medium ${supply.isAvailable ? 'text-green-400' : 'text-red-400'}`}
>
{supply.warehouseStock}
</span>
{!supply.isAvailable && (
<span className="px-2 py-1 rounded-full bg-red-500/20 text-red-300 text-xs">
Нет в наличии
</span>
)}
</div>
</td>
{/* Единица измерения */}
<td className="p-4">
<span className="text-white/80">{supply.unit}</span>
</td>
{/* Цена за единицу */}
<td className="p-4">
{supply.isEditing ? (
<Input
type="number"
step="0.01"
min="0"
value={supply.price}
onChange={(e) => updateField(supply.id!, 'price', e.target.value)}
value={supply.pricePerUnit}
onChange={(e) => updateField(supply.id, 'pricePerUnit', e.target.value)}
className="bg-white/5 border-white/20 text-white"
placeholder="0.00"
placeholder="Не установлена"
/>
) : (
<span className="text-white/80">
{supply.price ? parseFloat(supply.price).toLocaleString() : '0'}
{supply.pricePerUnit
? `${parseFloat(supply.pricePerUnit).toLocaleString()}`
: 'Не установлена'}
</span>
)}
</td>
{/* Описание */}
<td className="p-4">
{supply.isEditing ? (
<Input
value={supply.description}
onChange={(e) => updateField(supply.id!, 'description', e.target.value)}
className="bg-white/5 border-white/20 text-white"
placeholder="Описание расходника"
/>
) : (
<span className="text-white/80">{supply.description || '—'}</span>
)}
<span className="text-white/80">{supply.description || '—'}</span>
</td>
{/* Действия */}
@ -556,14 +423,9 @@ export function SuppliesTab() {
<Button
size="sm"
onClick={() => {
// Сохраняем только этот расходник если заполнены обязательные поля
if (supply.name.trim() && supply.price) {
saveAllChanges()
} else {
toast.error('Заполните обязательные поля')
}
saveAllChanges()
}}
disabled={!supply.name.trim() || !supply.price || isSaving}
disabled={isSaving}
className="h-8 w-8 p-0 bg-gradient-to-r from-green-500/20 to-emerald-500/20 hover:from-green-500/30 hover:to-emerald-500/30 border border-green-500/30 hover:border-green-400/50 text-green-300 hover:text-white transition-all duration-200 shadow-lg shadow-green-500/10 hover:shadow-green-500/20 disabled:opacity-50 disabled:cursor-not-allowed"
title="Сохранить"
>
@ -571,7 +433,7 @@ export function SuppliesTab() {
</Button>
<Button
size="sm"
onClick={() => cancelEditing(supply.id!)}
onClick={() => cancelEditing(supply.id)}
className="h-8 w-8 p-0 bg-gradient-to-r from-red-500/20 to-red-600/20 hover:from-red-500/30 hover:to-red-600/30 border border-red-500/30 hover:border-red-400/50 text-red-300 hover:text-white transition-all duration-200 shadow-lg shadow-red-500/10 hover:shadow-red-500/20"
title="Отменить"
>
@ -581,47 +443,13 @@ export function SuppliesTab() {
) : (
<Button
size="sm"
onClick={() => startEditing(supply.id!)}
onClick={() => startEditing(supply.id)}
className="h-8 w-8 p-0 bg-gradient-to-r from-purple-500/20 to-pink-500/20 hover:from-purple-500/30 hover:to-pink-500/30 border border-purple-500/30 hover:border-purple-400/50 text-purple-300 hover:text-white transition-all duration-200 shadow-lg shadow-purple-500/10 hover:shadow-purple-500/20"
title="Редактировать"
title="Редактировать цену"
>
<Edit className="w-4 h-4" />
</Button>
)}
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
size="sm"
className="h-8 w-8 p-0 bg-gradient-to-r from-red-500/20 to-red-600/20 hover:from-red-500/30 hover:to-red-600/30 border border-red-500/30 hover:border-red-400/50 text-red-300 hover:text-white transition-all duration-200 shadow-lg shadow-red-500/10 hover:shadow-red-500/20"
title="Удалить"
>
<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">
Вы действительно хотите удалить расходник &ldquo;{supply.name}&rdquo;? Это действие
необратимо.
</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={() => removeRow(supply.id!, supply.isNew)}
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>

View File

@ -4,17 +4,12 @@ import { useQuery, useMutation } from '@apollo/client'
import {
ArrowLeft,
Building2,
MapPin,
Phone,
Mail,
Star,
Search,
Package,
Plus,
Minus,
ShoppingCart,
Wrench,
Box,
} from 'lucide-react'
import Image from 'next/image'
import { useRouter } from 'next/navigation'
@ -135,14 +130,6 @@ export function CreateConsumablesSupplyPage() {
}).format(amount)
}
const renderStars = (rating: number = 4.5) => {
return Array.from({ length: 5 }, (_, i) => (
<Star
key={i}
className={`h-3 w-3 ${i < Math.floor(rating) ? 'text-yellow-400 fill-current' : 'text-gray-400'}`}
/>
))
}
const updateConsumableQuantity = (productId: string, quantity: number) => {
const product = supplierProducts.find((p: ConsumableProduct) => p.id === productId)
@ -412,7 +399,7 @@ export function CreateConsumablesSupplyPage() {
variant="ghost"
size="sm"
onClick={() => setSelectedSupplier(null)}
className="text-white/70 hover:text-white hover:bg-white/20 text-sm h-8 px-3 flex-shrink-0 rounded-full transition-all duration-300 hover:scale-105"
className="text-white/70 hover:text-white hover:bg-white/20 text-sm h-8 px-3 flex-shrink-0 rounded-full transition-all duration-300"
>
Сбросить
</Button>
@ -440,7 +427,7 @@ export function CreateConsumablesSupplyPage() {
{filteredSuppliers.slice(0, 7).map((supplier: ConsumableSupplier, index) => (
<Card
key={supplier.id}
className={`relative cursor-pointer transition-all duration-300 border flex-shrink-0 rounded-xl overflow-hidden group hover:scale-105 hover:shadow-xl ${
className={`relative cursor-pointer transition-all duration-300 border flex-shrink-0 rounded-xl overflow-hidden group ${
selectedSupplier?.id === supplier.id
? 'bg-gradient-to-br from-orange-500/30 via-orange-400/20 to-orange-500/30 border-orange-400/60 shadow-lg shadow-orange-500/25'
: 'bg-gradient-to-br from-white/10 via-white/5 to-white/10 border-white/20 hover:from-white/20 hover:via-white/10 hover:to-white/20 hover:border-white/40'
@ -494,7 +481,7 @@ export function CreateConsumablesSupplyPage() {
))}
{filteredSuppliers.length > 7 && (
<div
className="flex-shrink-0 flex flex-col items-center justify-center bg-gradient-to-br from-white/10 to-white/5 rounded-xl border border-white/20 text-white/70 hover:text-white transition-all duration-300 hover:scale-105"
className="flex-shrink-0 flex flex-col items-center justify-center bg-gradient-to-br from-white/10 to-white/5 rounded-xl border border-white/20 text-white/70 hover:text-white transition-all duration-300"
style={{ width: 'calc((100% - 48px) / 7)' }}
>
<div className="text-lg font-bold text-purple-300">+{filteredSuppliers.length - 7}</div>
@ -576,7 +563,7 @@ export function CreateConsumablesSupplyPage() {
alt={product.name}
width={100}
height={100}
className="w-full h-full object-cover group-hover:scale-110 transition-transform duration-300"
className="w-full h-full object-cover"
/>
) : product.mainImage ? (
<Image
@ -584,7 +571,7 @@ export function CreateConsumablesSupplyPage() {
alt={product.name}
width={100}
height={100}
className="w-full h-full object-cover group-hover:scale-110 transition-transform duration-300"
className="w-full h-full object-cover"
/>
) : (
<div className="w-full h-full flex items-center justify-center">

View File

@ -4,7 +4,6 @@ import { useQuery, useMutation } from '@apollo/client'
import {
ArrowLeft,
Building2,
Star,
Search,
Package,
Plus,
@ -27,6 +26,9 @@ import { OrganizationAvatar } from '@/components/market/organization-avatar'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
// ВРЕМЕННО ОТКЛЮЧЕНО: импорты для верхней панели - до исправления Apollo ошибки
// import { DatePicker } from '@/components/ui/date-picker'
// import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { CREATE_SUPPLY_ORDER } from '@/graphql/mutations'
import {
GET_MY_COUNTERPARTIES,
@ -54,6 +56,7 @@ interface GoodsSupplier {
users?: Array<{ id: string; avatar?: string; managerName?: string }>
createdAt: string
rating?: number
market?: string // Принадлежность к рынку согласно rules-complete.md v10.0
}
interface GoodsProduct {
@ -153,7 +156,7 @@ export function CreateSuppliersSupplyPage() {
const [selectedSupplier, setSelectedSupplier] = useState<GoodsSupplier | null>(null)
const [selectedGoods, setSelectedGoods] = useState<SelectedGoodsItem[]>([])
const [searchQuery, setSearchQuery] = useState('')
const [productSearchQuery, setProductSearchQuery] = useState('')
const [productSearchQuery] = useState('')
// Обязательные поля согласно rules2.md 9.7.8
const [deliveryDate, setDeliveryDate] = useState('')
@ -179,30 +182,6 @@ export function CreateSuppliersSupplyPage() {
(GoodsProduct & { selectedQuantity: number; supplierId: string; supplierName: string })[]
>([])
// Состояние для увеличения карточек согласно rules-complete.md 9.2.2.2
const [expandedCard, setExpandedCard] = useState<string | null>(null)
const [hoverTimeout, setHoverTimeout] = useState<NodeJS.Timeout | null>(null)
// Функции для увеличения карточек при наведении
const handleCardMouseEnter = (productId: string) => {
if (hoverTimeout) {
clearTimeout(hoverTimeout)
}
const timeout = setTimeout(() => {
setExpandedCard(productId)
}, 2000) // 2 секунды согласно правилам
setHoverTimeout(timeout)
}
const handleCardMouseLeave = () => {
if (hoverTimeout) {
clearTimeout(hoverTimeout)
setHoverTimeout(null)
}
setExpandedCard(null)
}
// Загружаем партнеров-поставщиков согласно rules2.md 13.3
const {
@ -361,6 +340,25 @@ export function CreateSuppliersSupplyPage() {
})
// Моковые логистические компании согласно rules2.md 9.7.7
// Функции для работы с рынками согласно rules-complete.md v10.0
const getMarketLabel = (market?: string) => {
const marketLabels = {
'sadovod': 'Садовод',
'tyak-moscow': 'ТЯК Москва',
'opt-market': 'ОПТ Маркет',
}
return marketLabels[market as keyof typeof marketLabels] || market
}
const getMarketBadgeStyle = (market?: string) => {
const styles = {
'sadovod': 'bg-green-500/20 text-green-300 border-green-500/30',
'tyak-moscow': 'bg-blue-500/20 text-blue-300 border-blue-500/30',
'opt-market': 'bg-purple-500/20 text-purple-300 border-purple-500/30',
}
return styles[market as keyof typeof styles] || 'bg-gray-500/20 text-gray-300 border-gray-500/30'
}
const logisticsCompanies: LogisticsCompany[] = [
{ id: 'express', name: 'Экспресс доставка', estimatedCost: 2500, deliveryDays: 1, type: 'EXPRESS' },
{ id: 'standard', name: 'Стандартная доставка', estimatedCost: 1200, deliveryDays: 3, type: 'STANDARD' },
@ -386,11 +384,7 @@ export function CreateSuppliersSupplyPage() {
}))
}
const updateProductQuantity = (productId: string, delta: number): void => {
const currentQuantity = getProductQuantity(productId)
const newQuantity = currentQuantity + delta
setProductQuantity(productId, newQuantity)
}
// Removed unused updateProductQuantity function
// Добавление товара в корзину из карточки с заданным количеством
const addToCart = (product: GoodsProduct) => {
@ -445,11 +439,7 @@ export function CreateSuppliersSupplyPage() {
setProductQuantity(product.id, 0)
}
// Открытие модального окна для детального добавления
const openAddModal = (product: GoodsProduct) => {
setSelectedProductForModal(product)
setIsModalOpen(true)
}
// Removed unused openAddModal function
// Функции для работы с рецептурой
const initializeProductRecipe = (productId: string) => {
@ -704,40 +694,37 @@ export function CreateSuppliersSupplyPage() {
Назад
</Button>
<div className="h-4 w-px bg-white/20"></div>
<div className="p-2 bg-green-400/10 rounded-lg border border-green-400/20">
<Building2 className="h-4 w-4 text-green-400" />
</div>
<div>
<h2 className="text-base font-semibold text-white">Поставщики товаров</h2>
<Badge className="bg-purple-500/20 text-purple-300 border border-purple-500/30 text-xs font-medium mt-0.5">
Создание поставки
</Badge>
</div>
<Building2 className="h-5 w-5 text-blue-400" />
<h2 className="text-lg font-semibold text-white">Поставщики</h2>
</div>
<div className="flex-1 max-w-sm">
<div className="w-64">
<div className="relative">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-white/40 h-4 w-4" />
<Input
placeholder="Поиск..."
placeholder="Поиск поставщиков..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="bg-white/5 border-white/10 text-white placeholder:text-white/50 pl-10 h-9 text-sm transition-all duration-200 focus:border-white/20"
/>
</div>
{allCounterparties.length === 0 && (
<Button
variant="outline"
size="sm"
onClick={() => router.push('/market')}
className="glass-secondary hover:text-white/90 transition-all duration-200 mt-2 w-full"
>
<Building2 className="h-3 w-3 mr-2" />
Найти поставщиков в маркете
</Button>
)}
</div>
</div>
{/* Кнопка поиска в маркете */}
{allCounterparties.length === 0 && (
<div className="mt-4">
<Button
variant="outline"
size="sm"
onClick={() => router.push('/market')}
className="glass-secondary hover:text-white/90 transition-all duration-200"
>
<Building2 className="h-3 w-3 mr-2" />
Найти поставщиков в маркете
</Button>
</div>
)}
{/* Список поставщиков согласно visual-design-rules.md */}
<div className="flex-1 min-h-0">
{isLoading ? (
@ -790,32 +777,17 @@ export function CreateSuppliersSupplyPage() {
<OrganizationAvatar organization={supplier} size="sm" />
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<h4 className="text-white font-medium text-sm truncate group-hover:text-white transition-colors">
{supplier.name || supplier.fullName}
</h4>
{supplier.rating && (
<div className="flex items-center gap-1 bg-yellow-400/10 px-2 py-0.5 rounded-full">
<Star className="h-3 w-3 text-yellow-400 fill-current" />
<span className="text-yellow-300 text-xs font-medium">{supplier.rating}</span>
</div>
<h4 className="text-white font-medium text-sm truncate group-hover:text-white transition-colors">
{supplier.name || supplier.fullName}
</h4>
<div className="flex items-center gap-2 mt-1">
<p className="text-white/60 text-xs font-mono">ИНН: {supplier.inn}</p>
{supplier.market && (
<Badge className={`text-xs font-medium border ${getMarketBadgeStyle(supplier.market)}`}>
{getMarketLabel(supplier.market)}
</Badge>
)}
</div>
<div className="flex items-center gap-2 mb-1">
<p className="text-white/60 text-xs font-mono">ИНН: {supplier.inn}</p>
<Badge
className={`text-xs font-medium ${
supplier.type === 'WHOLESALE'
? 'bg-green-500/20 text-green-300 border border-green-500/30'
: 'bg-yellow-500/20 text-yellow-300 border border-yellow-500/30'
}`}
>
{supplier.type === 'WHOLESALE' ? 'Поставщик' : supplier.type}
</Badge>
</div>
{supplier.address && (
<p className="text-white/50 text-xs line-clamp-1">{supplier.address}</p>
)}
</div>
</div>
</div>
@ -826,158 +798,118 @@ export function CreateSuppliersSupplyPage() {
</div>
</div>
{/* БЛОК 2: КАРТОЧКИ ТОВАРОВ - новый блок согласно rules-complete.md 9.2.2 */}
<div
className="bg-white/10 backdrop-blur-xl border border-white/20 rounded-2xl flex-shrink-0 flex flex-col"
style={{ height: '160px' }}
>
<div className="p-4 border-b border-white/10 flex-shrink-0">
<div className="flex items-center justify-between gap-4">
<div className="flex items-center gap-3">
<div className="p-2 bg-blue-400/10 rounded-lg border border-blue-400/20">
<Package className="h-4 w-4 text-blue-400" />
</div>
<div>
<h3 className="text-base font-semibold text-white">
{selectedSupplier
? `Товары ${selectedSupplier.name || selectedSupplier.fullName}`
: 'Карточки товаров'}
</h3>
<p className="text-white/60 text-sm">Компактные карточки для быстрого выбора</p>
</div>
{/* БЛОК 2: КАРТОЧКИ ТОВАРОВ */}
<div className="bg-white/10 backdrop-blur-xl border border-white/20 rounded-2xl flex-shrink-0">
<div className="flex gap-3 overflow-x-auto p-4" style={{ scrollbarWidth: 'thin' }}>
{selectedSupplier && products.length > 0 && products.map((product: GoodsProduct) => {
return (
<div
key={product.id}
className="relative flex-shrink-0 bg-white/5 rounded-lg overflow-hidden border cursor-pointer transition-all duration-300 group w-20 h-28 border-white/10 hover:border-white/30"
onClick={() => {
// Добавляем товар в детальный каталог (блок 3)
if (!allSelectedProducts.find((p) => p.id === product.id)) {
setAllSelectedProducts((prev) => [
...prev,
{
...product,
selectedQuantity: 1,
supplierId: selectedSupplier.id,
supplierName: selectedSupplier.name || selectedSupplier.fullName || 'Поставщик',
},
])
}
}}
>
{product.mainImage ? (
<Image
src={product.mainImage}
alt={product.name}
width={80}
height={112}
className="w-full h-full object-cover"
/>
) : (
<div className="w-full h-full flex items-center justify-center">
<Package className="h-6 w-6 text-white/40" />
</div>
)}
</div>
</div>
</div>
<div className="flex-1 overflow-hidden">
{!selectedSupplier ? (
<div className="flex items-center justify-center h-full">
<div className="text-center">
<Package className="h-8 w-8 text-blue-400/50 mx-auto mb-2" />
<p className="text-white/60 text-sm">Выберите поставщика</p>
</div>
</div>
) : products.length === 0 ? (
<div className="flex items-center justify-center h-full">
<div className="text-center">
<Package className="h-8 w-8 text-white/40 mx-auto mb-2" />
<p className="text-white/60 text-sm">Нет товаров</p>
</div>
</div>
) : (
<div className="flex gap-3 overflow-x-auto p-4 h-full" style={{ scrollbarWidth: 'thin' }}>
{products.map((product: GoodsProduct) => {
const isExpanded = expandedCard === product.id
return (
<div
key={product.id}
className={`relative flex-shrink-0 bg-white/5 rounded-lg overflow-hidden border cursor-pointer transition-all duration-300 group ${
isExpanded
? 'w-80 h-112 border-white/50 shadow-2xl z-50 scale-105'
: 'w-20 h-28 border-white/10 hover:border-white/30'
}`}
style={{
transform: isExpanded ? 'scale(4)' : 'scale(1)',
zIndex: isExpanded ? 50 : 1,
transformOrigin: 'center center',
}}
onMouseEnter={() => handleCardMouseEnter(product.id)}
onMouseLeave={handleCardMouseLeave}
onClick={() => {
// Добавляем товар в детальный каталог (блок 3)
if (!allSelectedProducts.find((p) => p.id === product.id)) {
setAllSelectedProducts((prev) => [
...prev,
{
...product,
selectedQuantity: 1,
supplierId: selectedSupplier.id,
supplierName: selectedSupplier.name || selectedSupplier.fullName || 'Поставщик',
},
])
}
}}
>
{isExpanded ? (
<div className="p-3 space-y-2 bg-white/10 backdrop-blur-xl h-full">
{product.mainImage ? (
<Image
src={product.mainImage}
alt={product.name}
width={60}
height={60}
className="w-15 h-15 object-cover rounded mx-auto"
/>
) : (
<div className="w-15 h-15 bg-white/5 rounded flex items-center justify-center mx-auto">
<Package className="h-8 w-8 text-white/40" />
</div>
)}
<div className="text-center space-y-1">
<h4 className="text-white font-semibold text-sm truncate">{product.name}</h4>
<p className="text-green-400 font-bold text-base">
{product.price.toLocaleString('ru-RU')}
</p>
{product.category && <p className="text-blue-300 text-xs">{product.category.name}</p>}
{product.quantity !== undefined && (
<p className="text-white/60 text-xs">Доступно: {product.quantity}</p>
)}
<p className="text-white/50 text-xs font-mono">Артикул: {product.article}</p>
</div>
</div>
) : (
<>
{product.mainImage ? (
<Image
src={product.mainImage}
alt={product.name}
width={80}
height={112}
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-200"
/>
) : (
<div className="w-full h-full flex items-center justify-center">
<Package className="h-6 w-6 text-white/40" />
</div>
)}
</>
)}
</div>
)
})}
</div>
)}
)
})}
</div>
</div>
{/* БЛОК 3: ТОВАРЫ ПОСТАВЩИКА - детальный каталог согласно rules-complete.md 9.2 */}
{/* БЛОК 3: ТОВАРЫ ПОСТАВЩИКА - детальный каталог согласно rules-complete.md 9.2.3 */}
<div className="bg-white/10 backdrop-blur-xl border border-white/20 rounded-2xl flex-1 min-h-0 flex flex-col">
<div className="p-6 border-b border-white/10 flex-shrink-0">
<div className="flex items-center justify-between gap-6">
<div className="flex items-center gap-3">
<div className="p-2 bg-blue-400/10 rounded-lg border border-blue-400/20">
<Package className="h-6 w-6 text-blue-400" />
</div>
<div>
<h3 className="text-xl font-semibold text-white">
Детальный каталог ({allSelectedProducts.length} товаров)
</h3>
<p className="text-white/60 text-sm mt-1">Товары из блока карточек для детального управления</p>
</div>
{/* ВРЕМЕННО ОТКЛЮЧЕНО: Верхняя панель согласно правилам 9.2.3.1 - до исправления Apollo ошибки
{!counterpartiesLoading && (
<div className="flex items-center gap-4 p-4 bg-white/10 backdrop-blur-xl border border-white/20 rounded-2xl mb-4">
<DatePicker
placeholder="Дата поставки"
value={deliveryDate}
onChange={setDeliveryDate}
className="min-w-[140px]"
/>
<Select value={selectedFulfillment} onValueChange={setSelectedFulfillment}>
<SelectTrigger className="glass-input min-w-[200px]">
<SelectValue placeholder="Выберите фулфилмент" />
</SelectTrigger>
<SelectContent>
{allCounterparties && allCounterparties.length > 0 ? (
allCounterparties
.filter((partner) => partner.type === 'FULFILLMENT')
.map((fulfillment) => (
<SelectItem key={fulfillment.id} value={fulfillment.id}>
{fulfillment.name || fulfillment.fullName}
</SelectItem>
))
) : (
<SelectItem value="" disabled>
Нет доступных фулфилмент-центров
</SelectItem>
)}
</SelectContent>
</Select>
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-white/40" />
<Input
placeholder="Поиск товаров..."
value={productSearchQuery}
onChange={(e) => setProductSearchQuery(e.target.value)}
className="pl-10 glass-input"
/>
</div>
</div>
)}
{counterpartiesLoading && (
<div className="flex items-center justify-center p-4 bg-white/5 backdrop-blur-xl border border-white/10 rounded-2xl mb-4">
<div className="text-white/60 text-sm">Загрузка партнеров...</div>
</div>
)}
{counterpartiesError && (
<div className="flex items-center justify-center p-4 bg-red-500/10 backdrop-blur-xl border border-red-500/20 rounded-2xl mb-4">
<div className="text-red-300 text-sm">
Ошибка загрузки партнеров: {counterpartiesError.message}
</div>
</div>
)}
*/}
{/* Заголовок каталога */}
<div className="px-6 py-4 border-b border-white/10 flex-shrink-0">
<div className="flex items-center gap-3">
<div className="p-2 bg-blue-400/10 rounded-lg border border-blue-400/20">
<Package className="h-6 w-6 text-blue-400" />
</div>
<div>
<h3 className="text-xl font-semibold text-white">
Детальный каталог ({allSelectedProducts.length} товаров)
</h3>
<p className="text-white/60 text-sm mt-1">Товары для детального управления поставкой</p>
</div>
{selectedSupplier && (
<div className="flex-1 max-w-sm">
<div className="relative">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-white/40 h-4 w-4" />
<Input
placeholder="Поиск товаров..."
value={productSearchQuery}
onChange={(e) => setProductSearchQuery(e.target.value)}
className="glass-input text-white placeholder:text-white/50 pl-10 h-10 transition-all duration-200 focus-visible:ring-ring/50"
/>
</div>
</div>
)}
</div>
</div>
@ -991,7 +923,7 @@ export function CreateSuppliersSupplyPage() {
<div>
<h4 className="text-xl font-medium text-white mb-2">Детальный каталог пуст</h4>
<p className="text-white/60 max-w-sm mx-auto">
Добавьте товары из блока карточек выше для детального управления
Добавьте товары
</p>
</div>
</div>
@ -1289,7 +1221,6 @@ export function CreateSuppliersSupplyPage() {
const quantity = getProductQuantity(product.id)
const recipeCost = calculateRecipeCost(product.id)
const productTotal = product.price * quantity
const totalRecipePrice = productTotal + recipeCost.total
return (
<>

View File

@ -6,7 +6,7 @@ import React, { useState } from 'react'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Card } from '@/components/ui/card'
import { SelectedCard, WildberriesCard } from '@/types/supplies'
import { SelectedCard } from '@/types/supplies'
import { WBProductCards } from './wb-product-cards'
@ -74,7 +74,6 @@ const mockWholesalers: Wholesaler[] = [
export function CreateSupplyForm({ onClose, onSupplyCreated }: CreateSupplyFormProps) {
const [selectedVariant, setSelectedVariant] = useState<'cards' | 'wholesaler' | null>(null)
const [selectedWholesaler, setSelectedWholesaler] = useState<Wholesaler | null>(null)
const [selectedCards, setSelectedCards] = useState<SelectedCard[]>([])
const renderStars = (rating: number) => {
return Array.from({ length: 5 }, (_, i) => (
@ -86,7 +85,6 @@ export function CreateSupplyForm({ onClose, onSupplyCreated }: CreateSupplyFormP
}
const handleCardsComplete = (cards: SelectedCard[]) => {
setSelectedCards(cards)
console.warn('Карточки товаров выбраны:', cards)
// TODO: Здесь будет создание поставки с данными карточек
onSupplyCreated()
@ -164,7 +162,7 @@ export function CreateSupplyForm({ onClose, onSupplyCreated }: CreateSupplyFormP
{mockWholesalers.map((wholesaler) => (
<Card
key={wholesaler.id}
className="bg-white/10 backdrop-blur border-white/20 p-6 cursor-pointer transition-all hover:bg-white/15 hover:border-white/30 hover:scale-105"
className="bg-white/10 backdrop-blur border-white/20 p-6 cursor-pointer transition-all hover:bg-white/15 hover:border-white/30"
onClick={() => setSelectedWholesaler(wholesaler)}
>
<div className="space-y-4">

View File

@ -50,7 +50,7 @@ const CreateSupplyPage = React.memo(() => {
const fulfillmentOrgs = useMemo(() =>
(counterpartiesData?.myCounterparties || []).filter(
(org: Organization) => org.type === 'FULFILLMENT',
), [counterpartiesData?.myCounterparties]
), [counterpartiesData?.myCounterparties],
)
const formatCurrency = useCallback((amount: number) => {

View File

@ -25,12 +25,12 @@ export function ProductCard({ product, selectedQuantity, onQuantityChange, forma
}
return (
<Card className="bg-white/10 backdrop-blur border-white/20 overflow-hidden group hover:bg-white/15 hover:border-white/30 transition-all duration-300 hover:scale-105 hover:shadow-2xl">
<Card className="bg-white/10 backdrop-blur border-white/20 overflow-hidden group hover:bg-white/15 hover:border-white/30 transition-all duration-300">
<div className="aspect-square relative bg-white/5 overflow-hidden">
<img
src={product.mainImage || '/api/placeholder/400/400'}
alt={product.name}
className="w-full h-full object-cover group-hover:scale-110 transition-transform duration-500"
className="w-full h-full object-cover"
/>
{/* Количество в наличии */}

View File

@ -16,7 +16,7 @@ interface SupplierCardProps {
export function SupplierCard({ supplier, onClick }: SupplierCardProps) {
return (
<Card
className="bg-white/10 backdrop-blur border-white/20 p-4 cursor-pointer transition-all hover:bg-white/15 hover:border-white/30 hover:scale-[1.02]"
className="bg-white/10 backdrop-blur border-white/20 p-4 cursor-pointer transition-all hover:bg-white/15 hover:border-white/30"
onClick={onClick}
>
<div className="space-y-3">

View File

@ -1,7 +1,24 @@
'use client'
import { useQuery, useMutation } from '@apollo/client'
import { format } from 'date-fns'
import { ru } from 'date-fns/locale'
import {
Search,
Plus,
Minus,
ShoppingCart,
Calendar as CalendarIcon,
Package,
ArrowLeft,
Check,
Eye,
ChevronLeft,
ChevronRight,
} from 'lucide-react'
import React, { useState, useEffect } from 'react'
import DatePicker from 'react-datepicker'
import 'react-datepicker/dist/react-datepicker.css'
import { toast } from 'sonner'
import { Badge } from '@/components/ui/badge'
@ -9,43 +26,16 @@ import { Button } from '@/components/ui/button'
import { Card } from '@/components/ui/card'
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import { ProductCardSkeletonGrid } from '@/components/ui/product-card-skeleton'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import 'react-datepicker/dist/react-datepicker.css'
import { CREATE_WILDBERRIES_SUPPLY } from '@/graphql/mutations'
import { GET_MY_COUNTERPARTIES, GET_COUNTERPARTY_SERVICES, GET_COUNTERPARTY_SUPPLIES } from '@/graphql/queries'
import { useAuth } from '@/hooks/useAuth'
import { useSidebar } from '@/hooks/useSidebar'
import {
Search,
Plus,
Minus,
ShoppingCart,
Calendar as CalendarIcon,
Phone,
User,
MapPin,
Package,
Wrench,
ArrowLeft,
Check,
Eye,
ChevronLeft,
ChevronRight,
} from 'lucide-react'
import { apolloClient } from '@/lib/apollo-client'
import { WildberriesService } from '@/services/wildberries-service'
import { useQuery, useMutation } from '@apollo/client'
import { format } from 'date-fns'
import { ru } from 'date-fns/locale'
import { SelectedCard, FulfillmentService, ConsumableService, WildberriesCard } from '@/types/supplies'
import { SelectedCard, WildberriesCard } from '@/types/supplies'
interface Organization {
id: string
@ -64,7 +54,7 @@ interface WBProductCardsProps {
}
export function WBProductCards({
onBack,
_onBack, // eslint-disable-line @typescript-eslint/no-unused-vars
onComplete,
showSummary: externalShowSummary,
setShowSummary: externalSetShowSummary,
@ -88,7 +78,6 @@ export function WBProductCards({
const actualShowSummary = externalShowSummary !== undefined ? externalShowSummary : showSummary
const actualSetShowSummary = externalSetShowSummary || setShowSummary
const [globalDeliveryDate, setGlobalDeliveryDate] = useState<Date | undefined>(undefined)
const [fulfillmentServices, setFulfillmentServices] = useState<FulfillmentService[]>([])
const [organizationServices, setOrganizationServices] = useState<{
[orgId: string]: Array<{ id: string; name: string; description?: string; price: number }>
}>({})
@ -193,13 +182,6 @@ export function WBProductCards({
},
})
// Данные рынков можно будет загружать через GraphQL в будущем
const markets = [
{ value: 'sadovod', label: 'Садовод' },
{ value: 'luzhniki', label: 'Лужники' },
{ value: 'tishinka', label: 'Тишинка' },
{ value: 'food-city', label: 'Фуд Сити' },
]
// Загружаем карточки из GraphQL запроса
useEffect(() => {
@ -1007,12 +989,11 @@ export function WBProductCards({
{wbCards.map((card) => {
const selectedQuantity = getSelectedQuantity(card)
const isSelected = selectedQuantity > 0
const selectedCard = actualSelectedCards.find((sc) => sc.card.nmID === card.nmID)
return (
<Card
key={card.nmID}
className={`bg-white/10 backdrop-blur border-white/20 transition-all hover:scale-105 hover:shadow-2xl group ${isSelected ? 'ring-2 ring-purple-500/50 bg-purple-500/10' : ''} relative overflow-hidden`}
className={`bg-white/10 backdrop-blur border-white/20 transition-all group ${isSelected ? 'ring-2 ring-purple-500/50 bg-purple-500/10' : ''} relative overflow-hidden`}
>
<div className="p-2 space-y-2">
{/* Изображение и основная информация */}
@ -1022,7 +1003,7 @@ export function WBProductCards({
<img
src={WildberriesService.getCardImage(card, 'c516x688') || '/api/placeholder/300/300'}
alt={card.title}
className="w-full h-full object-cover cursor-pointer group-hover:scale-110 transition-transform duration-500"
className="w-full h-full object-cover cursor-pointer"
onClick={() => handleCardClick(card)}
/>

View File

@ -49,6 +49,7 @@ interface Product {
isActive: boolean
createdAt: string
updatedAt: string
organization: { id: string; market?: string }
}
interface ProductCardProps {
@ -57,6 +58,30 @@ interface ProductCardProps {
onDeleted: () => void
}
// Функция для отображения бэйджа рынка согласно правилам системы
const getMarketBadge = (market?: string) => {
if (!market) return null
const marketStyles = {
sadovod: 'bg-green-500/20 text-green-300 border-green-500/30',
'tyak-moscow': 'bg-blue-500/20 text-blue-300 border-blue-500/30',
}
const marketLabels = {
sadovod: 'Садовод',
'tyak-moscow': 'ТЯК Москва',
}
const style = marketStyles[market as keyof typeof marketStyles] || 'bg-gray-500/20 text-gray-300 border-gray-500/30'
const label = marketLabels[market as keyof typeof marketLabels] || market
return (
<span className={`inline-flex items-center px-2 py-1 rounded text-xs font-medium border ${style}`}>
{label}
</span>
)
}
export function ProductCard({ product, onEdit, onDeleted }: ProductCardProps) {
const [deleteProduct, { loading: deleting }] = useMutation(DELETE_PRODUCT)
const [imageDialogOpen, setImageDialogOpen] = useState(false)
@ -103,7 +128,7 @@ export function ProductCard({ product, onEdit, onDeleted }: ProductCardProps) {
}
return (
<Card className="glass-card group relative overflow-hidden transition-all duration-300 hover:scale-[1.02] hover:shadow-xl hover:shadow-purple-500/20">
<Card className="glass-card group relative overflow-hidden transition-all duration-300">
{/* Изображение товара */}
<div className="relative h-48 bg-white/5 overflow-hidden flex items-center justify-center">
{product.mainImage || product.images[0] ? (
@ -115,7 +140,7 @@ export function ProductCard({ product, onEdit, onDeleted }: ProductCardProps) {
alt={product.name}
width={300}
height={200}
className="w-full h-full object-contain transition-transform duration-300 group-hover:scale-110"
className="w-full h-full object-contain"
/>
</div>
</DialogTrigger>
@ -248,6 +273,9 @@ export function ProductCard({ product, onEdit, onDeleted }: ProductCardProps) {
{product.type === 'PRODUCT' ? 'Товар' : 'Расходник'}
</Badge>
{/* Рынок */}
{getMarketBadge(product.organization?.market)}
{/* Категория */}
{product.category && (
<Badge variant="outline" className="glass-secondary text-white/60 border-white/20 text-xs">

View File

@ -38,6 +38,7 @@ interface Product {
images: string[]
mainImage: string
isActive: boolean
organization?: { id: string; market?: string }
}
interface ProductFormProps {
@ -46,6 +47,7 @@ interface ProductFormProps {
onCancel: () => void
}
export function ProductForm({ product, onSave, onCancel }: ProductFormProps) {
const [formData, setFormData] = useState({
name: product?.name || '',

View File

@ -41,8 +41,34 @@ interface Product {
isActive: boolean
createdAt: string
updatedAt: string
organization: { id: string; market?: string }
}
// Функция для отображения бэйджа рынка согласно правилам системы
const getMarketBadge = (market?: string) => {
if (!market) return null
const marketStyles = {
sadovod: 'bg-green-500/20 text-green-300 border-green-500/30',
'tyak-moscow': 'bg-blue-500/20 text-blue-300 border-blue-500/30',
}
const marketLabels = {
sadovod: 'Садовод',
'tyak-moscow': 'ТЯК Москва',
}
const style = marketStyles[market as keyof typeof marketStyles] || 'bg-gray-500/20 text-gray-300 border-gray-500/30'
const label = marketLabels[market as keyof typeof marketLabels] || market
return (
<span className={`inline-flex items-center px-2 py-1 rounded text-xs font-medium border ${style}`}>
{label}
</span>
)
}
export function WarehouseDashboard() {
const { getSidebarMargin } = useSidebar()
const [isDialogOpen, setIsDialogOpen] = useState(false)
@ -57,13 +83,16 @@ export function WarehouseDashboard() {
const products: Product[] = data?.myProducts || []
// Фильтрация товаров по поисковому запросу
const filteredProducts = products.filter(
(product) =>
const filteredProducts = products.filter((product) => {
const matchesSearch = !searchQuery || (
product.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
product.article.toLowerCase().includes(searchQuery.toLowerCase()) ||
product.category?.name?.toLowerCase().includes(searchQuery.toLowerCase()) ||
product.brand?.toLowerCase().includes(searchQuery.toLowerCase()),
)
product.brand?.toLowerCase().includes(searchQuery.toLowerCase())
)
return matchesSearch
})
const handleCreateProduct = () => {
setEditingProduct(null)
@ -118,13 +147,14 @@ export function WarehouseDashboard() {
<div className="relative max-w-md">
<Input
type="text"
placeholder="Поиск по названию, артикулу, категории..."
placeholder="Поиск по названию, артикулу, рынку, категории..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="glass-input text-white placeholder:text-white/50 h-10"
/>
</div>
{/* Переключатель режимов отображения */}
<div className="flex border border-white/10 rounded-lg overflow-hidden">
<Button
@ -234,7 +264,7 @@ export function WarehouseDashboard() {
<div className="col-span-2">Название</div>
<div className="col-span-1">Артикул</div>
<div className="col-span-1">Тип</div>
<div className="col-span-1">Категория</div>
<div className="col-span-1">Рынок</div>
<div className="col-span-1">Цена</div>
<div className="col-span-1">Остаток</div>
<div className="col-span-1">Заказано</div>
@ -276,7 +306,9 @@ export function WarehouseDashboard() {
{product.type === 'PRODUCT' ? 'Товар' : 'Расходник'}
</span>
</div>
<div className="col-span-1 text-white/70 text-sm">{product.category?.name || 'Нет'}</div>
<div className="col-span-1 text-white/70 text-sm">
{getMarketBadge(product.organization?.market) || <span className="text-white/40 text-xs">Не указан</span>}
</div>
<div className="col-span-1 text-white text-sm font-medium">
{new Intl.NumberFormat('ru-RU', {
style: 'currency',

View File

@ -212,6 +212,7 @@ export const UPDATE_USER_PROFILE = gql`
ogrn
ogrnDate
type
market
status
actualityDate
registrationDate
@ -622,65 +623,33 @@ export const DELETE_SERVICE = gql`
}
`
// Мутации для расходников
export const CREATE_SUPPLY = gql`
mutation CreateSupply($input: SupplyInput!) {
createSupply(input: $input) {
// Мутации для расходников - только обновление цены разрешено
export const UPDATE_SUPPLY_PRICE = gql`
mutation UpdateSupplyPrice($id: ID!, $input: UpdateSupplyPriceInput!) {
updateSupplyPrice(id: $id, input: $input) {
success
message
supply {
id
name
description
price
quantity
pricePerUnit
unit
category
status
date
supplier
minStock
currentStock
imageUrl
warehouseStock
isAvailable
warehouseConsumableId
createdAt
updatedAt
organization {
id
name
}
}
}
}
`
export const UPDATE_SUPPLY = gql`
mutation UpdateSupply($id: ID!, $input: SupplyInput!) {
updateSupply(id: $id, input: $input) {
success
message
supply {
id
name
description
price
quantity
unit
category
status
date
supplier
minStock
currentStock
imageUrl
createdAt
updatedAt
}
}
}
`
export const DELETE_SUPPLY = gql`
mutation DeleteSupply($id: ID!) {
deleteSupply(id: $id)
}
`
// Мутация для заказа поставки расходников
export const CREATE_SUPPLY_ORDER = gql`
mutation CreateSupplyOrder($input: SupplyOrderInput!) {
@ -776,6 +745,11 @@ export const CREATE_LOGISTICS = gql`
description
createdAt
updatedAt
organization {
id
name
fullName
}
}
}
}
@ -795,6 +769,11 @@ export const UPDATE_LOGISTICS = gql`
description
createdAt
updatedAt
organization {
id
name
fullName
}
}
}
}
@ -841,6 +820,10 @@ export const CREATE_PRODUCT = gql`
isActive
createdAt
updatedAt
organization {
id
market
}
}
}
}
@ -880,6 +863,10 @@ export const UPDATE_PRODUCT = gql`
isActive
createdAt
updatedAt
organization {
id
market
}
}
}
}

View File

@ -52,6 +52,7 @@ export const GET_ME = gql`
ogrn
ogrnDate
type
market
status
actualityDate
registrationDate
@ -104,19 +105,32 @@ export const GET_MY_SUPPLIES = gql`
id
name
description
price
quantity
pricePerUnit
unit
category
status
date
supplier
minStock
currentStock
usedStock
imageUrl
warehouseStock
isAvailable
warehouseConsumableId
createdAt
updatedAt
organization {
id
name
}
}
}
`
// Новый запрос для получения доступных расходников для рецептур селлеров
export const GET_AVAILABLE_SUPPLIES_FOR_RECIPE = gql`
query GetAvailableSuppliesForRecipe {
getAvailableSuppliesForRecipe {
id
name
pricePerUnit
unit
imageUrl
warehouseStock
}
}
`
@ -247,6 +261,10 @@ export const GET_MY_PRODUCTS = gql`
isActive
createdAt
updatedAt
organization {
id
market
}
}
}
`
@ -321,6 +339,7 @@ export const GET_MY_COUNTERPARTIES = gql`
managementName
type
address
market
phones
emails
createdAt

View File

@ -687,28 +687,10 @@ export const resolvers = {
throw new GraphQLError('У пользователя нет организации')
}
// Получаем заказы поставок, где фулфилмент является получателем,
// но НЕ создателем (т.е. селлеры заказали расходники для фулфилмента)
const sellerSupplyOrders = await prisma.supplyOrder.findMany({
where: {
fulfillmentCenterId: currentUser.organization.id, // Получатель - мы
organizationId: { not: currentUser.organization.id }, // Создатель - НЕ мы
status: 'DELIVERED', // Только доставленные
},
include: {
organization: true,
partner: true,
items: {
include: {
product: {
include: {
category: true,
},
},
},
},
},
})
// Проверяем, что это фулфилмент центр
if (currentUser.organization.type !== 'FULFILLMENT') {
return [] // Только фулфилменты имеют расходники
}
// Получаем ВСЕ расходники из таблицы supply для фулфилмента
const allSupplies = await prisma.supply.findMany({
@ -717,52 +699,39 @@ export const resolvers = {
orderBy: { createdAt: 'desc' },
})
// Получаем все заказы фулфилмента для себя (чтобы исключить их расходники)
const fulfillmentOwnOrders = await prisma.supplyOrder.findMany({
where: {
organizationId: currentUser.organization.id, // Созданы фулфилментом
fulfillmentCenterId: currentUser.organization.id, // Для себя
status: 'DELIVERED',
},
include: {
items: {
include: {
product: true,
},
},
},
})
// Преобразуем старую структуру в новую согласно GraphQL схеме
const transformedSupplies = allSupplies.map((supply) => ({
id: supply.id,
name: supply.name,
description: supply.description,
pricePerUnit: supply.price ? parseFloat(supply.price.toString()) : null, // Конвертируем Decimal в Number
unit: supply.unit || 'шт', // Единица измерения
imageUrl: supply.imageUrl,
warehouseStock: supply.currentStock || 0, // Остаток на складе
isAvailable: (supply.currentStock || 0) > 0, // Есть ли в наличии
warehouseConsumableId: supply.id, // Связь со складом (пока используем тот же ID)
createdAt: supply.createdAt,
updatedAt: supply.updatedAt,
organization: supply.organization,
}))
// Создаем набор названий товаров из заказов фулфилмента для себя
const fulfillmentProductNames = new Set(
fulfillmentOwnOrders.flatMap((order) => order.items.map((item) => item.product.name)),
)
// Фильтруем расходники: исключаем те, что созданы заказами фулфилмента для себя
const sellerSupplies = allSupplies.filter((supply) => {
// Если расходник соответствует товару из заказа фулфилмента для себя,
// то это расходник фулфилмента, а не селлера
return !fulfillmentProductNames.has(supply.name)
})
// Логирование для отладки
console.warn('🔥🔥🔥 SELLER SUPPLIES RESOLVER CALLED 🔥🔥🔥')
console.warn('📊 Расходники селлеров:', {
console.warn('🔥 SUPPLIES RESOLVER - NEW FORMAT:', {
organizationId: currentUser.organization.id,
organizationType: currentUser.organization.type,
allSuppliesCount: allSupplies.length,
fulfillmentOwnOrdersCount: fulfillmentOwnOrders.length,
fulfillmentProductNames: Array.from(fulfillmentProductNames),
filteredSellerSuppliesCount: sellerSupplies.length,
sellerOrdersCount: sellerSupplyOrders.length,
suppliesCount: transformedSupplies.length,
supplies: transformedSupplies.map((s) => ({
id: s.id,
name: s.name,
pricePerUnit: s.pricePerUnit,
warehouseStock: s.warehouseStock,
isAvailable: s.isAvailable,
})),
})
// Возвращаем только расходники селлеров (исключая расходники фулфилмента)
return sellerSupplies
return transformedSupplies
},
// Расходники фулфилмента (материалы для работы фулфилмента)
myFulfillmentSupplies: async (_: unknown, __: unknown, context: Context) => {
// Доступные расходники для рецептур селлеров (только с ценой и в наличии)
getAvailableSuppliesForRecipe: async (_: unknown, __: unknown, context: Context) => {
if (!context.user) {
throw new GraphQLError('Требуется авторизация', {
extensions: { code: 'UNAUTHENTICATED' },
@ -778,83 +747,90 @@ export const resolvers = {
throw new GraphQLError('У пользователя нет организации')
}
// TypeScript assertion - мы знаем что organization не null после проверки выше
const organization = currentUser.organization
// Селлеры могут получать расходники от своих фулфилмент-партнеров
if (currentUser.organization.type !== 'SELLER') {
return [] // Только селлеры используют рецептуры
}
// Получаем заказы поставок, созданные этим фулфилмент-центром для себя
const fulfillmentSupplyOrders = await prisma.supplyOrder.findMany({
// TODO: В будущем здесь будет логика получения расходников от партнерских фулфилментов
// Пока возвращаем пустой массив, так как эта функциональность еще разрабатывается
console.warn('🔥 getAvailableSuppliesForRecipe called for seller:', {
sellerId: currentUser.organization.id,
sellerName: currentUser.organization.name,
})
return []
},
// Расходники фулфилмента из склада (новая архитектура - синхронизация со склада)
myFulfillmentSupplies: async (_: unknown, __: unknown, context: Context) => {
console.warn('🔥🔥🔥 FULFILLMENT SUPPLIES RESOLVER CALLED (NEW ARCHITECTURE) 🔥🔥🔥')
if (!context.user) {
console.warn('❌ No user in context')
throw new GraphQLError('Требуется авторизация', {
extensions: { code: 'UNAUTHENTICATED' },
})
}
const currentUser = await prisma.user.findUnique({
where: { id: context.user.id },
include: { organization: true },
})
console.warn('👤 Current user:', {
id: currentUser?.id,
phone: currentUser?.phone,
organizationId: currentUser?.organizationId,
organizationType: currentUser?.organization?.type,
organizationName: currentUser?.organization?.name,
})
if (!currentUser?.organization) {
console.warn('❌ No organization for user')
throw new GraphQLError('У пользователя нет организации')
}
// Проверяем что это фулфилмент центр
if (currentUser.organization.type !== 'FULFILLMENT') {
console.warn('❌ User organization is not FULFILLMENT:', currentUser.organization.type)
throw new GraphQLError('Доступ только для фулфилмент центров')
}
// Получаем расходники фулфилмента из таблицы Supply
const supplies = await prisma.supply.findMany({
where: {
organizationId: organization.id, // Создали мы
fulfillmentCenterId: organization.id, // Получатель - мы
status: {
in: ['PENDING', 'CONFIRMED', 'IN_TRANSIT', 'DELIVERED'], // Все статусы
},
organizationId: currentUser.organization.id,
type: 'FULFILLMENT_CONSUMABLES', // Только расходники фулфилмента
},
include: {
partner: true,
items: {
include: {
product: {
include: {
category: true,
},
},
},
},
organization: true,
},
orderBy: { createdAt: 'desc' },
})
// Преобразуем заказы поставок в формат supply для единообразия
const fulfillmentSupplies = fulfillmentSupplyOrders.flatMap((order) =>
order.items.map((item) => ({
id: `fulfillment-order-${order.id}-${item.id}`,
name: item.product.name,
description: item.product.description || `Расходники от ${order.partner.name}`,
price: item.price,
quantity: item.quantity,
unit: 'шт',
category: item.product.category?.name || 'Расходники фулфилмента',
status:
order.status === 'PENDING'
? 'planned'
: order.status === 'CONFIRMED'
? 'confirmed'
: order.status === 'IN_TRANSIT'
? 'in-transit'
: order.status === 'DELIVERED'
? 'in-stock'
: 'planned',
date: order.createdAt,
supplier: order.partner.name || order.partner.fullName || 'Не указан',
minStock: Math.round(item.quantity * 0.1),
currentStock: order.status === 'DELIVERED' ? item.quantity : 0,
usedStock: 0, // TODO: Подсчитывать реальное использование
imageUrl: null,
createdAt: order.createdAt,
updatedAt: order.updatedAt,
organizationId: organization.id,
organization: organization,
shippedQuantity: 0,
})),
)
// Логирование для отладки
console.warn('🔥🔥🔥 FULFILLMENT SUPPLIES RESOLVER CALLED 🔥🔥🔥')
console.warn('📊 Расходники фулфилмента:', {
organizationId: organization.id,
organizationType: organization.type,
fulfillmentOrdersCount: fulfillmentSupplyOrders.length,
fulfillmentSuppliesCount: fulfillmentSupplies.length,
fulfillmentOrders: fulfillmentSupplyOrders.map((o) => ({
id: o.id,
supplierName: o.partner.name,
status: o.status,
itemsCount: o.items.length,
console.warn('🔥🔥🔥 FULFILLMENT SUPPLIES RESOLVER CALLED (NEW ARCHITECTURE) 🔥🔥🔥')
console.warn('📊 Расходники фулфилмента из склада:', {
organizationId: currentUser.organization.id,
organizationType: currentUser.organization.type,
suppliesCount: supplies.length,
supplies: supplies.map((s) => ({
id: s.id,
name: s.name,
type: s.type,
status: s.status,
currentStock: s.currentStock,
quantity: s.quantity,
})),
})
return fulfillmentSupplies
// Преобразуем в формат для фронтенда
return supplies.map((supply) => ({
...supply,
price: supply.price ? parseFloat(supply.price.toString()) : 0,
shippedQuantity: 0, // Добавляем для совместимости
}))
},
// Заказы поставок расходников
@ -1411,12 +1387,6 @@ export const resolvers = {
// Мои товары и расходники (для поставщиков)
myProducts: async (_: unknown, __: unknown, context: Context) => {
console.warn('🔍 MY_PRODUCTS RESOLVER - ВЫЗВАН:', {
hasUser: !!context.user,
userId: context.user?.id,
timestamp: new Date().toISOString(),
})
if (!context.user) {
throw new GraphQLError('Требуется авторизация', {
extensions: { code: 'UNAUTHENTICATED' },
@ -1428,23 +1398,12 @@ export const resolvers = {
include: { organization: true },
})
console.warn('👤 ПОЛЬЗОВАТЕЛЬ НАЙДЕН:', {
userId: currentUser?.id,
hasOrganization: !!currentUser?.organization,
organizationType: currentUser?.organization?.type,
organizationName: currentUser?.organization?.name,
})
if (!currentUser?.organization) {
throw new GraphQLError('У пользователя нет организации')
}
// Проверяем, что это поставщик
if (currentUser.organization.type !== 'WHOLESALE') {
console.warn('❌ ДОСТУП ЗАПРЕЩЕН - НЕ ПОСТАВЩИК:', {
actualType: currentUser.organization.type,
requiredType: 'WHOLESALE',
})
throw new GraphQLError('Товары доступны только для поставщиков')
}
@ -2586,6 +2545,7 @@ export const resolvers = {
bik?: string
accountNumber?: string
corrAccount?: string
market?: string
}
},
context: Context,
@ -2636,6 +2596,7 @@ export const resolvers = {
emails?: object
managementName?: string
managementPost?: string
market?: string
} = {}
// Название организации больше не обновляется через профиль
@ -2651,6 +2612,11 @@ export const resolvers = {
updateData.emails = [{ value: input.email, type: 'main' }]
}
// Обновляем рынок для поставщиков
if (input.market !== undefined) {
updateData.market = input.market === 'none' ? null : input.market
}
// Сохраняем дополнительные контакты в custom полях
// Пока добавим их как дополнительные JSON поля
const customContacts: {
@ -3639,23 +3605,13 @@ export const resolvers = {
}
},
// Создать расходник
createSupply: async (
// Обновить цену расходника (новая архитектура - только цену можно редактировать)
updateSupplyPrice: async (
_: unknown,
args: {
id: string
input: {
name: string
description?: string
price: number
quantity: number
unit: string
category: string
status: string
date: string
supplier: string
minStock: number
currentStock: number
imageUrl?: string
pricePerUnit?: number | null
}
},
context: Context,
@ -3677,167 +3633,68 @@ export const resolvers = {
// Проверяем, что это фулфилмент центр
if (currentUser.organization.type !== 'FULFILLMENT') {
throw new GraphQLError('Расходники доступны только для фулфилмент центров')
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,
unit: args.input.unit,
category: args.input.category,
status: args.input.status,
date: new Date(args.input.date),
supplier: args.input.supplier,
minStock: args.input.minStock,
currentStock: args.input.currentStock,
imageUrl: args.input.imageUrl,
// Находим и обновляем расходник
const existingSupply = await prisma.supply.findFirst({
where: {
id: args.id,
organizationId: currentUser.organization.id,
},
include: { organization: true },
})
return {
success: true,
message: 'Расходник успешно создан',
supply,
if (!existingSupply) {
throw new GraphQLError('Расходник не найден')
}
} 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
unit: string
category: string
status: string
date: string
supplier: string
minStock: number
currentStock: 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({
const updatedSupply = 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,
unit: args.input.unit,
category: args.input.category,
status: args.input.status,
date: new Date(args.input.date),
supplier: args.input.supplier,
minStock: args.input.minStock,
currentStock: args.input.currentStock,
imageUrl: args.input.imageUrl,
pricePerUnit: args.input.pricePerUnit, // Обновляем цену продажи, НЕ цену закупки
updatedAt: new Date(),
},
include: { organization: true },
})
// Преобразуем в новый формат для GraphQL
const transformedSupply = {
id: updatedSupply.id,
name: updatedSupply.name,
description: updatedSupply.description,
pricePerUnit: updatedSupply.price ? parseFloat(updatedSupply.price.toString()) : null, // Конвертируем Decimal в Number
unit: updatedSupply.unit || 'шт',
imageUrl: updatedSupply.imageUrl,
warehouseStock: updatedSupply.currentStock || 0,
isAvailable: (updatedSupply.currentStock || 0) > 0,
warehouseConsumableId: updatedSupply.id,
createdAt: updatedSupply.createdAt,
updatedAt: updatedSupply.updatedAt,
organization: updatedSupply.organization,
}
console.warn('🔥 SUPPLY PRICE UPDATED:', {
id: transformedSupply.id,
name: transformedSupply.name,
oldPrice: existingSupply.price,
newPrice: transformedSupply.pricePerUnit,
})
return {
success: true,
message: 'Расходник успешно обновлен',
supply,
message: 'Цена расходника успешно обновлена',
supply: transformedSupply,
}
} catch (error) {
console.error('Error updating supply:', error)
console.error('Error updating supply price:', error)
return {
success: false,
message: 'Ошибка при обновлении расходника',
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
}
},
// Использовать расходники фулфилмента
useFulfillmentSupplies: async (
_: unknown,
@ -4190,7 +4047,7 @@ export const resolvers = {
return {
name: product.name,
description: product.description || `Заказано у ${partner.name}`,
price: product.price,
price: product.price, // Цена закупки у поставщика
quantity: item.quantity,
unit: 'шт',
category: productWithCategory?.category?.name || 'Расходники',
@ -5970,7 +5827,7 @@ export const resolvers = {
data: {
name: item.product.name,
description: item.product.description || `Поставка от ${existingOrder.partner.name}`,
price: item.price,
price: item.price, // Цена закупки у поставщика
quantity: item.quantity,
unit: 'шт',
category: item.product.category?.name || 'Расходники',
@ -6730,7 +6587,7 @@ export const resolvers = {
description: isSellerSupply
? `Расходники селлера ${updatedOrder.organization?.name || updatedOrder.organization?.fullName}`
: item.product.description || `Расходники от ${updatedOrder.partner.name}`,
price: item.price,
price: item.price, // Цена закупки у поставщика
quantity: item.quantity,
currentStock: item.quantity,
usedStock: 0,

View File

@ -37,6 +37,9 @@ export const typeDefs = gql`
# Расходники селлеров (материалы клиентов)
mySupplies: [Supply!]!
# Доступные расходники для рецептур селлеров (только с ценой и в наличии)
getAvailableSuppliesForRecipe: [SupplyForRecipe!]!
# Расходники фулфилмента (материалы для работы фулфилмента)
myFulfillmentSupplies: [Supply!]!
@ -173,10 +176,8 @@ export const typeDefs = gql`
updateService(id: ID!, input: ServiceInput!): ServiceResponse!
deleteService(id: ID!): Boolean!
# Работа с расходниками
createSupply(input: SupplyInput!): SupplyResponse!
updateSupply(id: ID!, input: SupplyInput!): SupplyResponse!
deleteSupply(id: ID!): Boolean!
# Работа с расходниками (только обновление цены разрешено)
updateSupplyPrice(id: ID!, input: UpdateSupplyPriceInput!): SupplyResponse!
# Использование расходников фулфилмента
useFulfillmentSupplies(input: UseFulfillmentSuppliesInput!): SupplyResponse!
@ -278,6 +279,7 @@ export const typeDefs = gql`
ogrn: String
ogrnDate: DateTime
type: OrganizationType!
market: String
status: String
actualityDate: DateTime
registrationDate: DateTime
@ -335,6 +337,9 @@ export const typeDefs = gql`
bik: String
accountNumber: String
corrAccount: String
# Рынок для поставщиков
market: String
}
input FulfillmentRegistrationInput {
@ -516,38 +521,45 @@ export const typeDefs = gql`
id: ID!
name: String!
description: String
price: Float!
quantity: Int!
unit: String
category: String
status: String
date: DateTime!
supplier: String
minStock: Int
currentStock: Int
usedStock: Int
# Новые поля для Services архитектуры
pricePerUnit: Float # Цена за единицу для рецептур (может быть null)
unit: String! # Единица измерения: "шт", "кг", "м"
warehouseStock: Int! # Остаток на складе (readonly)
isAvailable: Boolean! # Есть ли на складе (влияет на цвет)
warehouseConsumableId: ID! # Связь со складом
# Поля из базы данных для обратной совместимости
price: Float! # Цена закупки у поставщика (не меняется)
quantity: Int! # Из Prisma schema
category: String! # Из Prisma schema
status: String! # Из Prisma schema
date: DateTime! # Из Prisma schema
supplier: String! # Из Prisma schema
minStock: Int! # Из Prisma schema
currentStock: Int! # Из Prisma schema
usedStock: Int! # Из Prisma schema
type: String! # Из Prisma schema (SupplyType enum)
sellerOwnerId: ID # Из Prisma schema
sellerOwner: Organization # Из Prisma schema
shopLocation: String # Из Prisma schema
imageUrl: String
type: SupplyType!
sellerOwner: Organization # Селлер-владелец (для расходников селлеров)
shopLocation: String # Местоположение в магазине фулфилмента
createdAt: DateTime!
updatedAt: DateTime!
organization: Organization!
}
input SupplyInput {
# Для рецептур селлеров - только доступные с ценой
type SupplyForRecipe {
id: ID!
name: String!
description: String
price: Float!
quantity: Int!
pricePerUnit: Float! # Всегда не null
unit: String!
category: String!
status: String!
date: DateTime!
supplier: String!
minStock: Int!
currentStock: Int!
imageUrl: String
warehouseStock: Int! # Всегда > 0
}
# Для обновления цены расходника в разделе Услуги
input UpdateSupplyPriceInput {
pricePerUnit: Float # Может быть null (цена не установлена)
}
input UseFulfillmentSuppliesInput {
@ -556,6 +568,14 @@ export const typeDefs = gql`
description: String # Описание использования (например, "Подготовка 300 продуктов")
}
# Устаревшие типы для обратной совместимости
input SupplyInput {
name: String!
description: String
price: Float!
imageUrl: String
}
type SupplyResponse {
success: Boolean!
message: String!

View File

@ -1,6 +1,5 @@
import { ApolloClient, InMemoryCache, createHttpLink, from } from '@apollo/client'
import { setContext } from '@apollo/client/link/context'
import { onError } from '@apollo/client/link/error'
// HTTP Link для GraphQL запросов
const httpLink = createHttpLink({
@ -19,87 +18,20 @@ const authLink = setContext((operation, { headers }) => {
// Приоритет у админского токена
const token = adminToken || userToken
const tokenType = adminToken ? 'admin' : 'user'
console.warn(
`Apollo Client - Operation: ${operation.operationName}, Token type: ${tokenType}, Token:`,
token ? `${token.substring(0, 20)}...` : 'No token',
)
const authHeaders = {
...headers,
authorization: token ? `Bearer ${token}` : '',
}
console.warn('Apollo Client - Auth headers:', {
authorization: authHeaders.authorization ? 'Bearer ***' : 'No auth',
})
return {
headers: authHeaders,
}
})
// Error Link для обработки ошибок с детальным логированием
const errorLink = onError(({ graphQLErrors, networkError, operation, forward: _forward }) => {
try {
// Расширенная отладочная информация для всех ошибок
const debugInfo = {
hasGraphQLErrors: !!graphQLErrors,
graphQLErrorsLength: graphQLErrors?.length || 0,
hasNetworkError: !!networkError,
operationName: operation?.operationName || 'Unknown',
operationType: (operation?.query?.definitions?.[0] as any)?.operation || 'Unknown',
variables: operation?.variables || {},
}
console.warn('🎯 APOLLO ERROR LINK TRIGGERED:', debugInfo)
// Безопасная обработка GraphQL ошибок
if (graphQLErrors && Array.isArray(graphQLErrors) && graphQLErrors.length > 0) {
console.warn('📊 GRAPHQL ERRORS COUNT:', graphQLErrors.length)
graphQLErrors.forEach((error, index) => {
try {
// Безопасная деструктуризация
const message = error?.message || 'No message'
const locations = error?.locations || []
const path = error?.path || []
const extensions = error?.extensions || {}
console.warn(`🚨 GraphQL Error #${index + 1}:`, {
message,
locations,
path,
extensions,
operation: operation?.operationName || 'Unknown',
})
} catch (innerError) {
console.warn(`❌ Error processing GraphQL error #${index + 1}:`, innerError)
}
})
}
// Безопасная обработка Network ошибок
if (networkError) {
try {
console.warn('🌐 Network Error:', {
message: networkError.message || 'No message',
statusCode: (networkError as any).statusCode || 'No status',
operation: operation?.operationName || 'Unknown',
})
} catch (innerError) {
console.warn('❌ Error processing network error:', innerError)
}
}
} catch (outerError) {
console.warn('❌ Critical error in Apollo error link:', outerError)
}
})
// Создаем Apollo Client
export const apolloClient = new ApolloClient({
link: from([errorLink, authLink, httpLink]),
link: from([authLink, httpLink]),
cache: new InMemoryCache({
typePolicies: {
User: {