Добавлены модели услуг и расходников для фулфилмент центров, реализованы соответствующие мутации и запросы в GraphQL. Обновлен конфигурационный файл и добавлен новый компонент Toaster в макет приложения. Обновлены зависимости в package.json и package-lock.json.

This commit is contained in:
Bivekich
2025-07-17 10:47:20 +03:00
parent 205c9eae98
commit 99e91287f3
22 changed files with 2148 additions and 2 deletions

View File

@ -11,7 +11,8 @@ import {
LogOut,
Building2,
Store,
MessageCircle
MessageCircle,
Wrench
} from 'lucide-react'
export function Sidebar() {
@ -63,9 +64,14 @@ export function Sidebar() {
router.push('/messenger')
}
const handleServicesClick = () => {
router.push('/services')
}
const isSettingsActive = pathname === '/settings'
const isMarketActive = pathname.startsWith('/market')
const isMessengerActive = pathname.startsWith('/messenger')
const isServicesActive = pathname.startsWith('/services')
return (
<div className="fixed left-0 top-0 h-full w-56 bg-white/10 backdrop-blur-xl border-r border-white/20 p-3">
@ -131,6 +137,22 @@ export function Sidebar() {
<MessageCircle className="h-3 w-3 mr-2" />
Мессенджер
</Button>
{/* Услуги - только для фулфилмент центров */}
{user?.organization?.type === 'FULFILLMENT' && (
<Button
variant={isServicesActive ? "secondary" : "ghost"}
className={`w-full justify-start text-left transition-all duration-200 h-8 text-xs ${
isServicesActive
? 'bg-white/20 text-white hover:bg-white/30'
: 'text-white/80 hover:bg-white/10 hover:text-white'
} cursor-pointer`}
onClick={handleServicesClick}
>
<Wrench className="h-3 w-3 mr-2" />
Услуги
</Button>
)}
<Button
variant={isSettingsActive ? "secondary" : "ghost"}

View File

@ -0,0 +1,36 @@
"use client"
import { Card } from '@/components/ui/card'
export function LogisticsTab() {
return (
<div className="h-full">
<Card className="h-full bg-white/5 backdrop-blur border-white/10 p-6">
<div className="h-full flex items-center justify-center">
<div className="text-center">
<div className="w-16 h-16 bg-white/10 rounded-full flex items-center justify-center mx-auto mb-4">
<svg
className="w-8 h-8 text-white/50"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M8 7h12m0 0l-4-4m4 4l-4 4m0 6H4m0 0l4 4m-4-4l4-4"
/>
</svg>
</div>
<h3 className="text-lg font-semibold text-white mb-2">Логистика</h3>
<p className="text-white/70 text-sm max-w-md">
Раздел логистики находится в разработке.
Здесь будут инструменты для управления доставкой и складскими операциями.
</p>
</div>
</div>
</Card>
</div>
)
}

View File

@ -0,0 +1,67 @@
"use client"
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { Sidebar } from '@/components/dashboard/sidebar'
import { ServicesTab } from './services-tab'
import { SuppliesTab } from './supplies-tab'
import { LogisticsTab } from './logistics-tab'
export function ServicesDashboard() {
return (
<div className="h-screen bg-gradient-smooth flex overflow-hidden">
<Sidebar />
<main className="flex-1 ml-56 px-6 py-4 overflow-hidden">
<div className="h-full w-full flex flex-col">
{/* Заголовок - фиксированная высота */}
<div className="flex items-center justify-between mb-4 flex-shrink-0">
<div>
<h1 className="text-xl font-bold text-white mb-1">Услуги</h1>
<p className="text-white/70 text-sm">Управление услугами, расходниками и логистикой</p>
</div>
</div>
{/* Основной контент с табами */}
<div className="flex-1 overflow-hidden">
<Tabs defaultValue="services" className="h-full flex flex-col">
<TabsList className="grid w-full grid-cols-3 bg-white/5 backdrop-blur border-white/10 flex-shrink-0">
<TabsTrigger
value="services"
className="data-[state=active]:bg-white/20 data-[state=active]:text-white text-white/70"
>
Услуги
</TabsTrigger>
<TabsTrigger
value="logistics"
className="data-[state=active]:bg-white/20 data-[state=active]:text-white text-white/70"
>
Логистика
</TabsTrigger>
<TabsTrigger
value="supplies"
className="data-[state=active]:bg-white/20 data-[state=active]:text-white text-white/70"
>
Расходники
</TabsTrigger>
</TabsList>
{/* Контент вкладок */}
<div className="flex-1 overflow-hidden mt-4">
<TabsContent value="services" className="h-full m-0 overflow-hidden">
<ServicesTab />
</TabsContent>
<TabsContent value="logistics" className="h-full m-0 overflow-hidden">
<LogisticsTab />
</TabsContent>
<TabsContent value="supplies" className="h-full m-0 overflow-hidden">
<SuppliesTab />
</TabsContent>
</div>
</Tabs>
</div>
</div>
</main>
</div>
)
}

View File

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

View 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">
Вы действительно хотите удалить расходник &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={() => 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>
)
}

View File

@ -0,0 +1,157 @@
"use client"
import * as React from "react"
import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog"
import { cn } from "@/lib/utils"
import { buttonVariants } from "@/components/ui/button"
function AlertDialog({
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Root>) {
return <AlertDialogPrimitive.Root data-slot="alert-dialog" {...props} />
}
function AlertDialogTrigger({
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Trigger>) {
return (
<AlertDialogPrimitive.Trigger data-slot="alert-dialog-trigger" {...props} />
)
}
function AlertDialogPortal({
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Portal>) {
return (
<AlertDialogPrimitive.Portal data-slot="alert-dialog-portal" {...props} />
)
}
function AlertDialogOverlay({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Overlay>) {
return (
<AlertDialogPrimitive.Overlay
data-slot="alert-dialog-overlay"
className={cn(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
className
)}
{...props}
/>
)
}
function AlertDialogContent({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Content>) {
return (
<AlertDialogPortal>
<AlertDialogOverlay />
<AlertDialogPrimitive.Content
data-slot="alert-dialog-content"
className={cn(
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg",
className
)}
{...props}
/>
</AlertDialogPortal>
)
}
function AlertDialogHeader({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-dialog-header"
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
{...props}
/>
)
}
function AlertDialogFooter({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-dialog-footer"
className={cn(
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
className
)}
{...props}
/>
)
}
function AlertDialogTitle({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Title>) {
return (
<AlertDialogPrimitive.Title
data-slot="alert-dialog-title"
className={cn("text-lg font-semibold", className)}
{...props}
/>
)
}
function AlertDialogDescription({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Description>) {
return (
<AlertDialogPrimitive.Description
data-slot="alert-dialog-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
)
}
function AlertDialogAction({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Action>) {
return (
<AlertDialogPrimitive.Action
className={cn(buttonVariants(), className)}
{...props}
/>
)
}
function AlertDialogCancel({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Cancel>) {
return (
<AlertDialogPrimitive.Cancel
className={cn(buttonVariants({ variant: "outline" }), className)}
{...props}
/>
)
}
export {
AlertDialog,
AlertDialogPortal,
AlertDialogOverlay,
AlertDialogTrigger,
AlertDialogContent,
AlertDialogHeader,
AlertDialogFooter,
AlertDialogTitle,
AlertDialogDescription,
AlertDialogAction,
AlertDialogCancel,
}