
- Add pricePerUnit field to Supply model for seller pricing - Fix updateSupplyPrice mutation to update pricePerUnit only - Separate purchase price (price) from selling price (pricePerUnit) - Fix GraphQL mutations to include organization field (CREATE/UPDATE_LOGISTICS) - Update GraphQL types to make Supply.price required again - Add comprehensive pricing rules to rules-complete.md sections 11.7.5 and 18.8 - Fix supplies-tab.tsx to show debug info and handle user loading Architecture changes: • Supply.price = purchase price from supplier (immutable) • Supply.pricePerUnit = selling price to sellers (mutable by fulfillment) • Warehouse shows purchase price only (readonly) • Services shows/edits selling price only 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
466 lines
20 KiB
TypeScript
466 lines
20 KiB
TypeScript
'use client'
|
||
|
||
import { useQuery, useMutation } from '@apollo/client'
|
||
import { Package, Save, X, Edit, Check } from 'lucide-react'
|
||
import Image from 'next/image'
|
||
import { useState, useEffect, useMemo } from 'react'
|
||
import { toast } from 'sonner'
|
||
|
||
// 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 { UPDATE_SUPPLY_PRICE } from '@/graphql/mutations'
|
||
import { GET_MY_SUPPLIES } from '@/graphql/queries'
|
||
import { useAuth } from '@/hooks/useAuth'
|
||
|
||
interface Supply {
|
||
id: string
|
||
name: string
|
||
description?: string
|
||
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
|
||
name: string
|
||
description: string
|
||
pricePerUnit: string // Цена за единицу - единственное редактируемое поле
|
||
unit: string
|
||
imageUrl: string
|
||
warehouseStock: number
|
||
isAvailable: boolean
|
||
isEditing: boolean
|
||
hasChanges: boolean
|
||
}
|
||
|
||
// PendingChange interface no longer needed
|
||
|
||
export function SuppliesTab() {
|
||
const { user } = useAuth()
|
||
const [editableSupplies, setEditableSupplies] = useState<EditableSupply[]>([])
|
||
// 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 || 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 supplies = data?.mySupplies || []
|
||
|
||
// Преобразуем загруженные расходники в редактируемый формат
|
||
useEffect(() => {
|
||
if (data?.mySupplies && !isInitialized) {
|
||
const convertedSupplies: EditableSupply[] = data.mySupplies.map((supply: Supply) => ({
|
||
id: supply.id,
|
||
name: supply.name,
|
||
description: supply.description || '',
|
||
pricePerUnit: supply.pricePerUnit ? supply.pricePerUnit.toString() : '',
|
||
unit: supply.unit,
|
||
imageUrl: supply.imageUrl || '',
|
||
warehouseStock: supply.warehouseStock,
|
||
isAvailable: supply.isAvailable,
|
||
isEditing: false,
|
||
hasChanges: false,
|
||
}))
|
||
|
||
setEditableSupplies(convertedSupplies)
|
||
setIsInitialized(true)
|
||
}
|
||
}, [data, isInitialized])
|
||
|
||
// Расходники нельзя создавать - они появляются автоматически со склада
|
||
// const addNewRow = () => { ... } - REMOVED
|
||
|
||
// Расходники нельзя удалять - они управляются через склад
|
||
// const removeRow = async (supplyId: string, isNew: boolean) => { ... } - REMOVED
|
||
|
||
// Начать редактирование существующей строки
|
||
const startEditing = (supplyId: string) => {
|
||
setEditableSupplies((prev) =>
|
||
prev.map((supply) => (supply.id === supplyId ? { ...supply, isEditing: true } : supply)),
|
||
)
|
||
}
|
||
|
||
// Отменить редактирование
|
||
const cancelEditing = (supplyId: string) => {
|
||
const supply = editableSupplies.find((s) => s.id === supplyId)
|
||
if (!supply) return
|
||
|
||
// Возвращаем к исходному состоянию
|
||
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) => {
|
||
if (field !== 'pricePerUnit') {
|
||
return // Только цену можно редактировать
|
||
}
|
||
|
||
setEditableSupplies((prev) =>
|
||
prev.map((supply) => {
|
||
if (supply.id !== supplyId) return supply
|
||
|
||
return {
|
||
...supply,
|
||
pricePerUnit: value,
|
||
hasChanges: true,
|
||
}
|
||
}),
|
||
)
|
||
}
|
||
|
||
// Image upload no longer needed - supplies are readonly except price
|
||
|
||
// Сохранить все изменения (только цены)
|
||
const saveAllChanges = async () => {
|
||
setIsSaving(true)
|
||
try {
|
||
const suppliesToSave = editableSupplies.filter((s) => s.hasChanges)
|
||
|
||
for (const supply of suppliesToSave) {
|
||
// Проверяем валидность цены (может быть пустой)
|
||
const pricePerUnit = supply.pricePerUnit.trim() ? parseFloat(supply.pricePerUnit) : null
|
||
|
||
if (supply.pricePerUnit.trim() && (isNaN(pricePerUnit!) || pricePerUnit! <= 0)) {
|
||
toast.error('Введите корректную цену')
|
||
setIsSaving(false)
|
||
return
|
||
}
|
||
|
||
const input = {
|
||
pricePerUnit: pricePerUnit,
|
||
}
|
||
|
||
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,
|
||
),
|
||
},
|
||
})
|
||
}
|
||
}
|
||
},
|
||
})
|
||
}
|
||
|
||
// Сбрасываем флаги изменений
|
||
setEditableSupplies((prev) => prev.map((s) => ({ ...s, hasChanges: false, isEditing: false })))
|
||
|
||
toast.success('Цены успешно обновлены')
|
||
} catch (error) {
|
||
console.error('Error saving changes:', error)
|
||
toast.error('Ошибка при сохранении цен')
|
||
} finally {
|
||
setIsSaving(false)
|
||
}
|
||
}
|
||
|
||
// Проверяем есть ли несохраненные изменения (только цены)
|
||
const hasUnsavedChanges = useMemo(() => {
|
||
return editableSupplies.some((s) => s.hasChanges)
|
||
}, [editableSupplies])
|
||
|
||
return (
|
||
<div className="h-full flex flex-col">
|
||
<Card className="flex-1 bg-white/5 backdrop-blur border-white/10 p-6 overflow-hidden">
|
||
{/* Заголовок и кнопки */}
|
||
<div className="flex items-center justify-between mb-6">
|
||
<div>
|
||
<h2 className="text-lg font-semibold text-white mb-1">Расходники со склада</h2>
|
||
<p className="text-white/70 text-sm">
|
||
Расходники появляются автоматически из поставок. Можно только установить цену.
|
||
</p>
|
||
</div>
|
||
|
||
<div className="flex gap-3">
|
||
{hasUnsavedChanges && (
|
||
<Button
|
||
onClick={saveAllChanges}
|
||
disabled={isSaving}
|
||
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 ? 'Обновление цен...' : 'Сохранить цены'}
|
||
</Button>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
{/* Таблица расходников */}
|
||
<div className="overflow-auto flex-1">
|
||
{loading ? (
|
||
<div className="h-full flex items-center justify-center">
|
||
<div className="text-center">
|
||
<div className="w-16 h-16 bg-white/10 rounded-full flex items-center justify-center mx-auto mb-4 animate-spin">
|
||
<svg className="w-8 h-8 text-white/50" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path
|
||
strokeLinecap="round"
|
||
strokeLinejoin="round"
|
||
strokeWidth={2}
|
||
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"
|
||
/>
|
||
</svg>
|
||
</div>
|
||
<p className="text-white/70 text-sm">Загрузка расходников...</p>
|
||
</div>
|
||
</div>
|
||
) : error ? (
|
||
<div className="h-full flex items-center justify-center">
|
||
<div className="text-center">
|
||
<div className="w-16 h-16 bg-red-500/20 rounded-full flex items-center justify-center mx-auto mb-4">
|
||
<svg className="w-8 h-8 text-red-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path
|
||
strokeLinecap="round"
|
||
strokeLinejoin="round"
|
||
strokeWidth={2}
|
||
d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
||
/>
|
||
</svg>
|
||
</div>
|
||
<h3 className="text-lg font-semibold text-white mb-2">Ошибка загрузки</h3>
|
||
<p className="text-white/70 text-sm mb-4">
|
||
Не удалось загрузить расходники
|
||
{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"
|
||
>
|
||
Попробовать снова
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
) : editableSupplies.length === 0 ? (
|
||
<div className="h-full flex items-center justify-center">
|
||
<div className="text-center">
|
||
<div className="w-16 h-16 bg-white/10 rounded-full flex items-center justify-center mx-auto mb-4">
|
||
<Package className="w-8 h-8 text-white/50" />
|
||
</div>
|
||
<h3 className="text-lg font-semibold text-white mb-2">Пока нет расходников</h3>
|
||
<p className="text-white/70 text-sm mb-4">Расходники появятся автоматически при получении поставок</p>
|
||
</div>
|
||
</div>
|
||
) : (
|
||
<div className="bg-white/5 rounded-lg border border-white/10 overflow-hidden">
|
||
<table className="w-full">
|
||
<thead className="bg-white/5">
|
||
<tr>
|
||
<th className="text-left p-4 text-white font-medium">№</th>
|
||
<th className="text-left p-4 text-white font-medium">Фото</th>
|
||
<th className="text-left p-4 text-white font-medium">Название</th>
|
||
<th className="text-left p-4 text-white font-medium">Остаток</th>
|
||
<th className="text-left p-4 text-white font-medium">Единица</th>
|
||
<th className="text-left p-4 text-white font-medium">Цена за единицу (₽)</th>
|
||
<th className="text-left p-4 text-white font-medium">Описание</th>
|
||
<th className="text-left p-4 text-white font-medium">Действия</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{editableSupplies.map((supply, index) => (
|
||
<tr
|
||
key={supply.id || index}
|
||
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.imageUrl ? (
|
||
<div className="relative group w-12 h-12">
|
||
<Image
|
||
src={supply.imageUrl}
|
||
alt={supply.name}
|
||
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={supply.name}
|
||
width={240}
|
||
height={240}
|
||
className="w-60 h-60 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 className="absolute bottom-2 left-2 right-2 bg-black/60 backdrop-blur rounded px-2 py-1">
|
||
<p className="text-white text-xs font-medium truncate">{supply.name}</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
) : (
|
||
<div className="w-12 h-12 bg-white/10 rounded flex items-center justify-center">
|
||
<Package className="w-5 h-5 text-white/50" />
|
||
</div>
|
||
)}
|
||
</td>
|
||
|
||
{/* Название */}
|
||
<td className="p-4">
|
||
<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.pricePerUnit}
|
||
onChange={(e) => updateField(supply.id, 'pricePerUnit', e.target.value)}
|
||
className="bg-white/5 border-white/20 text-white"
|
||
placeholder="Не установлена"
|
||
/>
|
||
) : (
|
||
<span className="text-white/80">
|
||
{supply.pricePerUnit
|
||
? `${parseFloat(supply.pricePerUnit).toLocaleString()} ₽`
|
||
: 'Не установлена'}
|
||
</span>
|
||
)}
|
||
</td>
|
||
|
||
{/* Описание */}
|
||
<td className="p-4">
|
||
<span className="text-white/80">{supply.description || '—'}</span>
|
||
</td>
|
||
|
||
{/* Действия */}
|
||
<td className="p-4">
|
||
<div className="flex gap-2">
|
||
{supply.isEditing ? (
|
||
<>
|
||
<Button
|
||
size="sm"
|
||
onClick={() => {
|
||
saveAllChanges()
|
||
}}
|
||
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="Сохранить"
|
||
>
|
||
<Check className="w-4 h-4" />
|
||
</Button>
|
||
<Button
|
||
size="sm"
|
||
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="Отменить"
|
||
>
|
||
<X className="w-4 h-4" />
|
||
</Button>
|
||
</>
|
||
) : (
|
||
<Button
|
||
size="sm"
|
||
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="Редактировать цену"
|
||
>
|
||
<Edit className="w-4 h-4" />
|
||
</Button>
|
||
)}
|
||
</div>
|
||
</td>
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</Card>
|
||
</div>
|
||
)
|
||
}
|