Добавлены модели и функциональность для управления логистикой, включая создание, обновление и удаление логистических маршрутов через GraphQL. Обновлены компоненты для отображения и управления логистикой, улучшен интерфейс взаимодействия с пользователем. Реализованы новые типы данных и интерфейсы для логистики, а также улучшена обработка ошибок.
This commit is contained in:
@ -1,34 +1,688 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useEffect, useMemo } 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 { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger
|
||||
} from '@/components/ui/alert-dialog'
|
||||
import { Plus, Trash2, Save, X, Edit, Check, Truck, Building2, Store, Package, ShoppingCart } from 'lucide-react'
|
||||
import { toast } from "sonner"
|
||||
import { useAuth } from '@/hooks/useAuth'
|
||||
import { GET_MY_LOGISTICS } from '@/graphql/queries'
|
||||
import { CREATE_LOGISTICS, UPDATE_LOGISTICS, DELETE_LOGISTICS } from '@/graphql/mutations'
|
||||
import { WildberriesService } from '@/services/wildberries-service'
|
||||
|
||||
interface LogisticsRoute {
|
||||
id: string
|
||||
fromLocation: string
|
||||
toLocation: string
|
||||
priceUnder1m3: number
|
||||
priceOver1m3: number
|
||||
description?: string
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
interface EditableLogistics {
|
||||
id?: string
|
||||
fromLocation: string
|
||||
toLocation: string
|
||||
priceUnder1m3: string
|
||||
priceOver1m3: string
|
||||
description: string
|
||||
isNew: boolean
|
||||
isEditing: boolean
|
||||
hasChanges: boolean
|
||||
}
|
||||
|
||||
interface LocationOption {
|
||||
value: string
|
||||
label: string
|
||||
}
|
||||
|
||||
// Базовые локации (без своего фулфилмента)
|
||||
const BASE_LOCATIONS = {
|
||||
markets: [
|
||||
{ value: 'sadovod', label: 'Садовод' },
|
||||
{ value: 'tyak-moscow', label: 'ТЯК Москва' }
|
||||
],
|
||||
wbWarehouses: [
|
||||
// Статичные склады WB как fallback
|
||||
{ value: 'wb-warehouse-1', label: 'Подольск' },
|
||||
{ value: 'wb-warehouse-2', label: 'Электросталь' },
|
||||
{ value: 'wb-warehouse-3', label: 'Коледино' }
|
||||
],
|
||||
ozonWarehouses: [
|
||||
// Статичные склады Ozon
|
||||
{ value: 'ozon-warehouse-1', label: 'Тверь' },
|
||||
{ value: 'ozon-warehouse-2', label: 'Казань' }
|
||||
]
|
||||
}
|
||||
|
||||
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>
|
||||
const { user } = useAuth()
|
||||
const [editableLogistics, setEditableLogistics] = useState<EditableLogistics[]>([])
|
||||
const [isSaving, setIsSaving] = useState(false)
|
||||
const [isInitialized, setIsInitialized] = useState(false)
|
||||
const [warehouses, setWarehouses] = useState<LocationOption[]>([])
|
||||
|
||||
// GraphQL запросы и мутации
|
||||
const { data, loading, error, refetch } = useQuery(GET_MY_LOGISTICS, {
|
||||
skip: user?.organization?.type !== 'FULFILLMENT'
|
||||
})
|
||||
const [createLogistics] = useMutation(CREATE_LOGISTICS)
|
||||
const [updateLogistics] = useMutation(UPDATE_LOGISTICS)
|
||||
const [deleteLogistics] = useMutation(DELETE_LOGISTICS)
|
||||
|
||||
const logistics = data?.myLogistics || []
|
||||
|
||||
// Загружаем склады из API WB
|
||||
useEffect(() => {
|
||||
const loadWarehouses = async () => {
|
||||
try {
|
||||
// Получаем API ключ из организации пользователя
|
||||
const wbApiKey = user?.organization?.apiKeys?.find(key => key.marketplace === 'WILDBERRIES')
|
||||
if (wbApiKey?.isActive && wbApiKey.validationData) {
|
||||
const validationData = wbApiKey.validationData as Record<string, string>
|
||||
const apiToken = validationData?.token || validationData?.apiKey
|
||||
if (apiToken) {
|
||||
const wbWarehouses = await WildberriesService.getWarehouses(apiToken)
|
||||
const warehouseOptions = wbWarehouses.map(w => ({
|
||||
value: `wb-${w.id}`,
|
||||
label: `Склад WB ${w.name}`
|
||||
}))
|
||||
setWarehouses(warehouseOptions)
|
||||
console.log('Loaded WB warehouses:', warehouseOptions)
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load warehouses:', error)
|
||||
// Используем только статичные склады
|
||||
}
|
||||
}
|
||||
|
||||
if (user?.organization) {
|
||||
loadWarehouses()
|
||||
}
|
||||
}, [user])
|
||||
|
||||
// Преобразуем загруженные маршруты в редактируемый формат
|
||||
useEffect(() => {
|
||||
if (data?.myLogistics && !isInitialized) {
|
||||
const convertedLogistics: EditableLogistics[] = data.myLogistics.map((route: LogisticsRoute) => ({
|
||||
id: route.id,
|
||||
fromLocation: route.fromLocation,
|
||||
toLocation: route.toLocation,
|
||||
priceUnder1m3: route.priceUnder1m3.toString(),
|
||||
priceOver1m3: route.priceOver1m3.toString(),
|
||||
description: route.description || '',
|
||||
isNew: false,
|
||||
isEditing: false,
|
||||
hasChanges: false
|
||||
}))
|
||||
|
||||
setEditableLogistics(convertedLogistics)
|
||||
setIsInitialized(true)
|
||||
}
|
||||
}, [data, isInitialized])
|
||||
|
||||
// Получить все опции локаций с группировкой
|
||||
const getAllLocationOptions = () => {
|
||||
const myFulfillment = user?.organization?.name ? [
|
||||
{ value: 'my-fulfillment', label: user.organization.name }
|
||||
] : []
|
||||
|
||||
return [
|
||||
...myFulfillment,
|
||||
...BASE_LOCATIONS.markets,
|
||||
...BASE_LOCATIONS.wbWarehouses,
|
||||
...BASE_LOCATIONS.ozonWarehouses,
|
||||
...warehouses
|
||||
]
|
||||
}
|
||||
|
||||
// Создать группированные опции для селекта
|
||||
const getGroupedLocationOptions = () => {
|
||||
const myFulfillment = user?.organization?.name ? [
|
||||
{ value: 'my-fulfillment', label: user.organization.name }
|
||||
] : []
|
||||
|
||||
return {
|
||||
myFulfillment,
|
||||
markets: BASE_LOCATIONS.markets,
|
||||
wbWarehouses: [...BASE_LOCATIONS.wbWarehouses, ...warehouses],
|
||||
ozonWarehouses: BASE_LOCATIONS.ozonWarehouses
|
||||
}
|
||||
}
|
||||
|
||||
// Получить название локации по ID
|
||||
const getLocationLabel = (locationId: string) => {
|
||||
const allOptions = getAllLocationOptions()
|
||||
return allOptions.find(opt => opt.value === locationId)?.label || locationId
|
||||
}
|
||||
|
||||
// Добавить новую строку
|
||||
const addNewRow = () => {
|
||||
const tempId = `temp-${Date.now()}-${Math.random()}`
|
||||
const newRow: EditableLogistics = {
|
||||
id: tempId,
|
||||
fromLocation: '',
|
||||
toLocation: '',
|
||||
priceUnder1m3: '',
|
||||
priceOver1m3: '',
|
||||
description: '',
|
||||
isNew: true,
|
||||
isEditing: true,
|
||||
hasChanges: false
|
||||
}
|
||||
setEditableLogistics(prev => [...prev, newRow])
|
||||
}
|
||||
|
||||
// Удалить строку
|
||||
const removeRow = async (routeId: string, isNew: boolean) => {
|
||||
if (isNew) {
|
||||
setEditableLogistics(prev => prev.filter(r => r.id !== routeId))
|
||||
} else {
|
||||
try {
|
||||
await deleteLogistics({
|
||||
variables: { id: routeId },
|
||||
update: (cache, { data }) => {
|
||||
if (data?.deleteLogistics) {
|
||||
const existingData = cache.readQuery({ query: GET_MY_LOGISTICS }) as { myLogistics: LogisticsRoute[] } | null
|
||||
if (existingData && existingData.myLogistics) {
|
||||
cache.writeQuery({
|
||||
query: GET_MY_LOGISTICS,
|
||||
data: {
|
||||
myLogistics: existingData.myLogistics.filter((route: LogisticsRoute) => route.id !== routeId)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
setEditableLogistics(prev => prev.filter(r => r.id !== routeId))
|
||||
toast.success('Маршрут успешно удален')
|
||||
} catch (error) {
|
||||
console.error('Error deleting logistics:', error)
|
||||
toast.error('Ошибка при удалении маршрута')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Начать редактирование
|
||||
const startEditing = (routeId: string) => {
|
||||
setEditableLogistics(prev =>
|
||||
prev.map(route =>
|
||||
route.id === routeId ? { ...route, isEditing: true } : route
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
// Отменить редактирование
|
||||
const cancelEditing = (routeId: string) => {
|
||||
const route = editableLogistics.find(r => r.id === routeId)
|
||||
if (!route) return
|
||||
|
||||
if (route.isNew) {
|
||||
setEditableLogistics(prev => prev.filter(r => r.id !== routeId))
|
||||
} else {
|
||||
const originalRoute = logistics.find((r: LogisticsRoute) => r.id === route.id)
|
||||
if (originalRoute) {
|
||||
setEditableLogistics(prev =>
|
||||
prev.map(r =>
|
||||
r.id === routeId
|
||||
? {
|
||||
id: originalRoute.id,
|
||||
fromLocation: originalRoute.fromLocation,
|
||||
toLocation: originalRoute.toLocation,
|
||||
priceUnder1m3: originalRoute.priceUnder1m3.toString(),
|
||||
priceOver1m3: originalRoute.priceOver1m3.toString(),
|
||||
description: originalRoute.description || '',
|
||||
isNew: false,
|
||||
isEditing: false,
|
||||
hasChanges: false
|
||||
}
|
||||
: r
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Обновить поле
|
||||
const updateField = (routeId: string, field: keyof EditableLogistics, value: string) => {
|
||||
setEditableLogistics(prev =>
|
||||
prev.map((route) => {
|
||||
if (route.id !== routeId) return route
|
||||
|
||||
const updated = { ...route, hasChanges: true }
|
||||
if (field === 'fromLocation') updated.fromLocation = value
|
||||
else if (field === 'toLocation') updated.toLocation = value
|
||||
else if (field === 'priceUnder1m3') updated.priceUnder1m3 = value
|
||||
else if (field === 'priceOver1m3') updated.priceOver1m3 = value
|
||||
else if (field === 'description') updated.description = value
|
||||
|
||||
return updated
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
// Сохранить все изменения
|
||||
const saveAllChanges = async () => {
|
||||
setIsSaving(true)
|
||||
console.log('Saving all changes...')
|
||||
|
||||
try {
|
||||
const routesToSave = editableLogistics.filter(r => {
|
||||
if (r.isNew) {
|
||||
return r.fromLocation && r.toLocation && r.priceUnder1m3 && r.priceOver1m3
|
||||
}
|
||||
return r.hasChanges
|
||||
})
|
||||
|
||||
console.log('Routes to save:', routesToSave)
|
||||
|
||||
for (const route of routesToSave) {
|
||||
if (!route.fromLocation || !route.toLocation || !route.priceUnder1m3 || !route.priceOver1m3) {
|
||||
toast.error('Заполните все обязательные поля')
|
||||
setIsSaving(false)
|
||||
return
|
||||
}
|
||||
|
||||
const input = {
|
||||
fromLocation: route.fromLocation,
|
||||
toLocation: route.toLocation,
|
||||
priceUnder1m3: parseFloat(route.priceUnder1m3),
|
||||
priceOver1m3: parseFloat(route.priceOver1m3),
|
||||
description: route.description || undefined
|
||||
}
|
||||
|
||||
console.log('Saving route with input:', input)
|
||||
|
||||
if (route.isNew) {
|
||||
const result = await createLogistics({
|
||||
variables: { input },
|
||||
update: (cache, { data }) => {
|
||||
if (data?.createLogistics?.success && data.createLogistics.logistics) {
|
||||
const existingData = cache.readQuery({ query: GET_MY_LOGISTICS }) as { myLogistics: LogisticsRoute[] } | null
|
||||
if (existingData && existingData.myLogistics) {
|
||||
cache.writeQuery({
|
||||
query: GET_MY_LOGISTICS,
|
||||
data: {
|
||||
myLogistics: [...existingData.myLogistics, data.createLogistics.logistics]
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
console.log('Create result:', result)
|
||||
} else if (route.id) {
|
||||
const result = await updateLogistics({
|
||||
variables: { id: route.id, input },
|
||||
update: (cache, { data }) => {
|
||||
if (data?.updateLogistics?.success && data.updateLogistics.logistics) {
|
||||
const existingData = cache.readQuery({ query: GET_MY_LOGISTICS }) as { myLogistics: LogisticsRoute[] } | null
|
||||
if (existingData && existingData.myLogistics) {
|
||||
cache.writeQuery({
|
||||
query: GET_MY_LOGISTICS,
|
||||
data: {
|
||||
myLogistics: existingData.myLogistics.map((route: LogisticsRoute) =>
|
||||
route.id === data.updateLogistics.logistics.id ? data.updateLogistics.logistics : route
|
||||
)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
console.log('Update result:', result)
|
||||
}
|
||||
}
|
||||
|
||||
toast.success('Все изменения успешно сохранены')
|
||||
// Обновляем локальное состояние - убираем флаги isNew и hasChanges
|
||||
setEditableLogistics(prev => prev.map(route => ({
|
||||
...route,
|
||||
isNew: false,
|
||||
hasChanges: false,
|
||||
isEditing: false
|
||||
})))
|
||||
} catch (error) {
|
||||
console.error('Error saving changes:', error)
|
||||
toast.error(`Ошибка при сохранении: ${error}`)
|
||||
} finally {
|
||||
setIsSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
// Проверяем есть ли несохраненные изменения
|
||||
const hasUnsavedChanges = useMemo(() => {
|
||||
return editableLogistics.some(r => {
|
||||
if (r.isNew) {
|
||||
return r.fromLocation || r.toLocation || r.priceUnder1m3 || r.priceOver1m3
|
||||
}
|
||||
return r.hasChanges
|
||||
})
|
||||
}, [editableLogistics])
|
||||
|
||||
// Компонент группированного селекта
|
||||
const GroupedLocationSelect = ({ value, onValueChange, placeholder }: {
|
||||
value: string
|
||||
onValueChange: (value: string) => void
|
||||
placeholder: string
|
||||
}) => {
|
||||
const groups = getGroupedLocationOptions()
|
||||
|
||||
return (
|
||||
<Select value={value} onValueChange={onValueChange}>
|
||||
<SelectTrigger className="bg-white/5 border-white/20 text-white">
|
||||
<SelectValue placeholder={placeholder} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{/* Мой фулфилмент */}
|
||||
{groups.myFulfillment.length > 0 && (
|
||||
<>
|
||||
<div className="px-2 py-1.5 text-xs font-semibold text-muted-foreground bg-muted/50 flex items-center gap-2">
|
||||
<Building2 className="w-3 h-3" />
|
||||
Мой фулфилмент
|
||||
</div>
|
||||
{groups.myFulfillment.map(option => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Рынки */}
|
||||
<div className="px-2 py-1.5 text-xs font-semibold text-muted-foreground bg-muted/50 flex items-center gap-2">
|
||||
<Store className="w-3 h-3" />
|
||||
Рынки
|
||||
</div>
|
||||
{groups.markets.map(option => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
|
||||
{/* Склады Wildberries */}
|
||||
<div className="px-2 py-1.5 text-xs font-semibold text-muted-foreground bg-muted/50 flex items-center gap-2">
|
||||
<Package className="w-3 h-3" />
|
||||
Склады Wildberries
|
||||
</div>
|
||||
{groups.wbWarehouses.map((option: LocationOption) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
|
||||
{/* Склады Ozon */}
|
||||
<div className="px-2 py-1.5 text-xs font-semibold text-muted-foreground bg-muted/50 flex items-center gap-2">
|
||||
<ShoppingCart className="w-3 h-3" />
|
||||
Склады Ozon
|
||||
</div>
|
||||
{groups.ozonWarehouses.map((option: LocationOption) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)
|
||||
}
|
||||
|
||||
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">
|
||||
<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}
|
||||
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">
|
||||
<Truck className="w-8 h-8 text-white/50" />
|
||||
</div>
|
||||
<p className="text-white/70 text-sm">Загрузка маршрутов...</p>
|
||||
</div>
|
||||
</div>
|
||||
) : editableLogistics.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={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>
|
||||
</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">До 1м³ (₽) *</th>
|
||||
<th className="text-left p-4 text-white font-medium">Свыше 1м³ (₽) *</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>
|
||||
{editableLogistics.map((route, index) => (
|
||||
<tr key={route.id || index} className={`border-t border-white/10 hover:bg-white/5 ${route.isNew || route.hasChanges ? 'bg-blue-500/10' : ''}`}>
|
||||
<td className="p-4 text-white/80">{index + 1}</td>
|
||||
|
||||
{/* Откуда */}
|
||||
<td className="p-4">
|
||||
{route.isEditing ? (
|
||||
<GroupedLocationSelect
|
||||
value={route.fromLocation}
|
||||
onValueChange={(value) => updateField(route.id!, 'fromLocation', value)}
|
||||
placeholder="Выберите точку отправления"
|
||||
/>
|
||||
) : (
|
||||
<span className="text-white/80">{getLocationLabel(route.fromLocation)}</span>
|
||||
)}
|
||||
</td>
|
||||
|
||||
{/* Куда */}
|
||||
<td className="p-4">
|
||||
{route.isEditing ? (
|
||||
<GroupedLocationSelect
|
||||
value={route.toLocation}
|
||||
onValueChange={(value) => updateField(route.id!, 'toLocation', value)}
|
||||
placeholder="Выберите точку назначения"
|
||||
/>
|
||||
) : (
|
||||
<span className="text-white/80">{getLocationLabel(route.toLocation)}</span>
|
||||
)}
|
||||
</td>
|
||||
|
||||
{/* Цена до 1м³ */}
|
||||
<td className="p-4">
|
||||
{route.isEditing ? (
|
||||
<Input
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
value={route.priceUnder1m3}
|
||||
onChange={(e) => updateField(route.id!, 'priceUnder1m3', e.target.value)}
|
||||
className="bg-white/5 border-white/20 text-white"
|
||||
placeholder="0.00"
|
||||
/>
|
||||
) : (
|
||||
<span className="text-white/80">
|
||||
{route.priceUnder1m3 ? parseFloat(route.priceUnder1m3).toLocaleString() : '0'} ₽
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
|
||||
{/* Цена свыше 1м³ */}
|
||||
<td className="p-4">
|
||||
{route.isEditing ? (
|
||||
<Input
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
value={route.priceOver1m3}
|
||||
onChange={(e) => updateField(route.id!, 'priceOver1m3', e.target.value)}
|
||||
className="bg-white/5 border-white/20 text-white"
|
||||
placeholder="0.00"
|
||||
/>
|
||||
) : (
|
||||
<span className="text-white/80">
|
||||
{route.priceOver1m3 ? parseFloat(route.priceOver1m3).toLocaleString() : '0'} ₽
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
|
||||
{/* Описание */}
|
||||
<td className="p-4">
|
||||
{route.isEditing ? (
|
||||
<Input
|
||||
value={route.description}
|
||||
onChange={(e) => updateField(route.id!, 'description', e.target.value)}
|
||||
className="bg-white/5 border-white/20 text-white"
|
||||
placeholder="Дополнительная информация"
|
||||
/>
|
||||
) : (
|
||||
<span className="text-white/80">{route.description || '—'}</span>
|
||||
)}
|
||||
</td>
|
||||
|
||||
{/* Действия */}
|
||||
<td className="p-4">
|
||||
<div className="flex gap-2">
|
||||
{route.isEditing ? (
|
||||
<>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
if (route.fromLocation && route.toLocation && route.priceUnder1m3 && route.priceOver1m3) {
|
||||
saveAllChanges()
|
||||
} else {
|
||||
toast.error('Заполните обязательные поля')
|
||||
}
|
||||
}}
|
||||
disabled={!route.fromLocation || !route.toLocation || !route.priceUnder1m3 || !route.priceOver1m3 || 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(route.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(route.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>
|
||||
)}
|
||||
|
||||
<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">
|
||||
Вы действительно хотите удалить маршрут? Это действие необратимо.
|
||||
</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(route.id!, route.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>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
@ -1,13 +1,11 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useState, useEffect, useMemo } from 'react'
|
||||
import { useQuery, useMutation } from '@apollo/client'
|
||||
import { Card } from '@/components/ui/card'
|
||||
import Image from 'next/image'
|
||||
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,
|
||||
@ -19,7 +17,7 @@ import {
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger
|
||||
} from '@/components/ui/alert-dialog'
|
||||
import { Plus, Edit, Trash2, Upload } from 'lucide-react'
|
||||
import { Plus, Trash2, Save, X, Edit, Upload, Check } from 'lucide-react'
|
||||
import { toast } from "sonner"
|
||||
import { useAuth } from '@/hooks/useAuth'
|
||||
import { GET_MY_SERVICES } from '@/graphql/queries'
|
||||
@ -35,18 +33,28 @@ interface Service {
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
interface EditableService {
|
||||
id?: string // undefined для новых записей
|
||||
name: string
|
||||
description: string
|
||||
price: string
|
||||
imageUrl: string
|
||||
imageFile?: File
|
||||
isNew: boolean
|
||||
isEditing: boolean
|
||||
hasChanges: boolean
|
||||
}
|
||||
|
||||
interface PendingChange extends EditableService {
|
||||
isDeleted?: boolean
|
||||
}
|
||||
|
||||
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] = useState(false)
|
||||
const [editableServices, setEditableServices] = useState<EditableService[]>([])
|
||||
const [pendingChanges, setPendingChanges] = useState<PendingChange[]>([])
|
||||
const [isSaving, setIsSaving] = useState(false)
|
||||
const [isInitialized, setIsInitialized] = useState(false)
|
||||
|
||||
// GraphQL запросы и мутации
|
||||
const { data, loading, error, refetch } = useQuery(GET_MY_SERVICES, {
|
||||
@ -57,45 +65,140 @@ export function ServicesTab() {
|
||||
const [deleteService] = useMutation(DELETE_SERVICE)
|
||||
|
||||
const services = data?.myServices || []
|
||||
|
||||
// Логирование для отладки
|
||||
console.log('Services data:', services)
|
||||
|
||||
const resetForm = () => {
|
||||
setFormData({
|
||||
// Преобразуем загруженные услуги в редактируемый формат
|
||||
useEffect(() => {
|
||||
if (data?.myServices && !isInitialized) {
|
||||
const convertedServices: EditableService[] = data.myServices.map((service: Service) => ({
|
||||
id: service.id,
|
||||
name: service.name,
|
||||
description: service.description || '',
|
||||
price: service.price.toString(),
|
||||
imageUrl: service.imageUrl || '',
|
||||
isNew: false,
|
||||
isEditing: false,
|
||||
hasChanges: false
|
||||
}))
|
||||
|
||||
setEditableServices(convertedServices)
|
||||
setPendingChanges([])
|
||||
setIsInitialized(true)
|
||||
}
|
||||
}, [data, isInitialized])
|
||||
|
||||
// Добавить новую строку
|
||||
const addNewRow = () => {
|
||||
const tempId = `temp-${Date.now()}-${Math.random()}`
|
||||
const newRow: EditableService = {
|
||||
id: tempId,
|
||||
name: '',
|
||||
description: '',
|
||||
price: '',
|
||||
imageUrl: ''
|
||||
})
|
||||
setImageFile(null)
|
||||
setEditingService(null)
|
||||
imageUrl: '',
|
||||
isNew: true,
|
||||
isEditing: true,
|
||||
hasChanges: false
|
||||
}
|
||||
setEditableServices(prev => [...prev, newRow])
|
||||
}
|
||||
|
||||
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 removeRow = async (serviceId: string, isNew: boolean) => {
|
||||
if (isNew) {
|
||||
// Просто удаляем из массива если это новая строка
|
||||
setEditableServices(prev => prev.filter(s => s.id !== serviceId))
|
||||
} else {
|
||||
// Удаляем существующую запись сразу
|
||||
try {
|
||||
await deleteService({
|
||||
variables: { id: serviceId },
|
||||
update: (cache, { data }) => {
|
||||
// Обновляем кэш Apollo Client
|
||||
const existingData = cache.readQuery({ query: GET_MY_SERVICES }) as { myServices: Service[] } | null
|
||||
if (existingData && existingData.myServices) {
|
||||
cache.writeQuery({
|
||||
query: GET_MY_SERVICES,
|
||||
data: {
|
||||
myServices: existingData.myServices.filter((s: Service) => s.id !== serviceId)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// Удаляем из локального состояния по ID, а не по индексу
|
||||
setEditableServices(prev => prev.filter(s => s.id !== serviceId))
|
||||
toast.success('Услуга успешно удалена')
|
||||
} catch (error) {
|
||||
console.error('Error deleting service:', error)
|
||||
toast.error('Ошибка при удалении услуги')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Начать редактирование существующей строки
|
||||
const startEditing = (serviceId: string) => {
|
||||
setEditableServices(prev =>
|
||||
prev.map(service =>
|
||||
service.id === serviceId ? { ...service, isEditing: true } : service
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
// Отменить редактирование
|
||||
const cancelEditing = (serviceId: string) => {
|
||||
const service = editableServices.find(s => s.id === serviceId)
|
||||
if (!service) return
|
||||
|
||||
if (service.isNew) {
|
||||
// Удаляем новую строку
|
||||
setEditableServices(prev => prev.filter(s => s.id !== serviceId))
|
||||
} else {
|
||||
// Возвращаем к исходному состоянию
|
||||
const originalService = services.find((s: Service) => s.id === service.id)
|
||||
if (originalService) {
|
||||
setEditableServices(prev =>
|
||||
prev.map(s =>
|
||||
s.id === serviceId
|
||||
? {
|
||||
id: originalService.id,
|
||||
name: originalService.name,
|
||||
description: originalService.description || '',
|
||||
price: originalService.price.toString(),
|
||||
imageUrl: originalService.imageUrl || '',
|
||||
isNew: false,
|
||||
isEditing: false,
|
||||
hasChanges: false
|
||||
}
|
||||
: s
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Обновить поле
|
||||
const updateField = (serviceId: string, field: keyof EditableService, value: string | File) => {
|
||||
setEditableServices(prev =>
|
||||
prev.map((service) => {
|
||||
if (service.id !== serviceId) return service
|
||||
|
||||
const updated = { ...service, 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 updated
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
// Загрузка изображения
|
||||
const uploadImageAndGetUrl = async (file: File): Promise<string> => {
|
||||
if (!user?.id) throw new Error('User not found')
|
||||
|
||||
@ -114,175 +217,137 @@ export function ServicesTab() {
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
// Сохранить все изменения
|
||||
const saveAllChanges = async () => {
|
||||
setIsSaving(true)
|
||||
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
|
||||
}
|
||||
const servicesToSave = editableServices.filter(s => {
|
||||
if (s.isNew) {
|
||||
// Для новых записей проверяем что обязательные поля заполнены
|
||||
return s.name.trim() && s.price
|
||||
}
|
||||
// Для существующих записей проверяем флаг изменений
|
||||
return s.hasChanges
|
||||
})
|
||||
|
||||
console.log('Submitting service with data:', input)
|
||||
console.log('Services to save:', servicesToSave.length, servicesToSave)
|
||||
|
||||
for (const service of servicesToSave) {
|
||||
if (!service.name.trim() || !service.price) {
|
||||
toast.error(`Заполните обязательные поля для всех услуг`)
|
||||
setIsSaving(false)
|
||||
return
|
||||
}
|
||||
|
||||
if (editingService) {
|
||||
await updateService({
|
||||
variables: { id: editingService.id, input }
|
||||
})
|
||||
} else {
|
||||
await createService({
|
||||
variables: { input }
|
||||
})
|
||||
let imageUrl = service.imageUrl
|
||||
|
||||
// Загружаем изображение если выбрано
|
||||
if (service.imageFile) {
|
||||
imageUrl = await uploadImageAndGetUrl(service.imageFile)
|
||||
}
|
||||
|
||||
const input = {
|
||||
name: service.name,
|
||||
description: service.description || undefined,
|
||||
price: parseFloat(service.price),
|
||||
imageUrl: imageUrl || undefined
|
||||
}
|
||||
|
||||
if (service.isNew) {
|
||||
await createService({
|
||||
variables: { input },
|
||||
update: (cache, { data }) => {
|
||||
if (data?.createService?.service) {
|
||||
const existingData = cache.readQuery({ query: GET_MY_SERVICES }) as { myServices: Service[] } | null
|
||||
if (existingData) {
|
||||
cache.writeQuery({
|
||||
query: GET_MY_SERVICES,
|
||||
data: {
|
||||
myServices: [...existingData.myServices, data.createService.service]
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
} else if (service.id) {
|
||||
await updateService({
|
||||
variables: { id: service.id, input },
|
||||
update: (cache, { data }) => {
|
||||
if (data?.updateService?.service) {
|
||||
const existingData = cache.readQuery({ query: GET_MY_SERVICES }) as { myServices: Service[] } | null
|
||||
if (existingData) {
|
||||
cache.writeQuery({
|
||||
query: GET_MY_SERVICES,
|
||||
data: {
|
||||
myServices: existingData.myServices.map((s: Service) =>
|
||||
s.id === data.updateService.service.id ? data.updateService.service : s
|
||||
)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
await refetch()
|
||||
setIsDialogOpen(false)
|
||||
resetForm()
|
||||
|
||||
toast.success(editingService ? 'Услуга успешно обновлена' : 'Услуга успешно создана')
|
||||
// Удаления теперь происходят сразу в removeRow, так что здесь обрабатываем только обновления
|
||||
|
||||
toast.success('Все изменения успешно сохранены')
|
||||
setPendingChanges([])
|
||||
} catch (error) {
|
||||
console.error('Error saving service:', error)
|
||||
toast.error('Ошибка при сохранении услуги')
|
||||
console.error('Error saving changes:', error)
|
||||
toast.error('Ошибка при сохранении изменений')
|
||||
} finally {
|
||||
setIsSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
// Проверяем есть ли несохраненные изменения
|
||||
const hasUnsavedChanges = useMemo(() => {
|
||||
return editableServices.some(s => {
|
||||
if (s.isNew) {
|
||||
// Для новых записей проверяем что есть данные для сохранения
|
||||
return s.name.trim() || s.price || s.description.trim()
|
||||
}
|
||||
return s.hasChanges
|
||||
})
|
||||
}, [editableServices])
|
||||
|
||||
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>
|
||||
<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>
|
||||
|
||||
<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">
|
||||
<Image
|
||||
src={formData.imageUrl}
|
||||
alt="Preview"
|
||||
width={80}
|
||||
height={80}
|
||||
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>
|
||||
{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>
|
||||
|
||||
{/* Таблица услуг */}
|
||||
@ -318,7 +383,7 @@ export function ServicesTab() {
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : services.length === 0 ? (
|
||||
) : editableServices.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">
|
||||
@ -329,10 +394,7 @@ export function ServicesTab() {
|
||||
Создайте свою первую услугу, чтобы начать работу
|
||||
</p>
|
||||
<Button
|
||||
onClick={() => {
|
||||
resetForm()
|
||||
setIsDialogOpen(true)
|
||||
}}
|
||||
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" />
|
||||
@ -347,54 +409,184 @@ export function ServicesTab() {
|
||||
<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>
|
||||
{services.map((service: Service, index: number) => (
|
||||
<tr key={service.id} className="border-t border-white/10 hover:bg-white/5">
|
||||
{editableServices.map((service, index) => (
|
||||
<tr key={service.id || index} className={`border-t border-white/10 hover:bg-white/5 ${service.isNew || service.hasChanges ? 'bg-blue-500/10' : ''}`}>
|
||||
<td className="p-4 text-white/80">{index + 1}</td>
|
||||
<td className="p-4">
|
||||
{service.imageUrl ? (
|
||||
<Image
|
||||
src={service.imageUrl}
|
||||
alt={service.name}
|
||||
width={48}
|
||||
height={48}
|
||||
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)}
|
||||
/>
|
||||
|
||||
{/* Фото */}
|
||||
<td className="p-4 relative">
|
||||
{service.isEditing ? (
|
||||
<div className="flex items-center gap-3">
|
||||
<Input
|
||||
type="file"
|
||||
accept="image/*"
|
||||
onChange={(e) => {
|
||||
const file = e.target.files?.[0]
|
||||
if (file) {
|
||||
updateField(service.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"
|
||||
/>
|
||||
{service.imageUrl && (
|
||||
<div className="relative group w-12 h-12 flex-shrink-0">
|
||||
<Image
|
||||
src={service.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={service.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>
|
||||
) : service.imageUrl ? (
|
||||
<div className="relative group w-12 h-12">
|
||||
<Image
|
||||
src={service.imageUrl}
|
||||
alt={service.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={service.imageUrl}
|
||||
alt={service.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">{service.name}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</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" />
|
||||
</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">
|
||||
{service.isEditing ? (
|
||||
<Input
|
||||
value={service.name}
|
||||
onChange={(e) => updateField(service.id!, 'name', e.target.value)}
|
||||
className="bg-white/5 border-white/20 text-white"
|
||||
placeholder="Название услуги"
|
||||
/>
|
||||
) : (
|
||||
<span className="text-white font-medium">{service.name}</span>
|
||||
)}
|
||||
</td>
|
||||
|
||||
{/* Цена */}
|
||||
<td className="p-4">
|
||||
{service.isEditing ? (
|
||||
<Input
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
value={service.price}
|
||||
onChange={(e) => updateField(service.id!, 'price', e.target.value)}
|
||||
className="bg-white/5 border-white/20 text-white"
|
||||
placeholder="0.00"
|
||||
/>
|
||||
) : (
|
||||
<span className="text-white/80">
|
||||
{service.price ? parseFloat(service.price).toLocaleString() : '0'} ₽
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
|
||||
{/* Описание */}
|
||||
<td className="p-4">
|
||||
{service.isEditing ? (
|
||||
<Input
|
||||
value={service.description}
|
||||
onChange={(e) => updateField(service.id!, 'description', e.target.value)}
|
||||
className="bg-white/5 border-white/20 text-white"
|
||||
placeholder="Описание услуги"
|
||||
/>
|
||||
) : (
|
||||
<span className="text-white/80">{service.description || '—'}</span>
|
||||
)}
|
||||
</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>
|
||||
{service.isEditing ? (
|
||||
<>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
// Сохраняем только эту услугу если заполнены обязательные поля
|
||||
if (service.name.trim() && service.price) {
|
||||
saveAllChanges()
|
||||
} else {
|
||||
toast.error('Заполните обязательные поля')
|
||||
}
|
||||
}}
|
||||
disabled={!service.name.trim() || !service.price || 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(service.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(service.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>
|
||||
)}
|
||||
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="border-red-500/50 text-red-400 hover:bg-red-500/10"
|
||||
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>
|
||||
@ -413,7 +605,7 @@ export function ServicesTab() {
|
||||
Отмена
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={() => handleDelete(service.id)}
|
||||
onClick={() => removeRow(service.id!, service.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"
|
||||
>
|
||||
Удалить
|
||||
@ -433,4 +625,4 @@ export function ServicesTab() {
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
@ -1,13 +1,11 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useState, useEffect, useMemo } from 'react'
|
||||
import { useQuery, useMutation } from '@apollo/client'
|
||||
import { Card } from '@/components/ui/card'
|
||||
import Image from 'next/image'
|
||||
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,
|
||||
@ -19,7 +17,7 @@ import {
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger
|
||||
} from '@/components/ui/alert-dialog'
|
||||
import { Plus, Edit, Trash2, Upload, Package } from 'lucide-react'
|
||||
import { Plus, Trash2, Save, X, Edit, Upload, Check } from 'lucide-react'
|
||||
import { toast } from "sonner"
|
||||
import { useAuth } from '@/hooks/useAuth'
|
||||
import { GET_MY_SUPPLIES } from '@/graphql/queries'
|
||||
@ -30,25 +28,33 @@ interface Supply {
|
||||
name: string
|
||||
description?: string
|
||||
price: number
|
||||
quantity: number
|
||||
imageUrl?: string
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
interface EditableSupply {
|
||||
id?: string // undefined для новых записей
|
||||
name: string
|
||||
description: string
|
||||
price: string
|
||||
imageUrl: string
|
||||
imageFile?: File
|
||||
isNew: boolean
|
||||
isEditing: boolean
|
||||
hasChanges: boolean
|
||||
}
|
||||
|
||||
interface PendingChange extends EditableSupply {
|
||||
isDeleted?: boolean
|
||||
}
|
||||
|
||||
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] = useState(false)
|
||||
const [editableSupplies, setEditableSupplies] = useState<EditableSupply[]>([])
|
||||
const [pendingChanges, setPendingChanges] = useState<PendingChange[]>([])
|
||||
const [isSaving, setIsSaving] = useState(false)
|
||||
const [isInitialized, setIsInitialized] = useState(false)
|
||||
|
||||
// GraphQL запросы и мутации
|
||||
const { data, loading, error, refetch } = useQuery(GET_MY_SUPPLIES, {
|
||||
@ -60,45 +66,139 @@ export function SuppliesTab() {
|
||||
|
||||
const supplies = data?.mySupplies || []
|
||||
|
||||
const resetForm = () => {
|
||||
setFormData({
|
||||
// Преобразуем загруженные расходники в редактируемый формат
|
||||
useEffect(() => {
|
||||
if (data?.mySupplies && !isInitialized) {
|
||||
const convertedSupplies: EditableSupply[] = data.mySupplies.map((supply: Supply) => ({
|
||||
id: supply.id,
|
||||
name: supply.name,
|
||||
description: supply.description || '',
|
||||
price: supply.price.toString(),
|
||||
imageUrl: supply.imageUrl || '',
|
||||
isNew: false,
|
||||
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: '',
|
||||
quantity: '',
|
||||
imageUrl: ''
|
||||
})
|
||||
setImageFile(null)
|
||||
setEditingSupply(null)
|
||||
imageUrl: '',
|
||||
isNew: true,
|
||||
isEditing: true,
|
||||
hasChanges: false
|
||||
}
|
||||
setEditableSupplies(prev => [...prev, newRow])
|
||||
}
|
||||
|
||||
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 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 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
|
||||
|
||||
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 updateField = (supplyId: string, field: keyof EditableSupply, value: string | File) => {
|
||||
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 updated
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
// Загрузка изображения
|
||||
const uploadImageAndGetUrl = async (file: File): Promise<string> => {
|
||||
if (!user?.id) throw new Error('User not found')
|
||||
|
||||
@ -117,246 +217,184 @@ export function SuppliesTab() {
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
// Сохранить все изменения
|
||||
const saveAllChanges = async () => {
|
||||
setIsSaving(true)
|
||||
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()
|
||||
const suppliesToSave = editableSupplies.filter(s => {
|
||||
if (s.isNew) {
|
||||
// Для новых записей проверяем что обязательные поля заполнены
|
||||
return s.name.trim() && s.price
|
||||
}
|
||||
// Для существующих записей проверяем флаг изменений
|
||||
return s.hasChanges
|
||||
})
|
||||
|
||||
toast.success(editingSupply ? 'Расходник успешно обновлен' : 'Расходник успешно создан')
|
||||
console.log('Supplies to save:', suppliesToSave.length, suppliesToSave)
|
||||
|
||||
for (const supply of suppliesToSave) {
|
||||
if (!supply.name.trim() || !supply.price) {
|
||||
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
|
||||
}
|
||||
|
||||
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]
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
} 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, так что здесь обрабатываем только обновления
|
||||
|
||||
toast.success('Все изменения успешно сохранены')
|
||||
setPendingChanges([])
|
||||
} catch (error) {
|
||||
console.error('Error saving supply:', error)
|
||||
toast.error('Ошибка при сохранении расходника')
|
||||
console.error('Error saving changes:', 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
|
||||
})
|
||||
}, [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>
|
||||
<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>
|
||||
<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>
|
||||
|
||||
<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">
|
||||
<Image
|
||||
src={formData.imageUrl}
|
||||
alt="Preview"
|
||||
width={80}
|
||||
height={80}
|
||||
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>
|
||||
{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">
|
||||
Не удалось загрузить расходники
|
||||
</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="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>
|
||||
) : 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" />
|
||||
<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)
|
||||
}}
|
||||
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" />
|
||||
@ -371,66 +409,184 @@ 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>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{supplies.map((supply: Supply, index: number) => (
|
||||
<tr key={supply.id} className="border-t border-white/10 hover:bg-white/5">
|
||||
{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' : ''}`}>
|
||||
<td className="p-4 text-white/80">{index + 1}</td>
|
||||
<td className="p-4">
|
||||
{supply.imageUrl ? (
|
||||
<Image
|
||||
src={supply.imageUrl}
|
||||
alt={supply.name}
|
||||
width={48}
|
||||
height={48}
|
||||
className="w-12 h-12 object-cover rounded border border-white/20"
|
||||
/>
|
||||
|
||||
{/* Фото */}
|
||||
<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 ? (
|
||||
<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">
|
||||
<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>
|
||||
{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>
|
||||
)}
|
||||
</td>
|
||||
<td className="p-4 text-white/80">{supply.description || '—'}</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)}
|
||||
className="bg-white/5 border-white/20 text-white"
|
||||
placeholder="0.00"
|
||||
/>
|
||||
) : (
|
||||
<span className="text-white/80">
|
||||
{supply.price ? parseFloat(supply.price).toLocaleString() : '0'} ₽
|
||||
</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>
|
||||
)}
|
||||
</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>
|
||||
{supply.isEditing ? (
|
||||
<>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
// Сохраняем только этот расходник если заполнены обязательные поля
|
||||
if (supply.name.trim() && supply.price) {
|
||||
saveAllChanges()
|
||||
} else {
|
||||
toast.error('Заполните обязательные поля')
|
||||
}
|
||||
}}
|
||||
disabled={!supply.name.trim() || !supply.price || 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>
|
||||
)}
|
||||
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="border-red-500/50 text-red-400 hover:bg-red-500/10"
|
||||
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>
|
||||
@ -449,7 +605,7 @@ export function SuppliesTab() {
|
||||
Отмена
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={() => handleDelete(supply.id)}
|
||||
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"
|
||||
>
|
||||
Удалить
|
||||
@ -467,6 +623,8 @@ export function SuppliesTab() {
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
Reference in New Issue
Block a user