Добавлены модели услуг и расходников для фулфилмент центров, реализованы соответствующие мутации и запросы в GraphQL. Обновлен конфигурационный файл и добавлен новый компонент Toaster в макет приложения. Обновлены зависимости в package.json и package-lock.json.
This commit is contained in:
495
src/components/services/supplies-tab.tsx
Normal file
495
src/components/services/supplies-tab.tsx
Normal file
@ -0,0 +1,495 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useQuery, useMutation } from '@apollo/client'
|
||||
import { Card } from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog'
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger
|
||||
} from '@/components/ui/alert-dialog'
|
||||
import { Plus, Edit, Trash2, Upload, Package } from 'lucide-react'
|
||||
import { toast } from "sonner"
|
||||
import { useAuth } from '@/hooks/useAuth'
|
||||
import { GET_MY_SUPPLIES } from '@/graphql/queries'
|
||||
import { CREATE_SUPPLY, UPDATE_SUPPLY, DELETE_SUPPLY } from '@/graphql/mutations'
|
||||
|
||||
interface Supply {
|
||||
id: string
|
||||
name: string
|
||||
description?: string
|
||||
price: number
|
||||
quantity: number
|
||||
imageUrl?: string
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
export function SuppliesTab() {
|
||||
const { user } = useAuth()
|
||||
const [isDialogOpen, setIsDialogOpen] = useState(false)
|
||||
const [editingSupply, setEditingSupply] = useState<Supply | null>(null)
|
||||
const [formData, setFormData] = useState({
|
||||
name: '',
|
||||
description: '',
|
||||
price: '',
|
||||
quantity: '',
|
||||
imageUrl: ''
|
||||
})
|
||||
const [imageFile, setImageFile] = useState<File | null>(null)
|
||||
const [isUploading, setIsUploading] = useState(false)
|
||||
|
||||
// GraphQL запросы и мутации
|
||||
const { data, loading, error, refetch } = useQuery(GET_MY_SUPPLIES, {
|
||||
skip: user?.organization?.type !== 'FULFILLMENT'
|
||||
})
|
||||
const [createSupply] = useMutation(CREATE_SUPPLY)
|
||||
const [updateSupply] = useMutation(UPDATE_SUPPLY)
|
||||
const [deleteSupply] = useMutation(DELETE_SUPPLY)
|
||||
|
||||
const supplies = data?.mySupplies || []
|
||||
|
||||
const resetForm = () => {
|
||||
setFormData({
|
||||
name: '',
|
||||
description: '',
|
||||
price: '',
|
||||
quantity: '',
|
||||
imageUrl: ''
|
||||
})
|
||||
setImageFile(null)
|
||||
setEditingSupply(null)
|
||||
}
|
||||
|
||||
const handleEdit = (supply: Supply) => {
|
||||
setEditingSupply(supply)
|
||||
setFormData({
|
||||
name: supply.name,
|
||||
description: supply.description || '',
|
||||
price: supply.price.toString(),
|
||||
quantity: supply.quantity.toString(),
|
||||
imageUrl: supply.imageUrl || ''
|
||||
})
|
||||
setIsDialogOpen(true)
|
||||
}
|
||||
|
||||
const handleDelete = async (supplyId: string) => {
|
||||
try {
|
||||
await deleteSupply({
|
||||
variables: { id: supplyId }
|
||||
})
|
||||
await refetch()
|
||||
toast.success('Расходник успешно удален')
|
||||
} catch (error) {
|
||||
console.error('Error deleting supply:', error)
|
||||
toast.error('Ошибка при удалении расходника')
|
||||
}
|
||||
}
|
||||
|
||||
const handleImageUpload = async (file: File) => {
|
||||
if (!user?.id) return
|
||||
|
||||
setIsUploading(true)
|
||||
try {
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
formData.append('userId', user.id)
|
||||
formData.append('type', 'supply')
|
||||
|
||||
const response = await fetch('/api/upload-service-image', {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to upload image')
|
||||
}
|
||||
|
||||
const result = await response.json()
|
||||
console.log('Upload result:', result)
|
||||
setFormData(prev => ({ ...prev, imageUrl: result.url }))
|
||||
} catch (error) {
|
||||
console.error('Error uploading image:', error)
|
||||
toast.error('Ошибка при загрузке изображения')
|
||||
} finally {
|
||||
setIsUploading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const uploadImageAndGetUrl = async (file: File): Promise<string> => {
|
||||
if (!user?.id) throw new Error('User not found')
|
||||
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
formData.append('userId', user.id)
|
||||
formData.append('type', 'supply')
|
||||
|
||||
const response = await fetch('/api/upload-service-image', {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to upload image')
|
||||
}
|
||||
|
||||
const result = await response.json()
|
||||
console.log('Upload result:', result)
|
||||
return result.url
|
||||
}
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
|
||||
if (!formData.name.trim() || !formData.price || !formData.quantity) {
|
||||
toast.error('Заполните обязательные поля')
|
||||
return
|
||||
}
|
||||
|
||||
const quantity = parseInt(formData.quantity)
|
||||
if (quantity < 0) {
|
||||
toast.error('Количество не может быть отрицательным')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
let imageUrl = formData.imageUrl
|
||||
|
||||
// Загружаем изображение если выбрано
|
||||
if (imageFile) {
|
||||
const uploadResult = await uploadImageAndGetUrl(imageFile)
|
||||
imageUrl = uploadResult
|
||||
}
|
||||
|
||||
const input = {
|
||||
name: formData.name,
|
||||
description: formData.description || undefined,
|
||||
price: parseFloat(formData.price),
|
||||
quantity: quantity,
|
||||
imageUrl: imageUrl || undefined
|
||||
}
|
||||
|
||||
if (editingSupply) {
|
||||
await updateSupply({
|
||||
variables: { id: editingSupply.id, input }
|
||||
})
|
||||
} else {
|
||||
await createSupply({
|
||||
variables: { input }
|
||||
})
|
||||
}
|
||||
|
||||
await refetch()
|
||||
setIsDialogOpen(false)
|
||||
resetForm()
|
||||
|
||||
toast.success(editingSupply ? 'Расходник успешно обновлен' : 'Расходник успешно создан')
|
||||
} catch (error) {
|
||||
console.error('Error saving supply:', error)
|
||||
toast.error('Ошибка при сохранении расходника')
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col">
|
||||
<Card className="flex-1 bg-white/5 backdrop-blur border-white/10 p-6 overflow-hidden">
|
||||
{/* Заголовок и кнопка добавления */}
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-white mb-1">Мои расходники</h2>
|
||||
<p className="text-white/70 text-sm">Управление вашими расходными материалами</p>
|
||||
</div>
|
||||
|
||||
<Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button
|
||||
className="bg-gradient-to-r from-purple-600 to-pink-600 hover:from-purple-700 hover:to-pink-700 text-white border-0 shadow-lg shadow-purple-500/25 transition-all duration-300"
|
||||
onClick={() => {
|
||||
resetForm()
|
||||
setIsDialogOpen(true)
|
||||
}}
|
||||
>
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
Добавить расходник
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
|
||||
<DialogContent className="max-w-md bg-gradient-to-br from-purple-900/95 via-purple-800/95 to-pink-900/95 backdrop-blur-xl border border-purple-500/30 text-white shadow-2xl shadow-purple-500/20">
|
||||
<DialogHeader className="pb-6">
|
||||
<DialogTitle className="text-2xl font-bold bg-gradient-to-r from-purple-300 to-pink-300 bg-clip-text text-transparent">
|
||||
{editingSupply ? 'Редактировать расходник' : 'Добавить расходник'}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="name" className="text-purple-200 text-sm font-medium">Название расходника *</Label>
|
||||
<Input
|
||||
id="name"
|
||||
value={formData.name}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, name: e.target.value }))}
|
||||
className="bg-white/5 border-purple-400/30 text-white placeholder:text-purple-300/50 focus:border-purple-300 focus:ring-2 focus:ring-purple-500/20 transition-all"
|
||||
placeholder="Введите название расходника"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="price" className="text-purple-200 text-sm font-medium">Цена за единицу (₽) *</Label>
|
||||
<Input
|
||||
id="price"
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
value={formData.price}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, price: e.target.value }))}
|
||||
className="bg-white/5 border-purple-400/30 text-white placeholder:text-purple-300/50 focus:border-purple-300 focus:ring-2 focus:ring-purple-500/20 transition-all"
|
||||
placeholder="0.00"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="quantity" className="text-purple-200 text-sm font-medium">Количество *</Label>
|
||||
<Input
|
||||
id="quantity"
|
||||
type="number"
|
||||
min="0"
|
||||
value={formData.quantity}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, quantity: e.target.value }))}
|
||||
className="bg-white/5 border-purple-400/30 text-white placeholder:text-purple-300/50 focus:border-purple-300 focus:ring-2 focus:ring-purple-500/20 transition-all"
|
||||
placeholder="0"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="description" className="text-purple-200 text-sm font-medium">Описание</Label>
|
||||
<Input
|
||||
id="description"
|
||||
value={formData.description}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, description: e.target.value }))}
|
||||
className="bg-white/5 border-purple-400/30 text-white placeholder:text-purple-300/50 focus:border-purple-300 focus:ring-2 focus:ring-purple-500/20 transition-all"
|
||||
placeholder="Описание расходника"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label className="text-purple-200 text-sm font-medium">Изображение</Label>
|
||||
<Input
|
||||
type="file"
|
||||
accept="image/*"
|
||||
onChange={(e) => {
|
||||
const file = e.target.files?.[0]
|
||||
if (file) {
|
||||
setImageFile(file)
|
||||
}
|
||||
}}
|
||||
className="bg-white/5 border-purple-400/30 text-white file:bg-gradient-to-r file:from-purple-500 file:to-pink-500 file:text-white file:border-0 file:rounded-lg file:px-4 file:py-2 file:mr-3 focus:border-purple-300 focus:ring-2 focus:ring-purple-500/20 transition-all"
|
||||
/>
|
||||
{formData.imageUrl && (
|
||||
<div className="mt-3">
|
||||
<img
|
||||
src={formData.imageUrl}
|
||||
alt="Preview"
|
||||
className="w-20 h-20 object-cover rounded-lg border border-purple-400/30 shadow-lg"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3 pt-8">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setIsDialogOpen(false)
|
||||
resetForm()
|
||||
}}
|
||||
className="flex-1 border-purple-400/30 text-purple-200 hover:bg-purple-500/10 hover:border-purple-300 transition-all duration-300"
|
||||
>
|
||||
Отмена
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={loading || isUploading}
|
||||
className="flex-1 bg-gradient-to-r from-purple-600 to-pink-600 hover:from-purple-500 hover:to-pink-500 text-white border-0 shadow-lg shadow-purple-500/25 hover:shadow-purple-500/40 transition-all duration-300"
|
||||
>
|
||||
{loading || isUploading ? 'Сохранение...' : 'Сохранить'}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
|
||||
{/* Таблица расходников */}
|
||||
<div className="overflow-auto flex-1">
|
||||
{loading ? (
|
||||
<div className="h-full flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<div className="w-16 h-16 bg-white/10 rounded-full flex items-center justify-center mx-auto mb-4 animate-spin">
|
||||
<svg className="w-8 h-8 text-white/50" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
||||
</svg>
|
||||
</div>
|
||||
<p className="text-white/70 text-sm">Загрузка расходников...</p>
|
||||
</div>
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="h-full flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<div className="w-16 h-16 bg-red-500/20 rounded-full flex items-center justify-center mx-auto mb-4">
|
||||
<svg className="w-8 h-8 text-red-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold text-white mb-2">Ошибка загрузки</h3>
|
||||
<p className="text-white/70 text-sm mb-4">
|
||||
Не удалось загрузить расходники
|
||||
</p>
|
||||
<Button
|
||||
onClick={() => refetch()}
|
||||
className="bg-gradient-to-r from-purple-500 to-purple-600 hover:from-purple-600 hover:to-purple-700 text-white"
|
||||
>
|
||||
Попробовать снова
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : supplies.length === 0 ? (
|
||||
<div className="h-full flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<div className="w-16 h-16 bg-white/10 rounded-full flex items-center justify-center mx-auto mb-4">
|
||||
<Package className="w-8 h-8 text-white/50" />
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold text-white mb-2">Пока нет расходников</h3>
|
||||
<p className="text-white/70 text-sm mb-4">
|
||||
Добавьте свой первый расходник для управления складскими запасами
|
||||
</p>
|
||||
<Button
|
||||
onClick={() => {
|
||||
resetForm()
|
||||
setIsDialogOpen(true)
|
||||
}}
|
||||
className="bg-gradient-to-r from-purple-500 to-purple-600 hover:from-purple-600 hover:to-purple-700 text-white"
|
||||
>
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
Добавить расходник
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="bg-white/5 rounded-lg border border-white/10 overflow-hidden">
|
||||
<table className="w-full">
|
||||
<thead className="bg-white/5">
|
||||
<tr>
|
||||
<th className="text-left p-4 text-white font-medium">№</th>
|
||||
<th className="text-left p-4 text-white font-medium">Фото</th>
|
||||
<th className="text-left p-4 text-white font-medium">Название</th>
|
||||
<th className="text-left p-4 text-white font-medium">Цена за единицу</th>
|
||||
<th className="text-left p-4 text-white font-medium">Количество</th>
|
||||
<th className="text-left p-4 text-white font-medium">Описание</th>
|
||||
<th className="text-left p-4 text-white font-medium">Действия</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{supplies.map((supply: Supply, index: number) => (
|
||||
<tr key={supply.id} className="border-t border-white/10 hover:bg-white/5">
|
||||
<td className="p-4 text-white/80">{index + 1}</td>
|
||||
<td className="p-4">
|
||||
{supply.imageUrl ? (
|
||||
<img
|
||||
src={supply.imageUrl}
|
||||
alt={supply.name}
|
||||
className="w-12 h-12 object-cover rounded border border-white/20"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-12 h-12 bg-white/10 rounded flex items-center justify-center">
|
||||
<Upload className="w-5 h-5 text-white/50" />
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
<td className="p-4 text-white font-medium">{supply.name}</td>
|
||||
<td className="p-4 text-white/80">{supply.price.toLocaleString()} ₽</td>
|
||||
<td className="p-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-white/80">{supply.quantity} шт.</span>
|
||||
{supply.quantity <= 10 && (
|
||||
<span className="text-xs bg-yellow-500/20 text-yellow-400 px-2 py-1 rounded-full">
|
||||
Мало
|
||||
</span>
|
||||
)}
|
||||
{supply.quantity === 0 && (
|
||||
<span className="text-xs bg-red-500/20 text-red-400 px-2 py-1 rounded-full">
|
||||
Нет в наличии
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
<td className="p-4 text-white/80">{supply.description || '—'}</td>
|
||||
<td className="p-4">
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => handleEdit(supply)}
|
||||
className="border-white/20 text-white hover:bg-white/10"
|
||||
>
|
||||
<Edit className="w-4 h-4" />
|
||||
</Button>
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="border-red-500/50 text-red-400 hover:bg-red-500/10"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent className="bg-gradient-to-br from-red-900/95 via-red-800/95 to-red-900/95 backdrop-blur-xl border border-red-500/30 text-white shadow-2xl shadow-red-500/20">
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle className="text-xl font-bold bg-gradient-to-r from-red-300 to-red-300 bg-clip-text text-transparent">
|
||||
Подтвердите удаление
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription className="text-red-200">
|
||||
Вы действительно хотите удалить расходник “{supply.name}”? Это действие необратимо.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter className="gap-3">
|
||||
<AlertDialogCancel className="border-red-400/30 text-red-200 hover:bg-red-500/10 hover:border-red-300 transition-all duration-300">
|
||||
Отмена
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={() => handleDelete(supply.id)}
|
||||
className="bg-gradient-to-r from-red-600 to-red-700 hover:from-red-700 hover:to-red-800 text-white border-0 shadow-lg shadow-red-500/25 hover:shadow-red-500/40 transition-all duration-300"
|
||||
>
|
||||
Удалить
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
Reference in New Issue
Block a user