Добавлены модели и функциональность для управления логистикой, включая создание, обновление и удаление логистических маршрутов через GraphQL. Обновлены компоненты для отображения и управления логистикой, улучшен интерфейс взаимодействия с пользователем. Реализованы новые типы данных и интерфейсы для логистики, а также улучшена обработка ошибок.

This commit is contained in:
Bivekich
2025-07-18 15:40:12 +03:00
parent 7e7e4a9b4a
commit 93bb5827d2
20 changed files with 5015 additions and 667 deletions

View File

@ -83,6 +83,7 @@ model Organization {
services Service[]
supplies Supply[]
users User[]
logistics Logistics[]
@@map("organizations")
}
@ -348,3 +349,18 @@ enum ScheduleStatus {
SICK
ABSENT
}
model Logistics {
id String @id @default(cuid())
fromLocation String
toLocation String
priceUnder1m3 Float
priceOver1m3 Float
description String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
organizationId String
organization Organization @relation(fields: [organizationId], references: [id])
@@map("logistics")
}

View File

@ -0,0 +1,10 @@
import { AuthGuard } from "@/components/auth-guard"
import { CreateSupplyPage } from "@/components/supplies/create-supply-page"
export default function CreateSupplyPageRoute() {
return (
<AuthGuard>
<CreateSupplyPage />
</AuthGuard>
)
}

10
src/app/supplies/page.tsx Normal file
View File

@ -0,0 +1,10 @@
import { AuthGuard } from "@/components/auth-guard"
import { SuppliesDashboard } from "@/components/supplies/supplies-dashboard"
export default function SuppliesPage() {
return (
<AuthGuard>
<SuppliesDashboard />
</AuthGuard>
)
}

View File

@ -12,7 +12,8 @@ import {
MessageCircle,
Wrench,
Warehouse,
Users
Users,
Truck
} from 'lucide-react'
export function Sidebar() {
@ -76,12 +77,17 @@ export function Sidebar() {
router.push('/employees')
}
const handleSuppliesClick = () => {
router.push('/supplies')
}
const isSettingsActive = pathname === '/settings'
const isMarketActive = pathname.startsWith('/market')
const isMessengerActive = pathname.startsWith('/messenger')
const isServicesActive = pathname.startsWith('/services')
const isWarehouseActive = pathname.startsWith('/warehouse')
const isEmployeesActive = pathname.startsWith('/employees')
const isSuppliesActive = pathname.startsWith('/supplies')
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">
@ -180,6 +186,22 @@ export function Sidebar() {
</Button>
)}
{/* Поставки - только для селлеров */}
{user?.organization?.type === 'SELLER' && (
<Button
variant={isSuppliesActive ? "secondary" : "ghost"}
className={`w-full justify-start text-left transition-all duration-200 h-8 text-xs ${
isSuppliesActive
? 'bg-white/20 text-white hover:bg-white/30'
: 'text-white/80 hover:bg-white/10 hover:text-white'
} cursor-pointer`}
onClick={handleSuppliesClick}
>
<Truck className="h-3 w-3 mr-2" />
Поставки
</Button>
)}
{/* Склад - только для оптовиков */}
{user?.organization?.type === 'WHOLESALE' && (
<Button

View File

@ -7,8 +7,22 @@ import { Label } from '@/components/ui/label'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { Card } from '@/components/ui/card'
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar'
import { User, Camera } from 'lucide-react'
import { User, Camera, AlertCircle } from 'lucide-react'
import { toast } from 'sonner'
import {
formatPhoneInput,
formatPassportSeries,
formatPassportNumber,
formatSalary,
formatNameInput,
isValidEmail,
isValidPhone,
isValidPassportSeries,
isValidPassportNumber,
isValidBirthDate,
isValidHireDate,
isValidSalary
} from '@/lib/input-masks'
interface Employee {
id: string
@ -43,6 +57,10 @@ interface EmployeeFormProps {
onCancel: () => void
}
interface ValidationErrors {
[key: string]: string
}
export function EmployeeForm({ employee, onSave, onCancel }: EmployeeFormProps) {
const [formData, setFormData] = useState({
firstName: employee?.firstName || '',
@ -66,13 +84,154 @@ export function EmployeeForm({ employee, onSave, onCancel }: EmployeeFormProps)
})
const [isUploadingAvatar, setIsUploadingAvatar] = useState(false)
const fileInputRef = useRef<HTMLInputElement>(null)
const [loading, setLoading] = useState(false)
const [errors, setErrors] = useState<ValidationErrors>({})
const fileInputRef = useRef<HTMLInputElement>(null)
const validateField = (field: string, value: string | number): string | null => {
switch (field) {
case 'firstName':
case 'lastName':
if (!value || String(value).trim() === '') {
return field === 'firstName' ? 'Имя обязательно для заполнения' : 'Фамилия обязательна для заполнения'
}
if (String(value).length < 2) {
return field === 'firstName' ? 'Имя должно содержать минимум 2 символа' : 'Фамилия должна содержать минимум 2 символа'
}
if (!/^[а-яёА-ЯЁa-zA-Z\s-]+$/.test(String(value))) {
return field === 'firstName' ? 'Имя может содержать только буквы, пробелы и дефисы' : 'Фамилия может содержать только буквы, пробелы и дефисы'
}
break
case 'middleName':
if (value && String(value).length > 0) {
if (String(value).length < 2) {
return 'Отчество должно содержать минимум 2 символа'
}
if (!/^[а-яёА-ЯЁa-zA-Z\s-]+$/.test(String(value))) {
return 'Отчество может содержать только буквы, пробелы и дефисы'
}
}
break
case 'position':
if (!value || String(value).trim() === '') {
return 'Должность обязательна для заполнения'
}
if (String(value).length < 2) {
return 'Должность должна содержать минимум 2 символа'
}
break
case 'phone':
if (!value || String(value).trim() === '') {
return 'Телефон обязателен для заполнения'
}
if (!isValidPhone(String(value))) {
return 'Введите корректный номер телефона в формате +7 (999) 123-45-67'
}
break
case 'email':
if (value && String(value).trim() !== '' && !isValidEmail(String(value))) {
return 'Введите корректный email адрес'
}
break
case 'emergencyPhone':
if (value && String(value).trim() !== '' && !isValidPhone(String(value))) {
return 'Введите корректный номер телефона в формате +7 (999) 123-45-67'
}
break
case 'passportSeries':
if (value && String(value).trim() !== '' && !isValidPassportSeries(String(value))) {
return 'Серия паспорта должна содержать 4 цифры'
}
break
case 'passportNumber':
if (value && String(value).trim() !== '' && !isValidPassportNumber(String(value))) {
return 'Номер паспорта должен содержать 6 цифр'
}
break
case 'birthDate':
if (value && String(value).trim() !== '') {
const validation = isValidBirthDate(String(value))
if (!validation.valid) {
return validation.message || 'Некорректная дата рождения'
}
}
break
case 'hireDate':
const hireValidation = isValidHireDate(String(value))
if (!hireValidation.valid) {
return hireValidation.message || 'Некорректная дата приема'
}
break
case 'salary':
const salaryValidation = isValidSalary(Number(value))
if (!salaryValidation.valid) {
return salaryValidation.message || 'Некорректная сумма зарплаты'
}
break
}
return null
}
const handleInputChange = (field: string, value: string | number) => {
let processedValue = value
// Применяем маски ввода
if (typeof value === 'string') {
switch (field) {
case 'phone':
case 'emergencyPhone':
processedValue = formatPhoneInput(value)
break
case 'passportSeries':
processedValue = formatPassportSeries(value)
break
case 'passportNumber':
processedValue = formatPassportNumber(value)
break
case 'firstName':
case 'lastName':
case 'middleName':
case 'emergencyContact':
processedValue = formatNameInput(value)
break
}
}
setFormData(prev => ({
...prev,
[field]: value
[field]: processedValue
}))
// Валидация в реальном времени
const error = validateField(field, processedValue)
setErrors(prev => ({
...prev,
[field]: error || ''
}))
}
const handleSalaryChange = (value: string) => {
const numericValue = parseInt(value.replace(/\D/g, '')) || 0
setFormData(prev => ({
...prev,
salary: numericValue
}))
const error = validateField('salary', numericValue)
setErrors(prev => ({
...prev,
salary: error || ''
}))
}
@ -109,35 +268,36 @@ export function EmployeeForm({ employee, onSave, onCancel }: EmployeeFormProps)
}
}
const validateForm = (): boolean => {
const newErrors: ValidationErrors = {}
// Валидируем все поля
Object.keys(formData).forEach(field => {
const error = validateField(field, formData[field as keyof typeof formData])
if (error) {
newErrors[field] = error
}
})
setErrors(newErrors)
return Object.keys(newErrors).filter(key => newErrors[key]).length === 0
}
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setLoading(true)
// Валидация
if (!formData.firstName || !formData.lastName || !formData.position) {
toast.error('Пожалуйста, заполните все обязательные поля')
setLoading(false)
return
}
if (formData.email && !/\S+@\S+\.\S+/.test(formData.email)) {
toast.error('Введите корректный email адрес')
setLoading(false)
return
}
if (formData.phone && !/^[\+]?[1-9][\d]{0,15}$/.test(formData.phone.replace(/\s/g, ''))) {
toast.error('Введите корректный номер телефона')
if (!validateForm()) {
toast.error('Пожалуйста, исправьте ошибки в форме')
setLoading(false)
return
}
try {
// Для создания/обновления отправляем только нужные поля
const employeeData = {
firstName: formData.firstName,
lastName: formData.lastName,
middleName: formData.middleName,
middleName: formData.middleName || undefined,
position: formData.position,
phone: formData.phone,
email: formData.email || undefined,
@ -170,13 +330,15 @@ export function EmployeeForm({ employee, onSave, onCancel }: EmployeeFormProps)
return `${first}${last}`
}
const formatPhoneInput = (value: string) => {
const cleaned = value.replace(/\D/g, '')
if (cleaned.length <= 1) return cleaned
if (cleaned.length <= 4) return `+7 (${cleaned.slice(1)}`
if (cleaned.length <= 7) return `+7 (${cleaned.slice(1, 4)}) ${cleaned.slice(4)}`
if (cleaned.length <= 9) return `+7 (${cleaned.slice(1, 4)}) ${cleaned.slice(4, 7)}-${cleaned.slice(7)}`
return `+7 (${cleaned.slice(1, 4)}) ${cleaned.slice(4, 7)}-${cleaned.slice(7, 9)}-${cleaned.slice(9, 11)}`
// Компонент для отображения ошибок
const ErrorMessage = ({ error }: { error: string }) => {
if (!error) return null
return (
<div className="flex items-center gap-1 mt-1 text-red-400 text-xs">
<AlertCircle className="h-3 w-3 flex-shrink-0" />
<span>{error}</span>
</div>
)
}
return (
@ -227,9 +389,10 @@ export function EmployeeForm({ employee, onSave, onCancel }: EmployeeFormProps)
value={formData.firstName}
onChange={(e) => handleInputChange('firstName', e.target.value)}
placeholder="Александр"
className="glass-input text-white placeholder:text-white/40 h-10"
className={`glass-input text-white placeholder:text-white/40 h-10 ${errors.firstName ? 'border-red-400' : ''}`}
required
/>
<ErrorMessage error={errors.firstName} />
</div>
<div>
@ -240,9 +403,10 @@ export function EmployeeForm({ employee, onSave, onCancel }: EmployeeFormProps)
value={formData.lastName}
onChange={(e) => handleInputChange('lastName', e.target.value)}
placeholder="Петров"
className="glass-input text-white placeholder:text-white/40 h-10"
className={`glass-input text-white placeholder:text-white/40 h-10 ${errors.lastName ? 'border-red-400' : ''}`}
required
/>
<ErrorMessage error={errors.lastName} />
</div>
<div>
@ -251,8 +415,9 @@ export function EmployeeForm({ employee, onSave, onCancel }: EmployeeFormProps)
value={formData.middleName}
onChange={(e) => handleInputChange('middleName', e.target.value)}
placeholder="Иванович"
className="glass-input text-white placeholder:text-white/40 h-10"
className={`glass-input text-white placeholder:text-white/40 h-10 ${errors.middleName ? 'border-red-400' : ''}`}
/>
<ErrorMessage error={errors.middleName} />
</div>
<div>
@ -261,8 +426,9 @@ export function EmployeeForm({ employee, onSave, onCancel }: EmployeeFormProps)
type="date"
value={formData.birthDate}
onChange={(e) => handleInputChange('birthDate', e.target.value)}
className="glass-input text-white h-10"
className={`glass-input text-white h-10 ${errors.birthDate ? 'border-red-400' : ''}`}
/>
<ErrorMessage error={errors.birthDate} />
</div>
<div>
@ -271,8 +437,10 @@ export function EmployeeForm({ employee, onSave, onCancel }: EmployeeFormProps)
value={formData.passportSeries}
onChange={(e) => handleInputChange('passportSeries', e.target.value)}
placeholder="1234"
className="glass-input text-white placeholder:text-white/40 h-10"
maxLength={4}
className={`glass-input text-white placeholder:text-white/40 h-10 ${errors.passportSeries ? 'border-red-400' : ''}`}
/>
<ErrorMessage error={errors.passportSeries} />
</div>
<div>
@ -281,8 +449,10 @@ export function EmployeeForm({ employee, onSave, onCancel }: EmployeeFormProps)
value={formData.passportNumber}
onChange={(e) => handleInputChange('passportNumber', e.target.value)}
placeholder="567890"
className="glass-input text-white placeholder:text-white/40 h-10"
maxLength={6}
className={`glass-input text-white placeholder:text-white/40 h-10 ${errors.passportNumber ? 'border-red-400' : ''}`}
/>
<ErrorMessage error={errors.passportNumber} />
</div>
<div>
@ -330,35 +500,39 @@ export function EmployeeForm({ employee, onSave, onCancel }: EmployeeFormProps)
value={formData.position}
onChange={(e) => handleInputChange('position', e.target.value)}
placeholder="Менеджер склада"
className="glass-input text-white placeholder:text-white/40 h-10"
className={`glass-input text-white placeholder:text-white/40 h-10 ${errors.position ? 'border-red-400' : ''}`}
required
/>
<ErrorMessage error={errors.position} />
</div>
<div>
<Label className="text-white/80 text-sm mb-2 block">Дата приема на работу</Label>
<Label className="text-white/80 text-sm mb-2 block">
Дата приема на работу <span className="text-red-400">*</span>
</Label>
<Input
type="date"
value={formData.hireDate}
onChange={(e) => handleInputChange('hireDate', e.target.value)}
className="glass-input text-white h-10"
className={`glass-input text-white h-10 ${errors.hireDate ? 'border-red-400' : ''}`}
/>
<ErrorMessage error={errors.hireDate} />
</div>
<div>
<Label className="text-white/80 text-sm mb-2 block">Статус</Label>
<Select
value={formData.status}
onValueChange={(value: 'active' | 'vacation' | 'sick' | 'inactive') => handleInputChange('status', value)}
onValueChange={(value: 'ACTIVE' | 'VACATION' | 'SICK' | 'FIRED') => handleInputChange('status', value)}
>
<SelectTrigger className="glass-input text-white h-10">
<SelectValue />
</SelectTrigger>
<SelectContent className="bg-gray-900 border-white/20">
<SelectItem value="active" className="text-white hover:bg-white/10">Активен</SelectItem>
<SelectItem value="vacation" className="text-white hover:bg-white/10">В отпуске</SelectItem>
<SelectItem value="sick" className="text-white hover:bg-white/10">На больничном</SelectItem>
<SelectItem value="inactive" className="text-white hover:bg-white/10">Неактивен</SelectItem>
<SelectItem value="ACTIVE" className="text-white hover:bg-white/10">Активен</SelectItem>
<SelectItem value="VACATION" className="text-white hover:bg-white/10">В отпуске</SelectItem>
<SelectItem value="SICK" className="text-white hover:bg-white/10">На больничном</SelectItem>
<SelectItem value="FIRED" className="text-white hover:bg-white/10">Уволен</SelectItem>
</SelectContent>
</Select>
</div>
@ -367,13 +541,12 @@ export function EmployeeForm({ employee, onSave, onCancel }: EmployeeFormProps)
<div className="mt-4">
<Label className="text-white/80 text-sm mb-2 block">Зарплата ()</Label>
<Input
type="number"
min="0"
value={formData.salary || ''}
onChange={(e) => handleInputChange('salary', parseInt(e.target.value) || 0)}
value={formData.salary ? formatSalary(formData.salary.toString()) : ''}
onChange={(e) => handleSalaryChange(e.target.value)}
placeholder="80 000"
className="glass-input text-white placeholder:text-white/40 h-10"
className={`glass-input text-white placeholder:text-white/40 h-10 ${errors.salary ? 'border-red-400' : ''}`}
/>
<ErrorMessage error={errors.salary} />
</div>
</Card>
@ -382,16 +555,16 @@ export function EmployeeForm({ employee, onSave, onCancel }: EmployeeFormProps)
<h3 className="text-white font-medium mb-4">Контактные данные</h3>
<div className="grid grid-cols-2 gap-4">
<div>
<Label className="text-white/80 text-sm mb-2 block">Телефон</Label>
<Label className="text-white/80 text-sm mb-2 block">
Телефон <span className="text-red-400">*</span>
</Label>
<Input
value={formData.phone}
onChange={(e) => {
const formatted = formatPhoneInput(e.target.value)
handleInputChange('phone', formatted)
}}
onChange={(e) => handleInputChange('phone', e.target.value)}
placeholder="+7 (999) 123-45-67"
className="glass-input text-white placeholder:text-white/40 h-10"
className={`glass-input text-white placeholder:text-white/40 h-10 ${errors.phone ? 'border-red-400' : ''}`}
/>
<ErrorMessage error={errors.phone} />
</div>
<div>
@ -401,8 +574,9 @@ export function EmployeeForm({ employee, onSave, onCancel }: EmployeeFormProps)
value={formData.email}
onChange={(e) => handleInputChange('email', e.target.value)}
placeholder="a.petrov@company.com"
className="glass-input text-white placeholder:text-white/40 h-10"
className={`glass-input text-white placeholder:text-white/40 h-10 ${errors.email ? 'border-red-400' : ''}`}
/>
<ErrorMessage error={errors.email} />
</div>
<div>
@ -419,13 +593,11 @@ export function EmployeeForm({ employee, onSave, onCancel }: EmployeeFormProps)
<Label className="text-white/80 text-sm mb-2 block">Телефон экстренного контакта</Label>
<Input
value={formData.emergencyPhone}
onChange={(e) => {
const formatted = formatPhoneInput(e.target.value)
handleInputChange('emergencyPhone', formatted)
}}
onChange={(e) => handleInputChange('emergencyPhone', e.target.value)}
placeholder="+7 (999) 123-45-67"
className="glass-input text-white placeholder:text-white/40 h-10"
className={`glass-input text-white placeholder:text-white/40 h-10 ${errors.emergencyPhone ? 'border-red-400' : ''}`}
/>
<ErrorMessage error={errors.emergencyPhone} />
</div>
</div>
</Card>

View File

@ -20,11 +20,25 @@ import {
Mail,
Briefcase,
DollarSign,
FileText,
MessageCircle
MessageCircle,
AlertCircle
} from 'lucide-react'
import { toast } from 'sonner'
import {
formatPhoneInput,
formatPassportSeries,
formatPassportNumber,
formatSalary,
formatNameInput,
isValidEmail,
isValidPhone,
isValidPassportSeries,
isValidPassportNumber,
isValidBirthDate,
isValidHireDate,
isValidSalary
} from '@/lib/input-masks'
interface EmployeeInlineFormProps {
onSave: (employeeData: {
@ -46,6 +60,10 @@ interface EmployeeInlineFormProps {
isLoading?: boolean
}
interface ValidationErrors {
[key: string]: string
}
export function EmployeeInlineForm({ onSave, onCancel, isLoading = false }: EmployeeInlineFormProps) {
const [formData, setFormData] = useState({
firstName: '',
@ -65,13 +83,123 @@ export function EmployeeInlineForm({ onSave, onCancel, isLoading = false }: Empl
const [isUploadingAvatar, setIsUploadingAvatar] = useState(false)
const [isUploadingPassport, setIsUploadingPassport] = useState(false)
const [showPassportPreview, setShowPassportPreview] = useState(false)
const [errors, setErrors] = useState<ValidationErrors>({})
const avatarInputRef = useRef<HTMLInputElement>(null)
const passportInputRef = useRef<HTMLInputElement>(null)
const validateField = (field: string, value: string | number): string | null => {
switch (field) {
case 'firstName':
case 'lastName':
if (!value || String(value).trim() === '') {
return field === 'firstName' ? 'Имя обязательно для заполнения' : 'Фамилия обязательна для заполнения'
}
if (String(value).length < 2) {
return field === 'firstName' ? 'Имя должно содержать минимум 2 символа' : 'Фамилия должна содержать минимум 2 символа'
}
if (!/^[а-яёА-ЯЁa-zA-Z\s-]+$/.test(String(value))) {
return field === 'firstName' ? 'Имя может содержать только буквы, пробелы и дефисы' : 'Фамилия может содержать только буквы, пробелы и дефисы'
}
break
case 'middleName':
if (value && String(value).length > 0) {
if (String(value).length < 2) {
return 'Отчество должно содержать минимум 2 символа'
}
if (!/^[а-яёА-ЯЁa-zA-Z\s-]+$/.test(String(value))) {
return 'Отчество может содержать только буквы, пробелы и дефисы'
}
}
break
case 'position':
if (!value || String(value).trim() === '') {
return 'Должность обязательна для заполнения'
}
if (String(value).length < 2) {
return 'Должность должна содержать минимум 2 символа'
}
break
case 'phone':
case 'whatsapp':
if (field === 'phone' && (!value || String(value).trim() === '')) {
return 'Телефон обязателен для заполнения'
}
if (value && String(value).trim() !== '' && !isValidPhone(String(value))) {
return 'Введите корректный номер телефона в формате +7 (999) 123-45-67'
}
break
case 'email':
if (value && String(value).trim() !== '' && !isValidEmail(String(value))) {
return 'Введите корректный email адрес'
}
break
case 'birthDate':
if (value && String(value).trim() !== '') {
const validation = isValidBirthDate(String(value))
if (!validation.valid) {
return validation.message || 'Некорректная дата рождения'
}
}
break
case 'salary':
const salaryValidation = isValidSalary(Number(value))
if (!salaryValidation.valid) {
return salaryValidation.message || 'Некорректная сумма зарплаты'
}
break
}
return null
}
const handleInputChange = (field: string, value: string | number) => {
let processedValue = value
// Применяем маски ввода
if (typeof value === 'string') {
switch (field) {
case 'phone':
case 'whatsapp':
processedValue = formatPhoneInput(value)
break
case 'firstName':
case 'lastName':
case 'middleName':
processedValue = formatNameInput(value)
break
}
}
setFormData(prev => ({
...prev,
[field]: value
[field]: processedValue
}))
// Валидация в реальном времени
const error = validateField(field, processedValue)
setErrors(prev => ({
...prev,
[field]: error || ''
}))
}
const handleSalaryChange = (value: string) => {
const numericValue = parseInt(value.replace(/\D/g, '')) || 0
setFormData(prev => ({
...prev,
salary: numericValue
}))
const error = validateField('salary', numericValue)
setErrors(prev => ({
...prev,
salary: error || ''
}))
}
@ -126,26 +254,28 @@ export function EmployeeInlineForm({ onSave, onCancel, isLoading = false }: Empl
}
}
const formatPhoneInput = (value: string) => {
const cleaned = value.replace(/\D/g, '')
if (cleaned.length <= 1) return cleaned
if (cleaned.length <= 4) return `+7 (${cleaned.slice(1)}`
if (cleaned.length <= 7) return `+7 (${cleaned.slice(1, 4)}) ${cleaned.slice(4)}`
if (cleaned.length <= 9) return `+7 (${cleaned.slice(1, 4)}) ${cleaned.slice(4, 7)}-${cleaned.slice(7)}`
return `+7 (${cleaned.slice(1, 4)}) ${cleaned.slice(4, 7)}-${cleaned.slice(7, 9)}-${cleaned.slice(9, 11)}`
const validateForm = (): boolean => {
const newErrors: ValidationErrors = {}
// Валидируем все поля
Object.keys(formData).forEach(field => {
const error = validateField(field, formData[field as keyof typeof formData])
if (error) {
newErrors[field] = error
}
})
setErrors(newErrors)
return Object.keys(newErrors).filter(key => newErrors[key]).length === 0
}
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
// Валидация обязательных полей
if (!formData.firstName || !formData.lastName || !formData.phone || !formData.position) {
toast.error('Пожалуйста, заполните все обязательные поля')
return
}
if (formData.email && !/\S+@\S+\.\S+/.test(formData.email)) {
toast.error('Введите корректный email адрес')
if (!validateForm()) {
toast.error('Пожалуйста, исправьте ошибки в форме')
return
}
@ -169,6 +299,17 @@ export function EmployeeInlineForm({ onSave, onCancel, isLoading = false }: Empl
onSave(employeeData)
}
// Компонент для отображения ошибок
const ErrorMessage = ({ error }: { error: string }) => {
if (!error) return null
return (
<div className="flex items-center gap-1 mt-1 text-red-400 text-xs">
<AlertCircle className="h-3 w-3 flex-shrink-0" />
<span>{error}</span>
</div>
)
}
const getInitials = () => {
const first = formData.firstName.charAt(0).toUpperCase()
const last = formData.lastName.charAt(0).toUpperCase()
@ -324,9 +465,10 @@ export function EmployeeInlineForm({ onSave, onCancel, isLoading = false }: Empl
value={formData.firstName}
onChange={(e) => handleInputChange('firstName', e.target.value)}
placeholder="Александр"
className="glass-input text-white placeholder:text-white/40"
className={`glass-input text-white placeholder:text-white/40 ${errors.firstName ? 'border-red-400' : ''}`}
required
/>
<ErrorMessage error={errors.firstName} />
</div>
<div>
@ -337,9 +479,10 @@ export function EmployeeInlineForm({ onSave, onCancel, isLoading = false }: Empl
value={formData.lastName}
onChange={(e) => handleInputChange('lastName', e.target.value)}
placeholder="Петров"
className="glass-input text-white placeholder:text-white/40"
className={`glass-input text-white placeholder:text-white/40 ${errors.lastName ? 'border-red-400' : ''}`}
required
/>
<ErrorMessage error={errors.lastName} />
</div>
<div>
@ -348,8 +491,9 @@ export function EmployeeInlineForm({ onSave, onCancel, isLoading = false }: Empl
value={formData.middleName}
onChange={(e) => handleInputChange('middleName', e.target.value)}
placeholder="Иванович"
className="glass-input text-white placeholder:text-white/40"
className={`glass-input text-white placeholder:text-white/40 ${errors.middleName ? 'border-red-400' : ''}`}
/>
<ErrorMessage error={errors.middleName} />
</div>
<div>
@ -382,14 +526,12 @@ export function EmployeeInlineForm({ onSave, onCancel, isLoading = false }: Empl
</Label>
<Input
value={formData.phone}
onChange={(e) => {
const formatted = formatPhoneInput(e.target.value)
handleInputChange('phone', formatted)
}}
onChange={(e) => handleInputChange('phone', e.target.value)}
placeholder="+7 (999) 123-45-67"
className="glass-input text-white placeholder:text-white/40"
className={`glass-input text-white placeholder:text-white/40 ${errors.phone ? 'border-red-400' : ''}`}
required
/>
<ErrorMessage error={errors.phone} />
</div>
<div>
@ -412,13 +554,11 @@ export function EmployeeInlineForm({ onSave, onCancel, isLoading = false }: Empl
</Label>
<Input
value={formData.whatsapp}
onChange={(e) => {
const formatted = formatPhoneInput(e.target.value)
handleInputChange('whatsapp', formatted)
}}
onChange={(e) => handleInputChange('whatsapp', e.target.value)}
placeholder="+7 (999) 123-45-67"
className="glass-input text-white placeholder:text-white/40"
className={`glass-input text-white placeholder:text-white/40 ${errors.whatsapp ? 'border-red-400' : ''}`}
/>
<ErrorMessage error={errors.whatsapp} />
</div>
<div>
@ -431,8 +571,9 @@ export function EmployeeInlineForm({ onSave, onCancel, isLoading = false }: Empl
value={formData.email}
onChange={(e) => handleInputChange('email', e.target.value)}
placeholder="a.petrov@company.com"
className="glass-input text-white placeholder:text-white/40"
className={`glass-input text-white placeholder:text-white/40 ${errors.email ? 'border-red-400' : ''}`}
/>
<ErrorMessage error={errors.email} />
</div>
</div>
</div>
@ -455,9 +596,10 @@ export function EmployeeInlineForm({ onSave, onCancel, isLoading = false }: Empl
value={formData.position}
onChange={(e) => handleInputChange('position', e.target.value)}
placeholder="Менеджер склада"
className="glass-input text-white placeholder:text-white/40"
className={`glass-input text-white placeholder:text-white/40 ${errors.position ? 'border-red-400' : ''}`}
required
/>
<ErrorMessage error={errors.position} />
</div>
<div>
@ -466,13 +608,12 @@ export function EmployeeInlineForm({ onSave, onCancel, isLoading = false }: Empl
Зарплата
</Label>
<Input
type="number"
min="0"
value={formData.salary || ''}
onChange={(e) => handleInputChange('salary', parseInt(e.target.value) || 0)}
value={formData.salary ? formatSalary(formData.salary.toString()) : ''}
onChange={(e) => handleSalaryChange(e.target.value)}
placeholder="80 000"
className="glass-input text-white placeholder:text-white/40"
className={`glass-input text-white placeholder:text-white/40 ${errors.salary ? 'border-red-400' : ''}`}
/>
<ErrorMessage error={errors.salary} />
</div>
</div>
</div>

View File

@ -1,35 +1,689 @@
"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() {
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 (
<div className="h-full">
<Card className="h-full bg-white/5 backdrop-blur border-white/10 p-6">
<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">
<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>
<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 max-w-md">
Раздел логистики находится в разработке.
Здесь будут инструменты для управления доставкой и складскими операциями.
<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>
)

View File

@ -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, {
@ -58,44 +66,139 @@ export function ServicesTab() {
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({
// Преобразуем загруженные услуги в редактируемый формат
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 || ''
})
setIsDialogOpen(true)
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: '',
isNew: true,
isEditing: true,
hasChanges: false
}
setEditableServices(prev => [...prev, newRow])
}
const handleDelete = async (serviceId: string) => {
// Удалить строку
const removeRow = async (serviceId: string, isNew: boolean) => {
if (isNew) {
// Просто удаляем из массива если это новая строка
setEditableServices(prev => prev.filter(s => s.id !== serviceId))
} else {
// Удаляем существующую запись сразу
try {
await deleteService({
variables: { id: serviceId }
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)
}
})
await refetch()
}
}
})
// Удаляем из локального состояния по 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()
// Сохранить все изменения
const saveAllChanges = async () => {
setIsSaving(true)
try {
const servicesToSave = editableServices.filter(s => {
if (s.isNew) {
// Для новых записей проверяем что обязательные поля заполнены
return s.name.trim() && s.price
}
// Для существующих записей проверяем флаг изменений
return s.hasChanges
})
if (!formData.name.trim() || !formData.price) {
toast.error('Заполните обязательные поля')
console.log('Services to save:', servicesToSave.length, servicesToSave)
for (const service of servicesToSave) {
if (!service.name.trim() || !service.price) {
toast.error(`Заполните обязательные поля для всех услуг`)
setIsSaving(false)
return
}
try {
let imageUrl = formData.imageUrl
let imageUrl = service.imageUrl
// Загружаем изображение если выбрано
if (imageFile) {
const uploadResult = await uploadImageAndGetUrl(imageFile)
imageUrl = uploadResult
if (service.imageFile) {
imageUrl = await uploadImageAndGetUrl(service.imageFile)
}
const input = {
name: formData.name,
description: formData.description || undefined,
price: parseFloat(formData.price),
name: service.name,
description: service.description || undefined,
price: parseFloat(service.price),
imageUrl: imageUrl || undefined
}
console.log('Submitting service with data:', input)
if (editingService) {
await updateService({
variables: { id: editingService.id, input }
})
} else {
if (service.isNew) {
await createService({
variables: { input }
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()
// Удаления теперь происходят сразу в removeRow, так что здесь обрабатываем только обновления
toast.success(editingService ? 'Услуга успешно обновлена' : 'Услуга успешно создана')
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>
<div className="flex gap-3">
<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)
}}
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>
</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">
<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>
{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 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>
{/* Таблица услуг */}
@ -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 ? (
{/* Фото */}
<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"
onError={(e) => {
console.error('Image failed to load:', service.imageUrl, e)
}}
onLoad={() => console.log('Image loaded successfully:', service.imageUrl)}
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">
{service.isEditing ? (
<>
<Button
size="sm"
variant="outline"
onClick={() => handleEdit(service)}
className="border-white/20 text-white hover:bg-white/10"
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"
>
Удалить

View File

@ -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({
name: '',
description: '',
price: '',
quantity: '',
imageUrl: ''
})
setImageFile(null)
setEditingSupply(null)
}
const handleEdit = (supply: Supply) => {
setEditingSupply(supply)
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(),
quantity: supply.quantity.toString(),
imageUrl: supply.imageUrl || ''
})
setIsDialogOpen(true)
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: '',
imageUrl: '',
isNew: true,
isEditing: true,
hasChanges: false
}
setEditableSupplies(prev => [...prev, newRow])
}
const handleDelete = async (supplyId: string) => {
// Удалить строку
const removeRow = async (supplyId: string, isNew: boolean) => {
if (isNew) {
// Просто удаляем из массива если это новая строка
setEditableSupplies(prev => prev.filter(s => s.id !== supplyId))
} else {
// Удаляем существующую запись сразу
try {
await deleteSupply({
variables: { id: supplyId }
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)
}
})
await refetch()
}
}
})
// Удаляем из локального состояния по 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,196 +217,137 @@ 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
const suppliesToSave = editableSupplies.filter(s => {
if (s.isNew) {
// Для новых записей проверяем что обязательные поля заполнены
return s.name.trim() && s.price
}
// Для существующих записей проверяем флаг изменений
return s.hasChanges
})
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 (imageFile) {
const uploadResult = await uploadImageAndGetUrl(imageFile)
imageUrl = uploadResult
if (supply.imageFile) {
imageUrl = await uploadImageAndGetUrl(supply.imageFile)
}
const input = {
name: formData.name,
description: formData.description || undefined,
price: parseFloat(formData.price),
quantity: quantity,
name: supply.name,
description: supply.description || undefined,
price: parseFloat(supply.price),
imageUrl: imageUrl || undefined
}
if (editingSupply) {
await updateSupply({
variables: { id: editingSupply.id, input }
})
} else {
if (supply.isNew) {
await createSupply({
variables: { input }
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
)
}
})
}
}
}
})
}
}
await refetch()
setIsDialogOpen(false)
resetForm()
// Удаления теперь происходят сразу в removeRow, так что здесь обрабатываем только обновления
toast.success(editingSupply ? 'Расходник успешно обновлен' : 'Расходник успешно создан')
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>
<div className="flex gap-3">
<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)
}}
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>
</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">
<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>
{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 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>
{/* Таблица расходников */}
@ -342,21 +383,18 @@ export function SuppliesTab() {
</Button>
</div>
</div>
) : supplies.length === 0 ? (
) : 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 ? (
{/* Фото */}
<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"
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.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>
)}
{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">
{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">
{supply.isEditing ? (
<>
<Button
size="sm"
variant="outline"
onClick={() => handleEdit(supply)}
className="border-white/20 text-white hover:bg-white/10"
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>
)
}

View File

@ -0,0 +1,301 @@
"use client"
import React, { useState } from 'react'
import { Card } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import {
ShoppingCart,
Users,
ArrowLeft,
Package,
Building2,
MapPin,
Phone,
Mail,
Star
} from 'lucide-react'
// import { WholesalerSelection } from './wholesaler-selection'
interface Wholesaler {
id: string
inn: string
name: string
fullName: string
address: string
phone?: string
email?: string
rating: number
productCount: number
avatar?: string
specialization: string[]
}
interface CreateSupplyFormProps {
onClose: () => void
onSupplyCreated: () => void
}
// Моковые данные оптовиков
const mockWholesalers: Wholesaler[] = [
{
id: '1',
inn: '7707083893',
name: 'ОПТ-Электроника',
fullName: 'ООО "ОПТ-Электроника"',
address: 'г. Москва, ул. Садовая, д. 15',
phone: '+7 (495) 123-45-67',
email: 'opt@electronics.ru',
rating: 4.8,
productCount: 1250,
specialization: ['Электроника', 'Бытовая техника']
},
{
id: '2',
inn: '7707083894',
name: 'ТекстильМастер',
fullName: 'ООО "ТекстильМастер"',
address: 'г. Иваново, пр. Ленина, д. 42',
phone: '+7 (4932) 55-66-77',
email: 'sales@textilmaster.ru',
rating: 4.6,
productCount: 850,
specialization: ['Текстиль', 'Одежда', 'Домашний текстиль']
},
{
id: '3',
inn: '7707083895',
name: 'МетизКомплект',
fullName: 'ООО "МетизКомплект"',
address: 'г. Тула, ул. Металлургов, д. 8',
phone: '+7 (4872) 33-44-55',
email: 'info@metiz.ru',
rating: 4.9,
productCount: 2100,
specialization: ['Крепеж', 'Метизы', 'Инструменты']
}
]
export function CreateSupplyForm({ onClose, onSupplyCreated }: CreateSupplyFormProps) {
const [selectedVariant, setSelectedVariant] = useState<'cards' | 'wholesaler' | null>(null)
const [selectedWholesaler, setSelectedWholesaler] = useState<Wholesaler | null>(null)
const renderStars = (rating: number) => {
return Array.from({ length: 5 }, (_, i) => (
<Star
key={i}
className={`h-4 w-4 ${i < Math.floor(rating) ? 'text-yellow-400 fill-current' : 'text-gray-400'}`}
/>
))
}
if (selectedVariant === 'wholesaler') {
if (selectedWholesaler) {
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div className="flex items-center space-x-3">
<Button
variant="ghost"
size="sm"
onClick={() => setSelectedWholesaler(null)}
className="text-white/60 hover:text-white hover:bg-white/10"
>
<ArrowLeft className="h-4 w-4 mr-2" />
Назад
</Button>
<div>
<h2 className="text-2xl font-bold text-white mb-1">Товары оптовика</h2>
<p className="text-white/60">{selectedWholesaler.name}</p>
</div>
</div>
<Button
variant="ghost"
size="sm"
onClick={onClose}
className="text-white/60 hover:text-white hover:bg-white/10"
>
Отмена
</Button>
</div>
<div className="text-center py-12">
<p className="text-white/60">Компонент товаров оптовика в разработке...</p>
</div>
</div>
)
}
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div className="flex items-center space-x-3">
<Button
variant="ghost"
size="sm"
onClick={() => setSelectedVariant(null)}
className="text-white/60 hover:text-white hover:bg-white/10"
>
<ArrowLeft className="h-4 w-4 mr-2" />
Назад
</Button>
<div>
<h2 className="text-2xl font-bold text-white mb-1">Выбор оптовика</h2>
<p className="text-white/60">Выберите оптовика для создания поставки</p>
</div>
</div>
<Button
variant="ghost"
size="sm"
onClick={onClose}
className="text-white/60 hover:text-white hover:bg-white/10"
>
Отмена
</Button>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{mockWholesalers.map((wholesaler) => (
<Card
key={wholesaler.id}
className="bg-white/10 backdrop-blur border-white/20 p-6 cursor-pointer transition-all hover:bg-white/15 hover:border-white/30 hover:scale-105"
onClick={() => setSelectedWholesaler(wholesaler)}
>
<div className="space-y-4">
{/* Заголовок карточки */}
<div className="flex items-start space-x-3">
<div className="p-3 bg-blue-500/20 rounded-lg">
<Building2 className="h-6 w-6 text-blue-400" />
</div>
<div className="flex-1 min-w-0">
<h3 className="text-white font-semibold text-lg mb-1 truncate">
{wholesaler.name}
</h3>
<p className="text-white/60 text-xs mb-2 truncate">
{wholesaler.fullName}
</p>
<div className="flex items-center space-x-1 mb-2">
{renderStars(wholesaler.rating)}
<span className="text-white/60 text-sm ml-2">{wholesaler.rating}</span>
</div>
</div>
</div>
{/* Информация */}
<div className="space-y-2">
<div className="flex items-center space-x-2">
<MapPin className="h-4 w-4 text-gray-400" />
<span className="text-white/80 text-sm truncate">{wholesaler.address}</span>
</div>
{wholesaler.phone && (
<div className="flex items-center space-x-2">
<Phone className="h-4 w-4 text-gray-400" />
<span className="text-white/80 text-sm">{wholesaler.phone}</span>
</div>
)}
{wholesaler.email && (
<div className="flex items-center space-x-2">
<Mail className="h-4 w-4 text-gray-400" />
<span className="text-white/80 text-sm truncate">{wholesaler.email}</span>
</div>
)}
<div className="flex items-center space-x-2">
<Package className="h-4 w-4 text-gray-400" />
<span className="text-white/80 text-sm">{wholesaler.productCount} товаров</span>
</div>
</div>
{/* Специализация */}
<div className="space-y-2">
<p className="text-white/60 text-xs">Специализация:</p>
<div className="flex flex-wrap gap-1">
{wholesaler.specialization.map((spec, index) => (
<Badge
key={index}
className="bg-purple-500/20 text-purple-300 border-purple-500/30 text-xs"
>
{spec}
</Badge>
))}
</div>
</div>
{/* ИНН */}
<div className="pt-2 border-t border-white/10">
<p className="text-white/60 text-xs">ИНН: {wholesaler.inn}</p>
</div>
</div>
</Card>
))}
</div>
</div>
)
}
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h2 className="text-2xl font-bold text-white mb-2">Создание поставки</h2>
<p className="text-white/60">Выберите способ создания поставки</p>
</div>
<Button
variant="ghost"
size="sm"
onClick={onClose}
className="text-white/60 hover:text-white hover:bg-white/10"
>
Отмена
</Button>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{/* Вариант 1: Карточки */}
<Card
className="bg-white/10 backdrop-blur border-white/20 p-6 cursor-pointer transition-all hover:bg-white/15 hover:border-white/30"
onClick={() => setSelectedVariant('cards')}
>
<div className="text-center space-y-4">
<div className="p-4 bg-blue-500/20 rounded-lg w-fit mx-auto">
<ShoppingCart className="h-8 w-8 text-blue-400" />
</div>
<div>
<h3 className="text-xl font-semibold text-white mb-2">Карточки</h3>
<p className="text-white/60 text-sm">
Создание поставки через выбор товаров по карточкам
</p>
</div>
<Badge className="bg-yellow-500/20 text-yellow-300 border-yellow-500/30">
В разработке
</Badge>
</div>
</Card>
{/* Вариант 2: Оптовик */}
<Card
className="bg-white/10 backdrop-blur border-white/20 p-6 cursor-pointer transition-all hover:bg-white/15 hover:border-white/30"
onClick={() => setSelectedVariant('wholesaler')}
>
<div className="text-center space-y-4">
<div className="p-4 bg-green-500/20 rounded-lg w-fit mx-auto">
<Users className="h-8 w-8 text-green-400" />
</div>
<div>
<h3 className="text-xl font-semibold text-white mb-2">Оптовик</h3>
<p className="text-white/60 text-sm">
Создание поставки через выбор товаров у оптовиков
</p>
</div>
<Badge className="bg-green-500/20 text-green-300 border-green-500/30">
Доступно
</Badge>
</div>
</Card>
</div>
</div>
)
}

View File

@ -0,0 +1,782 @@
"use client"
import React, { useState } from 'react'
import { Card } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { Input } from '@/components/ui/input'
import { Sidebar } from '@/components/dashboard/sidebar'
import {
ArrowLeft,
ShoppingCart,
Users,
Building2,
MapPin,
Phone,
Mail,
Star,
Plus,
Minus,
Info,
Package,
Zap,
Heart,
Eye,
ShoppingBag
} from 'lucide-react'
import { useRouter } from 'next/navigation'
import Image from 'next/image'
interface WholesalerForCreation {
id: string
inn: string
name: string
fullName: string
address: string
phone?: string
email?: string
rating: number
productCount: number
avatar?: string
specialization: string[]
}
interface WholesalerProduct {
id: string
name: string
article: string
description: string
price: number
quantity: number
category: string
brand?: string
color?: string
size?: string
weight?: number
dimensions?: string
material?: string
images: string[]
mainImage?: string
discount?: number
isNew?: boolean
isBestseller?: boolean
}
interface SelectedProduct extends WholesalerProduct {
selectedQuantity: number
}
// Моковые данные оптовиков
const mockWholesalers: WholesalerForCreation[] = [
{
id: '1',
inn: '7707083893',
name: 'ОПТ-Электроника',
fullName: 'ООО "ОПТ-Электроника"',
address: 'г. Москва, ул. Садовая, д. 15',
phone: '+7 (495) 123-45-67',
email: 'opt@electronics.ru',
rating: 4.8,
productCount: 1250,
specialization: ['Электроника', 'Бытовая техника']
},
{
id: '2',
inn: '7707083894',
name: 'ТекстильМастер',
fullName: 'ООО "ТекстильМастер"',
address: 'г. Иваново, пр. Ленина, д. 42',
phone: '+7 (4932) 55-66-77',
email: 'sales@textilmaster.ru',
rating: 4.6,
productCount: 850,
specialization: ['Текстиль', 'Одежда', 'Домашний текстиль']
},
{
id: '3',
inn: '7707083895',
name: 'МетизКомплект',
fullName: 'ООО "МетизКомплект"',
address: 'г. Тула, ул. Металлургов, д. 8',
phone: '+7 (4872) 33-44-55',
email: 'info@metiz.ru',
rating: 4.9,
productCount: 2100,
specialization: ['Крепеж', 'Метизы', 'Инструменты']
}
]
// Улучшенные моковые данные товаров
const mockProducts: WholesalerProduct[] = [
{
id: '1',
name: 'iPhone 15 Pro Max',
article: 'APL-15PM-256',
description: 'Флагманский смартфон Apple с титановым корпусом, камерой 48 МП и чипом A17 Pro',
price: 124900,
quantity: 45,
category: 'Смартфоны',
brand: 'Apple',
color: 'Натуральный титан',
size: '6.7"',
weight: 221,
dimensions: '159.9 x 76.7 x 8.25 мм',
material: 'Титан, стекло',
images: ['https://store.storeimages.cdn-apple.com/4982/as-images.apple.com/is/iphone-15-pro-max-naturaltitanium-select?wid=470&hei=556&fmt=jpeg&qlt=99&.v=1692845705224'],
mainImage: 'https://store.storeimages.cdn-apple.com/4982/as-images.apple.com/is/iphone-15-pro-max-naturaltitanium-select?wid=470&hei=556&fmt=jpeg&qlt=99&.v=1692845705224',
isNew: true,
isBestseller: true
},
{
id: '2',
name: 'Sony WH-1000XM5',
article: 'SNY-WH1000XM5',
description: 'Беспроводные наушники премиум-класса с лучшим в отрасли шумоподавлением',
price: 34900,
quantity: 128,
category: 'Наушники',
brand: 'Sony',
color: 'Черный',
weight: 250,
material: 'Пластик, эко-кожа',
images: ['https://www.sony.ru/image/5d02da5df552836db894cead8a68f5f3?fmt=pjpeg&wid=330&bgcolor=FFFFFF&bgc=FFFFFF'],
mainImage: 'https://www.sony.ru/image/5d02da5df552836db894cead8a68f5f3?fmt=pjpeg&wid=330&bgcolor=FFFFFF&bgc=FFFFFF',
discount: 15,
isBestseller: true
},
{
id: '3',
name: 'iPad Pro 12.9" M2',
article: 'APL-IPADPRO-M2',
description: 'Самый мощный iPad с чипом M2, дисплеем Liquid Retina XDR и поддержкой Apple Pencil',
price: 109900,
quantity: 32,
category: 'Планшеты',
brand: 'Apple',
color: 'Серый космос',
size: '12.9"',
weight: 682,
dimensions: '280.6 x 214.9 x 6.4 мм',
material: 'Алюминий',
images: ['https://store.storeimages.cdn-apple.com/4982/as-images.apple.com/is/ipad-pro-12-select-wifi-spacegray-202210?wid=470&hei=556&fmt=jpeg&qlt=99&.v=1664411207213'],
mainImage: 'https://store.storeimages.cdn-apple.com/4982/as-images.apple.com/is/ipad-pro-12-select-wifi-spacegray-202210?wid=470&hei=556&fmt=jpeg&qlt=99&.v=1664411207213',
isNew: true
},
{
id: '4',
name: 'MacBook Pro 16" M3 Max',
article: 'APL-MBP16-M3MAX',
description: 'Профессиональный ноутбук с чипом M3 Max, 36 ГБ памяти и дисплеем Liquid Retina XDR',
price: 329900,
quantity: 18,
category: 'Ноутбуки',
brand: 'Apple',
color: 'Серый космос',
size: '16"',
weight: 2160,
dimensions: '355.7 x 248.1 x 16.8 мм',
material: 'Алюминий',
images: ['https://store.storeimages.cdn-apple.com/4982/as-images.apple.com/is/mbp16-spacegray-select-202310?wid=470&hei=556&fmt=jpeg&qlt=99&.v=1697230830200'],
mainImage: 'https://store.storeimages.cdn-apple.com/4982/as-images.apple.com/is/mbp16-spacegray-select-202310?wid=470&hei=556&fmt=jpeg&qlt=99&.v=1697230830200',
isNew: true,
isBestseller: true
},
{
id: '5',
name: 'Apple Watch Ultra 2',
article: 'APL-AWU2-49',
description: 'Самые прочные и функциональные умные часы Apple для экстремальных приключений',
price: 89900,
quantity: 67,
category: 'Умные часы',
brand: 'Apple',
color: 'Натуральный титан',
size: '49 мм',
weight: 61,
dimensions: '49 x 44 x 14.4 мм',
material: 'Титан',
images: ['https://store.storeimages.cdn-apple.com/4982/as-images.apple.com/is/watch-ultra2-select-202309?wid=470&hei=556&fmt=jpeg&qlt=99&.v=1693967875133'],
mainImage: 'https://store.storeimages.cdn-apple.com/4982/as-images.apple.com/is/watch-ultra2-select-202309?wid=470&hei=556&fmt=jpeg&qlt=99&.v=1693967875133',
isNew: true
},
{
id: '6',
name: 'Magic Keyboard для iPad Pro',
article: 'APL-MK-IPADPRO',
description: 'Клавиатура с трекпадом и подсветкой клавиш для iPad Pro 12.9"',
price: 36900,
quantity: 89,
category: 'Аксессуары',
brand: 'Apple',
color: 'Черный',
weight: 710,
dimensions: '280.9 x 214.3 x 25 мм',
material: 'Алюминий, пластик',
images: ['https://store.storeimages.cdn-apple.com/4982/as-images.apple.com/is/MJQJ3?wid=470&hei=556&fmt=jpeg&qlt=99&.v=1639066901000'],
mainImage: 'https://store.storeimages.cdn-apple.com/4982/as-images.apple.com/is/MJQJ3?wid=470&hei=556&fmt=jpeg&qlt=99&.v=1639066901000'
}
]
export function CreateSupplyPage() {
const router = useRouter()
const [selectedVariant, setSelectedVariant] = useState<'cards' | 'wholesaler' | null>(null)
const [selectedWholesaler, setSelectedWholesaler] = useState<WholesalerForCreation | null>(null)
const [selectedProducts, setSelectedProducts] = useState<SelectedProduct[]>([])
const [showSummary, setShowSummary] = useState(false)
const formatCurrency = (amount: number) => {
return new Intl.NumberFormat('ru-RU', {
style: 'currency',
currency: 'RUB',
minimumFractionDigits: 0
}).format(amount)
}
const renderStars = (rating: number) => {
return Array.from({ length: 5 }, (_, i) => (
<Star
key={i}
className={`h-4 w-4 ${i < Math.floor(rating) ? 'text-yellow-400 fill-current' : 'text-gray-400'}`}
/>
))
}
const updateProductQuantity = (productId: string, quantity: number) => {
const product = mockProducts.find(p => p.id === productId)
if (!product) return
setSelectedProducts(prev => {
const existing = prev.find(p => p.id === productId)
if (quantity === 0) {
return prev.filter(p => p.id !== productId)
}
if (existing) {
return prev.map(p =>
p.id === productId ? { ...p, selectedQuantity: quantity } : p
)
} else {
return [...prev, { ...product, selectedQuantity: quantity }]
}
})
}
const getSelectedQuantity = (productId: string): number => {
const selected = selectedProducts.find(p => p.id === productId)
return selected ? selected.selectedQuantity : 0
}
const getTotalAmount = () => {
return selectedProducts.reduce((sum, product) => {
const discountedPrice = product.discount
? product.price * (1 - product.discount / 100)
: product.price
return sum + (discountedPrice * product.selectedQuantity)
}, 0)
}
const getTotalItems = () => {
return selectedProducts.reduce((sum, product) => sum + product.selectedQuantity, 0)
}
const handleCreateSupply = () => {
console.log('Создание поставки с товарами:', selectedProducts)
// TODO: Здесь будет реальное создание поставки
router.push('/supplies')
}
const handleGoBack = () => {
if (selectedWholesaler) {
setSelectedWholesaler(null)
setSelectedProducts([])
setShowSummary(false)
} else if (selectedVariant) {
setSelectedVariant(null)
} else {
router.push('/supplies')
}
}
// Рендер товаров оптовика
if (selectedWholesaler && selectedVariant === 'wholesaler') {
return (
<div className="min-h-screen bg-gradient-smooth flex">
<Sidebar />
<main className="flex-1 ml-56">
<div className="p-8">
<div className="flex items-center justify-between mb-8">
<div className="flex items-center space-x-4">
<Button
variant="ghost"
size="sm"
onClick={handleGoBack}
className="text-white/60 hover:text-white hover:bg-white/10"
>
<ArrowLeft className="h-4 w-4 mr-2" />
Назад
</Button>
<div>
<h1 className="text-3xl font-bold text-white mb-2">Товары оптовика</h1>
<p className="text-white/60">{selectedWholesaler.name} {mockProducts.length} товаров</p>
</div>
</div>
<div className="flex items-center space-x-3">
<Button
variant="ghost"
size="sm"
onClick={() => setShowSummary(!showSummary)}
className="text-white/60 hover:text-white hover:bg-white/10"
>
<Info className="h-4 w-4 mr-2" />
Резюме ({selectedProducts.length})
</Button>
</div>
</div>
{showSummary && selectedProducts.length > 0 && (
<Card className="bg-gradient-to-r from-purple-500/10 to-pink-500/10 backdrop-blur border-purple-500/30 p-8 mb-8">
<div className="flex items-center justify-between mb-6">
<h3 className="text-white font-semibold text-xl flex items-center">
<ShoppingBag className="h-5 w-5 mr-2" />
Резюме заказа
</h3>
<Badge className="bg-purple-500/20 text-purple-300 border-purple-500/30">
{selectedProducts.length} товаров
</Badge>
</div>
<div className="space-y-4">
{selectedProducts.map((product) => {
const discountedPrice = product.discount
? product.price * (1 - product.discount / 100)
: product.price
return (
<div key={product.id} className="flex justify-between items-center bg-white/5 rounded-lg p-4">
<div className="flex items-center space-x-4">
<img
src={product.mainImage || '/api/placeholder/60/60'}
alt={product.name}
className="w-12 h-12 rounded-lg object-cover"
/>
<div>
<span className="text-white font-medium">{product.name}</span>
<div className="flex items-center space-x-2 mt-1">
<span className="text-white/60 text-sm">× {product.selectedQuantity}</span>
{product.discount && (
<Badge className="bg-red-500/20 text-red-300 border-red-500/30 text-xs">
-{product.discount}%
</Badge>
)}
</div>
</div>
</div>
<div className="text-right">
<span className="text-white font-bold">
{formatCurrency(discountedPrice * product.selectedQuantity)}
</span>
{product.discount && (
<div className="text-white/40 text-sm line-through">
{formatCurrency(product.price * product.selectedQuantity)}
</div>
)}
</div>
</div>
)
})}
<div className="border-t border-white/20 pt-6 flex justify-between items-center">
<span className="text-white font-semibold text-lg">
Итого: {getTotalItems()} товаров
</span>
<span className="text-white font-bold text-2xl">
{formatCurrency(getTotalAmount())}
</span>
</div>
<Button
className="w-full bg-gradient-to-r from-green-500 to-emerald-500 hover:from-green-600 hover:to-emerald-600 text-white text-lg py-6"
onClick={handleCreateSupply}
disabled={selectedProducts.length === 0}
>
<ShoppingCart className="h-5 w-5 mr-2" />
Создать поставку
</Button>
</div>
</Card>
)}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
{mockProducts.map((product) => {
const selectedQuantity = getSelectedQuantity(product.id)
const discountedPrice = product.discount
? product.price * (1 - product.discount / 100)
: product.price
return (
<Card key={product.id} className="bg-white/10 backdrop-blur border-white/20 overflow-hidden group hover:bg-white/15 hover:border-white/30 transition-all duration-300 hover:scale-105 hover:shadow-2xl">
<div className="aspect-square relative bg-white/5 overflow-hidden">
<img
src={product.mainImage || '/api/placeholder/400/400'}
alt={product.name}
className="w-full h-full object-cover group-hover:scale-110 transition-transform duration-500"
/>
{/* Badges в верхних углах */}
<div className="absolute top-3 left-3 flex flex-col space-y-2">
{product.isNew && (
<Badge className="bg-blue-500 text-white border-0 font-medium">
<Zap className="h-3 w-3 mr-1" />
NEW
</Badge>
)}
{product.isBestseller && (
<Badge className="bg-orange-500 text-white border-0 font-medium">
<Star className="h-3 w-3 mr-1" />
ХИТ
</Badge>
)}
{product.discount && (
<Badge className="bg-red-500 text-white border-0 font-bold">
-{product.discount}%
</Badge>
)}
</div>
{/* Количество в наличии */}
<div className="absolute top-3 right-3">
<Badge className={`${product.quantity > 50 ? 'bg-green-500/80' : product.quantity > 10 ? 'bg-yellow-500/80' : 'bg-red-500/80'} text-white border-0 backdrop-blur`}>
{product.quantity} шт
</Badge>
</div>
{/* Overlay с кнопками */}
<div className="absolute inset-0 bg-black/40 opacity-0 group-hover:opacity-100 transition-opacity duration-300 flex items-center justify-center">
<div className="flex space-x-2">
<Button size="sm" variant="secondary" className="bg-white/20 backdrop-blur text-white border-white/30 hover:bg-white/30">
<Eye className="h-4 w-4" />
</Button>
<Button size="sm" variant="secondary" className="bg-white/20 backdrop-blur text-white border-white/30 hover:bg-white/30">
<Heart className="h-4 w-4" />
</Button>
</div>
</div>
</div>
<div className="p-5 space-y-4">
{/* Заголовок и бренд */}
<div>
<div className="flex items-center justify-between mb-2">
{product.brand && (
<Badge className="bg-gray-500/20 text-gray-300 border-gray-500/30 text-xs">
{product.brand}
</Badge>
)}
<Badge className="bg-blue-500/20 text-blue-300 border-blue-500/30 text-xs">
{product.category}
</Badge>
</div>
<h3 className="text-white font-semibold text-lg mb-1 line-clamp-2 leading-tight">
{product.name}
</h3>
<p className="text-white/60 text-xs mb-2">
Артикул: {product.article}
</p>
</div>
{/* Описание */}
<p className="text-white/70 text-sm line-clamp-2 leading-relaxed">
{product.description}
</p>
{/* Характеристики */}
<div className="space-y-2">
{product.color && (
<div className="text-white/60 text-xs flex items-center">
<div className="w-3 h-3 rounded-full bg-gray-400 mr-2"></div>
Цвет: <span className="text-white ml-1">{product.color}</span>
</div>
)}
{product.size && (
<div className="text-white/60 text-xs">
Размер: <span className="text-white">{product.size}</span>
</div>
)}
{product.weight && (
<div className="text-white/60 text-xs">
Вес: <span className="text-white">{product.weight} г</span>
</div>
)}
</div>
{/* Цена */}
<div className="flex items-center justify-between pt-3 border-t border-white/10">
<div>
<div className="flex items-center space-x-2">
<div className="text-white font-bold text-xl">
{formatCurrency(discountedPrice)}
</div>
{product.discount && (
<div className="text-white/40 text-sm line-through">
{formatCurrency(product.price)}
</div>
)}
</div>
<div className="text-white/60 text-xs">за штуку</div>
</div>
</div>
{/* Управление количеством */}
<div className="flex items-center justify-between space-x-3">
<div className="flex items-center space-x-2 flex-1">
<Button
variant="ghost"
size="sm"
onClick={() => updateProductQuantity(product.id, Math.max(0, selectedQuantity - 1))}
disabled={selectedQuantity === 0}
className="h-9 w-9 p-0 text-white/60 hover:text-white hover:bg-white/10 border border-white/20"
>
<Minus className="h-4 w-4" />
</Button>
<Input
type="number"
value={selectedQuantity}
onChange={(e) => {
const value = Math.max(0, Math.min(product.quantity, parseInt(e.target.value) || 0))
updateProductQuantity(product.id, value)
}}
className="h-9 w-16 text-center bg-white/10 border-white/20 text-white text-sm"
min={0}
max={product.quantity}
/>
<Button
variant="ghost"
size="sm"
onClick={() => updateProductQuantity(product.id, Math.min(product.quantity, selectedQuantity + 1))}
disabled={selectedQuantity >= product.quantity}
className="h-9 w-9 p-0 text-white/60 hover:text-white hover:bg-white/10 border border-white/20"
>
<Plus className="h-4 w-4" />
</Button>
</div>
{selectedQuantity > 0 && (
<Button
size="sm"
className="bg-gradient-to-r from-purple-500 to-pink-500 hover:from-purple-600 hover:to-pink-600 text-white flex-shrink-0"
>
<ShoppingCart className="h-4 w-4 mr-1" />
{selectedQuantity}
</Button>
)}
</div>
{/* Сумма для выбранного товара */}
{selectedQuantity > 0 && (
<div className="bg-gradient-to-r from-green-500/20 to-emerald-500/20 border border-green-500/30 rounded-lg p-3">
<div className="text-green-300 text-sm font-medium text-center">
Сумма: {formatCurrency(discountedPrice * selectedQuantity)}
{product.discount && (
<span className="text-green-200 text-xs ml-2">
(экономия {formatCurrency((product.price - discountedPrice) * selectedQuantity)})
</span>
)}
</div>
</div>
)}
</div>
</Card>
)
})}
</div>
{/* Floating корзина */}
{selectedProducts.length > 0 && (
<div className="fixed bottom-6 right-6 z-50">
<Button
size="lg"
className="bg-gradient-to-r from-purple-500 to-pink-500 hover:from-purple-600 hover:to-pink-600 text-white shadow-2xl text-lg px-8 py-4"
onClick={() => setShowSummary(!showSummary)}
>
<ShoppingCart className="h-5 w-5 mr-3" />
Корзина ({selectedProducts.length}) {formatCurrency(getTotalAmount())}
</Button>
</div>
)}
</div>
</main>
</div>
)
}
// Рендер выбора оптовиков
if (selectedVariant === 'wholesaler') {
return (
<div className="min-h-screen bg-gradient-smooth flex">
<Sidebar />
<main className="flex-1 ml-56">
<div className="p-8">
<div className="flex items-center justify-between mb-8">
<div className="flex items-center space-x-4">
<Button
variant="ghost"
size="sm"
onClick={handleGoBack}
className="text-white/60 hover:text-white hover:bg-white/10"
>
<ArrowLeft className="h-4 w-4 mr-2" />
Назад
</Button>
<div>
<h1 className="text-3xl font-bold text-white mb-2">Выбор оптовика</h1>
<p className="text-white/60">Выберите оптовика для создания поставки</p>
</div>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{mockWholesalers.map((wholesaler) => (
<Card
key={wholesaler.id}
className="bg-white/10 backdrop-blur border-white/20 p-6 cursor-pointer transition-all hover:bg-white/15 hover:border-white/30 hover:scale-105"
onClick={() => setSelectedWholesaler(wholesaler)}
>
<div className="space-y-4">
<div className="flex items-start space-x-3">
<div className="p-3 bg-blue-500/20 rounded-lg">
<Building2 className="h-6 w-6 text-blue-400" />
</div>
<div className="flex-1 min-w-0">
<h3 className="text-white font-semibold text-lg mb-1 truncate">
{wholesaler.name}
</h3>
<p className="text-white/60 text-xs mb-2 truncate">
{wholesaler.fullName}
</p>
<div className="flex items-center space-x-1 mb-2">
{renderStars(wholesaler.rating)}
<span className="text-white/60 text-sm ml-2">{wholesaler.rating}</span>
</div>
</div>
</div>
<div className="space-y-2">
<div className="flex items-center space-x-2">
<MapPin className="h-4 w-4 text-gray-400" />
<span className="text-white/80 text-sm truncate">{wholesaler.address}</span>
</div>
{wholesaler.phone && (
<div className="flex items-center space-x-2">
<Phone className="h-4 w-4 text-gray-400" />
<span className="text-white/80 text-sm">{wholesaler.phone}</span>
</div>
)}
{wholesaler.email && (
<div className="flex items-center space-x-2">
<Mail className="h-4 w-4 text-gray-400" />
<span className="text-white/80 text-sm truncate">{wholesaler.email}</span>
</div>
)}
<div className="flex items-center space-x-2">
<Package className="h-4 w-4 text-gray-400" />
<span className="text-white/80 text-sm">{wholesaler.productCount} товаров</span>
</div>
</div>
<div className="space-y-2">
<p className="text-white/60 text-xs">Специализация:</p>
<div className="flex flex-wrap gap-1">
{wholesaler.specialization.map((spec, index) => (
<Badge
key={index}
className="bg-purple-500/20 text-purple-300 border-purple-500/30 text-xs"
>
{spec}
</Badge>
))}
</div>
</div>
<div className="pt-2 border-t border-white/10">
<p className="text-white/60 text-xs">ИНН: {wholesaler.inn}</p>
</div>
</div>
</Card>
))}
</div>
</div>
</main>
</div>
)
}
// Главная страница выбора варианта
return (
<div className="min-h-screen bg-gradient-smooth flex">
<Sidebar />
<main className="flex-1 ml-56">
<div className="p-8">
<div className="flex items-center justify-between mb-8">
<div className="flex items-center space-x-4">
<Button
variant="ghost"
size="sm"
onClick={() => router.push('/supplies')}
className="text-white/60 hover:text-white hover:bg-white/10"
>
<ArrowLeft className="h-4 w-4 mr-2" />
Назад к поставкам
</Button>
<div>
<h1 className="text-3xl font-bold text-white mb-2">Создание поставки</h1>
<p className="text-white/60">Выберите способ создания поставки</p>
</div>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-8">
<Card
className="bg-white/10 backdrop-blur border-white/20 p-8 cursor-pointer transition-all hover:bg-white/15 hover:border-white/30 hover:scale-105"
onClick={() => setSelectedVariant('cards')}
>
<div className="text-center space-y-6">
<div className="p-6 bg-blue-500/20 rounded-2xl w-fit mx-auto">
<ShoppingCart className="h-12 w-12 text-blue-400" />
</div>
<div>
<h3 className="text-2xl font-semibold text-white mb-3">Карточки</h3>
<p className="text-white/60">
Создание поставки через выбор товаров по карточкам
</p>
</div>
<Badge className="bg-yellow-500/20 text-yellow-300 border-yellow-500/30 text-lg px-4 py-2">
В разработке
</Badge>
</div>
</Card>
<Card
className="bg-white/10 backdrop-blur border-white/20 p-8 cursor-pointer transition-all hover:bg-white/15 hover:border-white/30 hover:scale-105"
onClick={() => setSelectedVariant('wholesaler')}
>
<div className="text-center space-y-6">
<div className="p-6 bg-green-500/20 rounded-2xl w-fit mx-auto">
<Users className="h-12 w-12 text-green-400" />
</div>
<div>
<h3 className="text-2xl font-semibold text-white mb-3">Оптовик</h3>
<p className="text-white/60">
Создание поставки через выбор товаров у оптовиков
</p>
</div>
<Badge className="bg-green-500/20 text-green-300 border-green-500/30 text-lg px-4 py-2">
Доступно
</Badge>
</div>
</Card>
</div>
</div>
</main>
</div>
)
}

View File

@ -0,0 +1,724 @@
"use client"
import React, { useState } from 'react'
import { Card } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { Sidebar } from '@/components/dashboard/sidebar'
import {
ChevronDown,
ChevronRight,
Plus,
Calendar,
Package,
MapPin,
Building2,
TrendingUp,
AlertTriangle,
DollarSign
} from 'lucide-react'
// Типы данных для 5-уровневой структуры
interface ProductParameter {
id: string
name: string
value: string
unit?: string
}
interface Product {
id: string
name: string
sku: string
category: string
plannedQty: number
actualQty: number
defectQty: number
productPrice: number
parameters: ProductParameter[]
}
interface Wholesaler {
id: string
name: string
inn: string
contact: string
address: string
products: Product[]
totalAmount: number
}
interface Route {
id: string
from: string
fromAddress: string
to: string
toAddress: string
wholesalers: Wholesaler[]
totalProductPrice: number
fulfillmentServicePrice: number
logisticsPrice: number
totalAmount: number
}
interface Supply {
id: string
number: number
deliveryDate: string
createdDate: string
routes: Route[]
plannedTotal: number
actualTotal: number
defectTotal: number
totalProductPrice: number
totalFulfillmentPrice: number
totalLogisticsPrice: number
grandTotal: number
status: 'planned' | 'in-transit' | 'delivered' | 'completed'
}
// Моковые данные для 5-уровневой структуры
const mockSupplies: Supply[] = [
{
id: '1',
number: 1,
deliveryDate: '2024-01-15',
createdDate: '2024-01-10',
status: 'delivered',
plannedTotal: 180,
actualTotal: 173,
defectTotal: 2,
totalProductPrice: 3750000,
totalFulfillmentPrice: 43000,
totalLogisticsPrice: 27000,
grandTotal: 3820000,
routes: [
{
id: 'r1',
from: 'Садовод',
fromAddress: 'Москва, 14-й км МКАД',
to: 'SFERAV Logistics',
toAddress: 'Москва, ул. Складская, 15',
totalProductPrice: 3600000,
fulfillmentServicePrice: 25000,
logisticsPrice: 15000,
totalAmount: 3640000,
wholesalers: [
{
id: 'w1',
name: 'ООО "ТехноСнаб"',
inn: '7701234567',
contact: '+7 (495) 123-45-67',
address: 'Москва, ул. Торговая, 1',
totalAmount: 3600000,
products: [
{
id: 'p1',
name: 'Смартфон iPhone 15',
sku: 'APL-IP15-128',
category: 'Электроника',
plannedQty: 50,
actualQty: 48,
defectQty: 2,
productPrice: 75000,
parameters: [
{ id: 'param1', name: 'Цвет', value: 'Черный' },
{ id: 'param2', name: 'Память', value: '128', unit: 'ГБ' },
{ id: 'param3', name: 'Гарантия', value: '12', unit: 'мес' }
]
}
]
}
]
},
{
id: 'r2',
from: 'ТЯК Москва',
fromAddress: 'Москва, Алтуфьевское шоссе, 27',
to: 'MegaFulfillment',
toAddress: 'Подольск, ул. Индустриальная, 42',
totalProductPrice: 150000,
fulfillmentServicePrice: 18000,
logisticsPrice: 12000,
totalAmount: 180000,
wholesalers: [
{
id: 'w2',
name: 'ИП Петров А.В.',
inn: '123456789012',
contact: '+7 (499) 987-65-43',
address: 'Москва, пр-т Мира, 45',
totalAmount: 150000,
products: [
{
id: 'p2',
name: 'Чехол для iPhone 15',
sku: 'ACC-IP15-CASE',
category: 'Аксессуары',
plannedQty: 100,
actualQty: 95,
defectQty: 0,
productPrice: 1500,
parameters: [
{ id: 'param4', name: 'Материал', value: 'Силикон' },
{ id: 'param5', name: 'Цвет', value: 'Прозрачный' }
]
}
]
}
]
}
]
},
{
id: '2',
number: 2,
deliveryDate: '2024-01-20',
createdDate: '2024-01-12',
status: 'in-transit',
plannedTotal: 30,
actualTotal: 30,
defectTotal: 0,
totalProductPrice: 750000,
totalFulfillmentPrice: 18000,
totalLogisticsPrice: 12000,
grandTotal: 780000,
routes: [
{
id: 'r3',
from: 'Садовод',
fromAddress: 'Москва, 14-й км МКАД',
to: 'WB Подольск',
toAddress: 'Подольск, ул. Складская, 25',
totalProductPrice: 750000,
fulfillmentServicePrice: 18000,
logisticsPrice: 12000,
totalAmount: 780000,
wholesalers: [
{
id: 'w3',
name: 'ООО "АудиоТех"',
inn: '7702345678',
contact: '+7 (495) 555-12-34',
address: 'Москва, ул. Звуковая, 8',
totalAmount: 750000,
products: [
{
id: 'p3',
name: 'Наушники AirPods Pro',
sku: 'APL-AP-PRO2',
category: 'Аудио',
plannedQty: 30,
actualQty: 30,
defectQty: 0,
productPrice: 25000,
parameters: [
{ id: 'param6', name: 'Тип', value: 'Беспроводные' },
{ id: 'param7', name: 'Шумоподавление', value: 'Активное' },
{ id: 'param8', name: 'Время работы', value: '6', unit: 'ч' }
]
}
]
}
]
}
]
}
]
export function SuppliesDashboard() {
const [expandedSupplies, setExpandedSupplies] = useState<Set<string>>(new Set())
const [expandedRoutes, setExpandedRoutes] = useState<Set<string>>(new Set())
const [expandedWholesalers, setExpandedWholesalers] = useState<Set<string>>(new Set())
const [expandedProducts, setExpandedProducts] = useState<Set<string>>(new Set())
const toggleSupplyExpansion = (supplyId: string) => {
const newExpanded = new Set(expandedSupplies)
if (newExpanded.has(supplyId)) {
newExpanded.delete(supplyId)
} else {
newExpanded.add(supplyId)
}
setExpandedSupplies(newExpanded)
}
const toggleRouteExpansion = (routeId: string) => {
const newExpanded = new Set(expandedRoutes)
if (newExpanded.has(routeId)) {
newExpanded.delete(routeId)
} else {
newExpanded.add(routeId)
}
setExpandedRoutes(newExpanded)
}
const toggleWholesalerExpansion = (wholesalerId: string) => {
const newExpanded = new Set(expandedWholesalers)
if (newExpanded.has(wholesalerId)) {
newExpanded.delete(wholesalerId)
} else {
newExpanded.add(wholesalerId)
}
setExpandedWholesalers(newExpanded)
}
const toggleProductExpansion = (productId: string) => {
const newExpanded = new Set(expandedProducts)
if (newExpanded.has(productId)) {
newExpanded.delete(productId)
} else {
newExpanded.add(productId)
}
setExpandedProducts(newExpanded)
}
const getStatusBadge = (status: Supply['status']) => {
const statusMap = {
planned: { label: 'Запланирована', color: 'bg-blue-500/20 text-blue-300 border-blue-500/30' },
'in-transit': { label: 'В пути', color: 'bg-yellow-500/20 text-yellow-300 border-yellow-500/30' },
delivered: { label: 'Доставлена', color: 'bg-green-500/20 text-green-300 border-green-500/30' },
completed: { label: 'Завершена', color: 'bg-purple-500/20 text-purple-300 border-purple-500/30' }
}
const { label, color } = statusMap[status]
return <Badge className={`${color} border`}>{label}</Badge>
}
const formatCurrency = (amount: number) => {
return new Intl.NumberFormat('ru-RU', {
style: 'currency',
currency: 'RUB',
minimumFractionDigits: 0
}).format(amount)
}
const formatDate = (dateString: string) => {
return new Date(dateString).toLocaleDateString('ru-RU', {
day: '2-digit',
month: '2-digit',
year: 'numeric'
})
}
const calculateProductTotal = (product: Product) => {
return product.actualQty * product.productPrice
}
const getEfficiencyBadge = (planned: number, actual: number, defect: number) => {
const efficiency = ((actual - defect) / planned) * 100
if (efficiency >= 95) {
return <Badge className="bg-green-500/20 text-green-300 border-green-500/30 border">Отлично</Badge>
} else if (efficiency >= 90) {
return <Badge className="bg-yellow-500/20 text-yellow-300 border-yellow-500/30 border">Хорошо</Badge>
} else {
return <Badge className="bg-red-500/20 text-red-300 border-red-500/30 border">Проблемы</Badge>
}
}
return (
<div className="min-h-screen bg-gradient-smooth flex">
<Sidebar />
<main className="flex-1 ml-56">
<div className="p-8">
{/* Заголовок */}
<div className="flex items-center justify-between mb-8">
<div>
<h1 className="text-3xl font-bold text-white mb-2">Поставки</h1>
<p className="text-white/60">Управление поставками товаров</p>
</div>
<Button
className="bg-gradient-to-r from-purple-500 to-pink-500 hover:from-purple-600 hover:to-pink-600 text-white shadow-lg"
onClick={() => {
window.location.href = '/supplies/create'
}}
>
<Plus className="h-4 w-4 mr-2" />
Создать поставку
</Button>
</div>
{/* Статистика */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-6 mb-8">
<Card className="bg-white/10 backdrop-blur border-white/20 p-6">
<div className="flex items-center space-x-3">
<div className="p-3 bg-blue-500/20 rounded-lg">
<Package className="h-6 w-6 text-blue-400" />
</div>
<div>
<p className="text-white/60 text-sm">Всего поставок</p>
<p className="text-2xl font-bold text-white">{mockSupplies.length}</p>
</div>
</div>
</Card>
<Card className="bg-white/10 backdrop-blur border-white/20 p-6">
<div className="flex items-center space-x-3">
<div className="p-3 bg-green-500/20 rounded-lg">
<TrendingUp className="h-6 w-6 text-green-400" />
</div>
<div>
<p className="text-white/60 text-sm">Общая сумма</p>
<p className="text-2xl font-bold text-white">
{formatCurrency(mockSupplies.reduce((sum, supply) => sum + supply.grandTotal, 0))}
</p>
</div>
</div>
</Card>
<Card className="bg-white/10 backdrop-blur border-white/20 p-6">
<div className="flex items-center space-x-3">
<div className="p-3 bg-yellow-500/20 rounded-lg">
<Calendar className="h-6 w-6 text-yellow-400" />
</div>
<div>
<p className="text-white/60 text-sm">В пути</p>
<p className="text-2xl font-bold text-white">
{mockSupplies.filter(supply => supply.status === 'in-transit').length}
</p>
</div>
</div>
</Card>
<Card className="bg-white/10 backdrop-blur border-white/20 p-6">
<div className="flex items-center space-x-3">
<div className="p-3 bg-red-500/20 rounded-lg">
<AlertTriangle className="h-6 w-6 text-red-400" />
</div>
<div>
<p className="text-white/60 text-sm">С браком</p>
<p className="text-2xl font-bold text-white">
{mockSupplies.filter(supply => supply.defectTotal > 0).length}
</p>
</div>
</div>
</Card>
</div>
{/* Многоуровневая таблица поставок */}
<Card className="bg-white/10 backdrop-blur border-white/20 overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full">
<thead>
<tr className="border-b border-white/20">
<th className="text-left p-4 text-white font-semibold"></th>
<th className="text-left p-4 text-white font-semibold">Дата поставки</th>
<th className="text-left p-4 text-white font-semibold">Дата создания</th>
<th className="text-left p-4 text-white font-semibold">План</th>
<th className="text-left p-4 text-white font-semibold">Факт</th>
<th className="text-left p-4 text-white font-semibold">Брак</th>
<th className="text-left p-4 text-white font-semibold">Цена товаров</th>
<th className="text-left p-4 text-white font-semibold">Услуги ФФ</th>
<th className="text-left p-4 text-white font-semibold">Логистика до ФФ</th>
<th className="text-left p-4 text-white font-semibold">Итого сумма</th>
<th className="text-left p-4 text-white font-semibold">Статус</th>
</tr>
</thead>
<tbody>
{mockSupplies.map((supply) => {
const isSupplyExpanded = expandedSupplies.has(supply.id)
return (
<React.Fragment key={supply.id}>
{/* Уровень 1: Основная строка поставки */}
<tr className="border-b border-white/10 hover:bg-white/5 transition-colors bg-purple-500/10">
<td className="p-4">
<div className="flex items-center space-x-2">
<Button
variant="ghost"
size="sm"
onClick={() => toggleSupplyExpansion(supply.id)}
className="h-6 w-6 p-0 text-white/60 hover:text-white hover:bg-white/10"
>
{isSupplyExpanded ? (
<ChevronDown className="h-4 w-4" />
) : (
<ChevronRight className="h-4 w-4" />
)}
</Button>
<span className="text-white font-bold text-lg">#{supply.number}</span>
</div>
</td>
<td className="p-4">
<div className="flex items-center space-x-2">
<Calendar className="h-4 w-4 text-white/40" />
<span className="text-white font-semibold">{formatDate(supply.deliveryDate)}</span>
</div>
</td>
<td className="p-4">
<span className="text-white/80">{formatDate(supply.createdDate)}</span>
</td>
<td className="p-4">
<span className="text-white font-semibold">{supply.plannedTotal}</span>
</td>
<td className="p-4">
<span className="text-white font-semibold">{supply.actualTotal}</span>
</td>
<td className="p-4">
<span className={`font-semibold ${supply.defectTotal > 0 ? 'text-red-400' : 'text-white'}`}>
{supply.defectTotal}
</span>
</td>
<td className="p-4">
<span className="text-green-400 font-semibold">{formatCurrency(supply.totalProductPrice)}</span>
</td>
<td className="p-4">
<span className="text-blue-400 font-semibold">{formatCurrency(supply.totalFulfillmentPrice)}</span>
</td>
<td className="p-4">
<span className="text-purple-400 font-semibold">{formatCurrency(supply.totalLogisticsPrice)}</span>
</td>
<td className="p-4">
<div className="flex items-center space-x-2">
<DollarSign className="h-4 w-4 text-white/40" />
<span className="text-white font-bold text-lg">{formatCurrency(supply.grandTotal)}</span>
</div>
</td>
<td className="p-4">
{getStatusBadge(supply.status)}
</td>
</tr>
{/* Уровень 2: Маршруты */}
{isSupplyExpanded && supply.routes.map((route) => {
const isRouteExpanded = expandedRoutes.has(route.id)
return (
<React.Fragment key={route.id}>
<tr className="border-b border-white/10 hover:bg-white/5 transition-colors bg-blue-500/10">
<td className="p-4 pl-12">
<div className="flex items-center space-x-2">
<Button
variant="ghost"
size="sm"
onClick={() => toggleRouteExpansion(route.id)}
className="h-6 w-6 p-0 text-white/60 hover:text-white hover:bg-white/10"
>
{isRouteExpanded ? (
<ChevronDown className="h-4 w-4" />
) : (
<ChevronRight className="h-4 w-4" />
)}
</Button>
<MapPin className="h-4 w-4 text-blue-400" />
<span className="text-white font-medium">Маршрут</span>
</div>
</td>
<td className="p-4" colSpan={2}>
<div className="text-white">
<div className="flex items-center space-x-2 mb-1">
<span className="font-medium">{route.from}</span>
<span className="text-white/60"></span>
<span className="font-medium">{route.to}</span>
</div>
<div className="text-xs text-white/60">{route.fromAddress} {route.toAddress}</div>
</div>
</td>
<td className="p-4">
<span className="text-white/80">
{route.wholesalers.reduce((sum, w) =>
sum + w.products.reduce((pSum, p) => pSum + p.plannedQty, 0), 0
)}
</span>
</td>
<td className="p-4">
<span className="text-white/80">
{route.wholesalers.reduce((sum, w) =>
sum + w.products.reduce((pSum, p) => pSum + p.actualQty, 0), 0
)}
</span>
</td>
<td className="p-4">
<span className="text-white/80">
{route.wholesalers.reduce((sum, w) =>
sum + w.products.reduce((pSum, p) => pSum + p.defectQty, 0), 0
)}
</span>
</td>
<td className="p-4">
<span className="text-green-400 font-medium">{formatCurrency(route.totalProductPrice)}</span>
</td>
<td className="p-4">
<span className="text-blue-400 font-medium">{formatCurrency(route.fulfillmentServicePrice)}</span>
</td>
<td className="p-4">
<span className="text-purple-400 font-medium">{formatCurrency(route.logisticsPrice)}</span>
</td>
<td className="p-4">
<span className="text-white font-semibold">{formatCurrency(route.totalAmount)}</span>
</td>
<td className="p-4"></td>
</tr>
{/* Уровень 3: Оптовики */}
{isRouteExpanded && route.wholesalers.map((wholesaler) => {
const isWholesalerExpanded = expandedWholesalers.has(wholesaler.id)
return (
<React.Fragment key={wholesaler.id}>
<tr className="border-b border-white/10 hover:bg-white/5 transition-colors bg-green-500/10">
<td className="p-4 pl-20">
<div className="flex items-center space-x-2">
<Button
variant="ghost"
size="sm"
onClick={() => toggleWholesalerExpansion(wholesaler.id)}
className="h-6 w-6 p-0 text-white/60 hover:text-white hover:bg-white/10"
>
{isWholesalerExpanded ? (
<ChevronDown className="h-4 w-4" />
) : (
<ChevronRight className="h-4 w-4" />
)}
</Button>
<Building2 className="h-4 w-4 text-green-400" />
<span className="text-white font-medium">Оптовик</span>
</div>
</td>
<td className="p-4" colSpan={2}>
<div className="text-white">
<div className="font-medium mb-1">{wholesaler.name}</div>
<div className="text-xs text-white/60 mb-1">ИНН: {wholesaler.inn}</div>
<div className="text-xs text-white/60 mb-1">{wholesaler.address}</div>
<div className="text-xs text-white/60">{wholesaler.contact}</div>
</div>
</td>
<td className="p-4">
<span className="text-white/80">
{wholesaler.products.reduce((sum, p) => sum + p.plannedQty, 0)}
</span>
</td>
<td className="p-4">
<span className="text-white/80">
{wholesaler.products.reduce((sum, p) => sum + p.actualQty, 0)}
</span>
</td>
<td className="p-4">
<span className="text-white/80">
{wholesaler.products.reduce((sum, p) => sum + p.defectQty, 0)}
</span>
</td>
<td className="p-4">
<span className="text-green-400 font-medium">
{formatCurrency(wholesaler.products.reduce((sum, p) => sum + calculateProductTotal(p), 0))}
</span>
</td>
<td className="p-4" colSpan={2}></td>
<td className="p-4">
<span className="text-white font-semibold">{formatCurrency(wholesaler.totalAmount)}</span>
</td>
<td className="p-4"></td>
</tr>
{/* Уровень 4: Товары */}
{isWholesalerExpanded && wholesaler.products.map((product) => {
const isProductExpanded = expandedProducts.has(product.id)
return (
<React.Fragment key={product.id}>
<tr className="border-b border-white/10 hover:bg-white/5 transition-colors bg-yellow-500/10">
<td className="p-4 pl-28">
<div className="flex items-center space-x-2">
<Button
variant="ghost"
size="sm"
onClick={() => toggleProductExpansion(product.id)}
className="h-6 w-6 p-0 text-white/60 hover:text-white hover:bg-white/10"
>
{isProductExpanded ? (
<ChevronDown className="h-4 w-4" />
) : (
<ChevronRight className="h-4 w-4" />
)}
</Button>
<Package className="h-4 w-4 text-yellow-400" />
<span className="text-white font-medium">Товар</span>
</div>
</td>
<td className="p-4" colSpan={2}>
<div className="text-white">
<div className="font-medium mb-1">{product.name}</div>
<div className="text-xs text-white/60 mb-1">Артикул: {product.sku}</div>
<Badge className="bg-gray-500/20 text-gray-300 border-gray-500/30 border text-xs">
{product.category}
</Badge>
</div>
</td>
<td className="p-4">
<span className="text-white font-semibold">{product.plannedQty}</span>
</td>
<td className="p-4">
<span className="text-white font-semibold">{product.actualQty}</span>
</td>
<td className="p-4">
<span className={`font-semibold ${product.defectQty > 0 ? 'text-red-400' : 'text-white'}`}>
{product.defectQty}
</span>
</td>
<td className="p-4">
<div className="text-white">
<div className="font-medium">{formatCurrency(calculateProductTotal(product))}</div>
<div className="text-xs text-white/60">{formatCurrency(product.productPrice)} за шт.</div>
</div>
</td>
<td className="p-4" colSpan={2}>
{getEfficiencyBadge(product.plannedQty, product.actualQty, product.defectQty)}
</td>
<td className="p-4">
<span className="text-white font-semibold">{formatCurrency(calculateProductTotal(product))}</span>
</td>
<td className="p-4"></td>
</tr>
{/* Уровень 5: Параметры товара */}
{isProductExpanded && (
<tr>
<td colSpan={11} className="p-0">
<div className="bg-white/5 border-t border-white/10">
<div className="p-4 pl-36">
<h4 className="text-white font-medium mb-3 flex items-center space-x-2">
<span className="text-xs text-white/60">📋 Параметры товара:</span>
</h4>
<div className="grid grid-cols-3 gap-4">
{product.parameters.map((param) => (
<div key={param.id} className="bg-white/5 rounded-lg p-3">
<div className="text-white/80 text-xs font-medium mb-1">{param.name}</div>
<div className="text-white text-sm">
{param.value} {param.unit || ''}
</div>
</div>
))}
</div>
</div>
</div>
</td>
</tr>
)}
</React.Fragment>
)
})}
</React.Fragment>
)
})}
</React.Fragment>
)
})}
</React.Fragment>
)
})}
</tbody>
</table>
</div>
</Card>
</div>
</main>
</div>
)
}

View File

@ -0,0 +1,425 @@
"use client"
import React, { useState } from 'react'
import { Card } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { Input } from '@/components/ui/input'
import {
ArrowLeft,
Package,
Plus,
Minus,
ShoppingCart,
Eye,
Info
} from 'lucide-react'
import Image from 'next/image'
interface Wholesaler {
id: string
inn: string
name: string
fullName: string
address: string
phone?: string
email?: string
rating: number
productCount: number
avatar?: string
specialization: string[]
}
interface Product {
id: string
name: string
article: string
description: string
price: number
quantity: number
category: string
brand?: string
color?: string
size?: string
weight?: number
dimensions?: string
material?: string
images: string[]
mainImage?: string
}
interface SelectedProduct extends Product {
selectedQuantity: number
}
interface WholesalerProductsProps {
wholesaler: Wholesaler
onBack: () => void
onClose: () => void
onSupplyCreated: () => void
}
// Моковые данные товаров
const mockProducts: Product[] = [
{
id: '1',
name: 'Смартфон Samsung Galaxy A54',
article: 'SGX-A54-128',
description: 'Смартфон с экраном 6.4", камерой 50 МП, 128 ГБ памяти',
price: 28900,
quantity: 150,
category: 'Смартфоны',
brand: 'Samsung',
color: 'Черный',
size: '6.4"',
weight: 202,
dimensions: '158.2 x 76.7 x 8.2 мм',
material: 'Алюминий, стекло',
images: ['/api/placeholder/300/300?text=Samsung+A54'],
mainImage: '/api/placeholder/300/300?text=Samsung+A54'
},
{
id: '2',
name: 'Наушники Sony WH-1000XM4',
article: 'SNY-WH1000XM4',
description: 'Беспроводные наушники с шумоподавлением',
price: 24900,
quantity: 85,
category: 'Наушники',
brand: 'Sony',
color: 'Черный',
weight: 254,
material: 'Пластик, кожа',
images: ['/api/placeholder/300/300?text=Sony+WH1000XM4'],
mainImage: '/api/placeholder/300/300?text=Sony+WH1000XM4'
},
{
id: '3',
name: 'Планшет iPad Air 10.9"',
article: 'APL-IPADAIR-64',
description: 'Планшет Apple iPad Air с чипом M1, 64 ГБ',
price: 54900,
quantity: 45,
category: 'Планшеты',
brand: 'Apple',
color: 'Серый космос',
size: '10.9"',
weight: 461,
dimensions: '247.6 x 178.5 x 6.1 мм',
material: 'Алюминий',
images: ['/api/placeholder/300/300?text=iPad+Air'],
mainImage: '/api/placeholder/300/300?text=iPad+Air'
},
{
id: '4',
name: 'Ноутбук Lenovo ThinkPad E15',
article: 'LNV-TE15-I5',
description: 'Ноутбук 15.6" Intel Core i5, 8 ГБ ОЗУ, 256 ГБ SSD',
price: 45900,
quantity: 25,
category: 'Ноутбуки',
brand: 'Lenovo',
color: 'Черный',
size: '15.6"',
weight: 1700,
dimensions: '365 x 240 x 19.9 мм',
material: 'Пластик',
images: ['/api/placeholder/300/300?text=ThinkPad+E15'],
mainImage: '/api/placeholder/300/300?text=ThinkPad+E15'
},
{
id: '5',
name: 'Умные часы Apple Watch SE',
article: 'APL-AWSE-40',
description: 'Умные часы Apple Watch SE 40 мм',
price: 21900,
quantity: 120,
category: 'Умные часы',
brand: 'Apple',
color: 'Белый',
size: '40 мм',
weight: 30,
dimensions: '40 x 34 x 10.7 мм',
material: 'Алюминий',
images: ['/api/placeholder/300/300?text=Apple+Watch+SE'],
mainImage: '/api/placeholder/300/300?text=Apple+Watch+SE'
},
{
id: '6',
name: 'Клавиатура Logitech MX Keys',
article: 'LGT-MXKEYS',
description: 'Беспроводная клавиатура для продуктивной работы',
price: 8900,
quantity: 75,
category: 'Клавиатуры',
brand: 'Logitech',
color: 'Графит',
weight: 810,
dimensions: '430.2 x 20.5 x 131.6 мм',
material: 'Пластик, металл',
images: ['/api/placeholder/300/300?text=MX+Keys'],
mainImage: '/api/placeholder/300/300?text=MX+Keys'
}
]
export function WholesalerProducts({ wholesaler, onBack, onClose, onSupplyCreated }: WholesalerProductsProps) {
const [selectedProducts, setSelectedProducts] = useState<SelectedProduct[]>([])
const [showSummary, setShowSummary] = useState(false)
const formatCurrency = (amount: number) => {
return new Intl.NumberFormat('ru-RU', {
style: 'currency',
currency: 'RUB',
minimumFractionDigits: 0
}).format(amount)
}
const updateProductQuantity = (productId: string, quantity: number) => {
const product = mockProducts.find(p => p.id === productId)
if (!product) return
setSelectedProducts(prev => {
const existing = prev.find(p => p.id === productId)
if (quantity === 0) {
// Удаляем продукт если количество 0
return prev.filter(p => p.id !== productId)
}
if (existing) {
// Обновляем количество существующего продукта
return prev.map(p =>
p.id === productId ? { ...p, selectedQuantity: quantity } : p
)
} else {
// Добавляем новый продукт
return [...prev, { ...product, selectedQuantity: quantity }]
}
})
}
const getSelectedQuantity = (productId: string): number => {
const selected = selectedProducts.find(p => p.id === productId)
return selected ? selected.selectedQuantity : 0
}
const getTotalAmount = () => {
return selectedProducts.reduce((sum, product) =>
sum + (product.price * product.selectedQuantity), 0
)
}
const getTotalItems = () => {
return selectedProducts.reduce((sum, product) => sum + product.selectedQuantity, 0)
}
const handleCreateSupply = () => {
console.log('Создание поставки с товарами:', selectedProducts)
// TODO: Здесь будет реальное создание поставки
onSupplyCreated()
}
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div className="flex items-center space-x-3">
<Button
variant="ghost"
size="sm"
onClick={onBack}
className="text-white/60 hover:text-white hover:bg-white/10"
>
<ArrowLeft className="h-4 w-4 mr-2" />
Назад
</Button>
<div>
<h2 className="text-2xl font-bold text-white mb-1">Товары оптовика</h2>
<p className="text-white/60">{wholesaler.name} {mockProducts.length} товаров</p>
</div>
</div>
<div className="flex items-center space-x-3">
<Button
variant="ghost"
size="sm"
onClick={() => setShowSummary(!showSummary)}
className="text-white/60 hover:text-white hover:bg-white/10"
>
<Info className="h-4 w-4 mr-2" />
Резюме ({selectedProducts.length})
</Button>
<Button
variant="ghost"
size="sm"
onClick={onClose}
className="text-white/60 hover:text-white hover:bg-white/10"
>
Отмена
</Button>
</div>
</div>
{showSummary && selectedProducts.length > 0 && (
<Card className="bg-purple-500/10 backdrop-blur border-purple-500/30 p-6">
<h3 className="text-white font-semibold text-lg mb-4">Резюме заказа</h3>
<div className="space-y-3">
{selectedProducts.map((product) => (
<div key={product.id} className="flex justify-between items-center">
<div>
<span className="text-white">{product.name}</span>
<span className="text-white/60 text-sm ml-2">× {product.selectedQuantity}</span>
</div>
<span className="text-white font-medium">
{formatCurrency(product.price * product.selectedQuantity)}
</span>
</div>
))}
<div className="border-t border-white/20 pt-3 flex justify-between items-center">
<span className="text-white font-semibold">
Итого: {getTotalItems()} товаров
</span>
<span className="text-white font-bold text-xl">
{formatCurrency(getTotalAmount())}
</span>
</div>
<Button
className="w-full bg-gradient-to-r from-green-500 to-emerald-500 hover:from-green-600 hover:to-emerald-600 text-white"
onClick={handleCreateSupply}
disabled={selectedProducts.length === 0}
>
<ShoppingCart className="h-4 w-4 mr-2" />
Создать поставку
</Button>
</div>
</Card>
)}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{mockProducts.map((product) => {
const selectedQuantity = getSelectedQuantity(product.id)
return (
<Card key={product.id} className="bg-white/10 backdrop-blur border-white/20 overflow-hidden">
<div className="aspect-square relative bg-white/5">
<Image
src={product.mainImage || '/api/placeholder/300/300'}
alt={product.name}
fill
className="object-cover"
/>
<div className="absolute top-2 right-2">
<Badge className="bg-green-500/20 text-green-300 border-green-500/30">
В наличии: {product.quantity}
</Badge>
</div>
</div>
<div className="p-4 space-y-3">
<div>
<h3 className="text-white font-semibold mb-1 line-clamp-2">
{product.name}
</h3>
<p className="text-white/60 text-xs mb-2">
Артикул: {product.article}
</p>
<div className="flex items-center space-x-2 mb-2">
<Badge className="bg-blue-500/20 text-blue-300 border-blue-500/30 text-xs">
{product.category}
</Badge>
{product.brand && (
<Badge className="bg-gray-500/20 text-gray-300 border-gray-500/30 text-xs">
{product.brand}
</Badge>
)}
</div>
</div>
<p className="text-white/60 text-sm line-clamp-2">
{product.description}
</p>
<div className="space-y-2">
{product.color && (
<div className="text-white/60 text-xs">
Цвет: <span className="text-white">{product.color}</span>
</div>
)}
{product.size && (
<div className="text-white/60 text-xs">
Размер: <span className="text-white">{product.size}</span>
</div>
)}
{product.weight && (
<div className="text-white/60 text-xs">
Вес: <span className="text-white">{product.weight} г</span>
</div>
)}
</div>
<div className="flex items-center justify-between pt-2 border-t border-white/10">
<div>
<div className="text-white font-bold text-lg">
{formatCurrency(product.price)}
</div>
<div className="text-white/60 text-xs">за штуку</div>
</div>
</div>
<div className="flex items-center space-x-2">
<Button
variant="ghost"
size="sm"
onClick={() => updateProductQuantity(product.id, Math.max(0, selectedQuantity - 1))}
disabled={selectedQuantity === 0}
className="h-8 w-8 p-0 text-white/60 hover:text-white hover:bg-white/10"
>
<Minus className="h-4 w-4" />
</Button>
<Input
type="number"
value={selectedQuantity}
onChange={(e) => {
const value = Math.max(0, Math.min(product.quantity, parseInt(e.target.value) || 0))
updateProductQuantity(product.id, value)
}}
className="h-8 w-16 text-center bg-white/10 border-white/20 text-white"
min={0}
max={product.quantity}
/>
<Button
variant="ghost"
size="sm"
onClick={() => updateProductQuantity(product.id, Math.min(product.quantity, selectedQuantity + 1))}
disabled={selectedQuantity >= product.quantity}
className="h-8 w-8 p-0 text-white/60 hover:text-white hover:bg-white/10"
>
<Plus className="h-4 w-4" />
</Button>
</div>
{selectedQuantity > 0 && (
<div className="bg-green-500/20 border border-green-500/30 rounded-lg p-2">
<div className="text-green-300 text-sm font-medium">
Сумма: {formatCurrency(product.price * selectedQuantity)}
</div>
</div>
)}
</div>
</Card>
)
})}
</div>
{selectedProducts.length > 0 && (
<div className="fixed bottom-6 right-6">
<Button
className="bg-gradient-to-r from-purple-500 to-pink-500 hover:from-purple-600 hover:to-pink-600 text-white shadow-lg"
onClick={() => setShowSummary(!showSummary)}
>
<ShoppingCart className="h-4 w-4 mr-2" />
Корзина ({selectedProducts.length}) {formatCurrency(getTotalAmount())}
</Button>
</div>
)}
</div>
)
}

View File

@ -0,0 +1,260 @@
"use client"
import React, { useState } from 'react'
import { Card } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import {
ArrowLeft,
Building2,
MapPin,
Phone,
Mail,
Package,
Star
} from 'lucide-react'
// import { WholesalerProducts } from './wholesaler-products'
interface Wholesaler {
id: string
inn: string
name: string
fullName: string
address: string
phone?: string
email?: string
rating: number
productCount: number
avatar?: string
specialization: string[]
}
interface WholesalerSelectionProps {
onBack: () => void
onClose: () => void
onSupplyCreated: () => void
}
// Моковые данные оптовиков
const mockWholesalers: Wholesaler[] = [
{
id: '1',
inn: '7707083893',
name: 'ОПТ-Электроника',
fullName: 'ООО "ОПТ-Электроника"',
address: 'г. Москва, ул. Садовая, д. 15',
phone: '+7 (495) 123-45-67',
email: 'opt@electronics.ru',
rating: 4.8,
productCount: 1250,
specialization: ['Электроника', 'Бытовая техника']
},
{
id: '2',
inn: '7707083894',
name: 'ТекстильМастер',
fullName: 'ООО "ТекстильМастер"',
address: 'г. Иваново, пр. Ленина, д. 42',
phone: '+7 (4932) 55-66-77',
email: 'sales@textilmaster.ru',
rating: 4.6,
productCount: 850,
specialization: ['Текстиль', 'Одежда', 'Домашний текстиль']
},
{
id: '3',
inn: '7707083895',
name: 'МетизКомплект',
fullName: 'ООО "МетизКомплект"',
address: 'г. Тула, ул. Металлургов, д. 8',
phone: '+7 (4872) 33-44-55',
email: 'info@metiz.ru',
rating: 4.9,
productCount: 2100,
specialization: ['Крепеж', 'Метизы', 'Инструменты']
},
{
id: '4',
inn: '7707083896',
name: 'ПродОпт',
fullName: 'ООО "ПродОпт"',
address: 'г. Краснодар, ул. Красная, д. 123',
phone: '+7 (861) 777-88-99',
email: 'order@prodopt.ru',
rating: 4.7,
productCount: 560,
specialization: ['Продукты питания', 'Напитки']
},
{
id: '5',
inn: '7707083897',
name: 'СтройМатериалы+',
fullName: 'ООО "СтройМатериалы+"',
address: 'г. Воронеж, пр. Революции, д. 67',
phone: '+7 (473) 222-33-44',
email: 'stroim@materials.ru',
rating: 4.5,
productCount: 1800,
specialization: ['Стройматериалы', 'Сантехника']
},
{
id: '6',
inn: '7707083898',
name: 'КосметикОпт',
fullName: 'ООО "КосметикОпт"',
address: 'г. Санкт-Петербург, Невский пр., д. 45',
phone: '+7 (812) 111-22-33',
email: 'beauty@cosmeticopt.ru',
rating: 4.4,
productCount: 920,
specialization: ['Косметика', 'Парфюмерия', 'Уход']
}
]
export function WholesalerSelection({ onBack, onClose, onSupplyCreated }: WholesalerSelectionProps) {
const [selectedWholesaler, setSelectedWholesaler] = useState<Wholesaler | null>(null)
if (selectedWholesaler) {
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div className="flex items-center space-x-3">
<Button
variant="ghost"
size="sm"
onClick={() => setSelectedWholesaler(null)}
className="text-white/60 hover:text-white hover:bg-white/10"
>
<ArrowLeft className="h-4 w-4 mr-2" />
Назад
</Button>
<div>
<h2 className="text-2xl font-bold text-white mb-1">Товары оптовика</h2>
<p className="text-white/60">{selectedWholesaler.name}</p>
</div>
</div>
</div>
<div className="text-center py-12">
<p className="text-white/60">Компонент товаров в разработке...</p>
</div>
</div>
)
}
const renderStars = (rating: number) => {
return Array.from({ length: 5 }, (_, i) => (
<Star
key={i}
className={`h-4 w-4 ${i < Math.floor(rating) ? 'text-yellow-400 fill-current' : 'text-gray-400'}`}
/>
))
}
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div className="flex items-center space-x-3">
<Button
variant="ghost"
size="sm"
onClick={onBack}
className="text-white/60 hover:text-white hover:bg-white/10"
>
<ArrowLeft className="h-4 w-4 mr-2" />
Назад
</Button>
<div>
<h2 className="text-2xl font-bold text-white mb-1">Выбор оптовика</h2>
<p className="text-white/60">Выберите оптовика для создания поставки</p>
</div>
</div>
<Button
variant="ghost"
size="sm"
onClick={onClose}
className="text-white/60 hover:text-white hover:bg-white/10"
>
Отмена
</Button>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{mockWholesalers.map((wholesaler) => (
<Card
key={wholesaler.id}
className="bg-white/10 backdrop-blur border-white/20 p-6 cursor-pointer transition-all hover:bg-white/15 hover:border-white/30 hover:scale-105"
onClick={() => setSelectedWholesaler(wholesaler)}
>
<div className="space-y-4">
{/* Заголовок карточки */}
<div className="flex items-start space-x-3">
<div className="p-3 bg-blue-500/20 rounded-lg">
<Building2 className="h-6 w-6 text-blue-400" />
</div>
<div className="flex-1 min-w-0">
<h3 className="text-white font-semibold text-lg mb-1 truncate">
{wholesaler.name}
</h3>
<p className="text-white/60 text-xs mb-2 truncate">
{wholesaler.fullName}
</p>
<div className="flex items-center space-x-1 mb-2">
{renderStars(wholesaler.rating)}
<span className="text-white/60 text-sm ml-2">{wholesaler.rating}</span>
</div>
</div>
</div>
{/* Информация */}
<div className="space-y-2">
<div className="flex items-center space-x-2">
<MapPin className="h-4 w-4 text-gray-400" />
<span className="text-white/80 text-sm truncate">{wholesaler.address}</span>
</div>
{wholesaler.phone && (
<div className="flex items-center space-x-2">
<Phone className="h-4 w-4 text-gray-400" />
<span className="text-white/80 text-sm">{wholesaler.phone}</span>
</div>
)}
{wholesaler.email && (
<div className="flex items-center space-x-2">
<Mail className="h-4 w-4 text-gray-400" />
<span className="text-white/80 text-sm truncate">{wholesaler.email}</span>
</div>
)}
<div className="flex items-center space-x-2">
<Package className="h-4 w-4 text-gray-400" />
<span className="text-white/80 text-sm">{wholesaler.productCount} товаров</span>
</div>
</div>
{/* Специализация */}
<div className="space-y-2">
<p className="text-white/60 text-xs">Специализация:</p>
<div className="flex flex-wrap gap-1">
{wholesaler.specialization.map((spec, index) => (
<Badge
key={index}
className="bg-purple-500/20 text-purple-300 border-purple-500/30 text-xs"
>
{spec}
</Badge>
))}
</div>
</div>
{/* ИНН */}
<div className="pt-2 border-t border-white/10">
<p className="text-white/60 text-xs">ИНН: {wholesaler.inn}</p>
</div>
</div>
</Card>
))}
</div>
</div>
)
}

View File

@ -602,7 +602,6 @@ export const CREATE_SUPPLY = gql`
name
description
price
quantity
imageUrl
createdAt
updatedAt
@ -621,7 +620,6 @@ export const UPDATE_SUPPLY = gql`
name
description
price
quantity
imageUrl
createdAt
updatedAt
@ -636,6 +634,51 @@ export const DELETE_SUPPLY = gql`
}
`
// Мутации для логистики
export const CREATE_LOGISTICS = gql`
mutation CreateLogistics($input: LogisticsInput!) {
createLogistics(input: $input) {
success
message
logistics {
id
fromLocation
toLocation
priceUnder1m3
priceOver1m3
description
createdAt
updatedAt
}
}
}
`
export const UPDATE_LOGISTICS = gql`
mutation UpdateLogistics($id: ID!, $input: LogisticsInput!) {
updateLogistics(id: $id, input: $input) {
success
message
logistics {
id
fromLocation
toLocation
priceUnder1m3
priceOver1m3
description
createdAt
updatedAt
}
}
}
`
export const DELETE_LOGISTICS = gql`
mutation DeleteLogistics($id: ID!) {
deleteLogistics(id: $id)
}
`
// Мутации для товаров оптовика
export const CREATE_PRODUCT = gql`
mutation CreateProduct($input: ProductInput!) {

View File

@ -69,7 +69,6 @@ export const GET_MY_SUPPLIES = gql`
name
description
price
quantity
imageUrl
createdAt
updatedAt
@ -77,6 +76,21 @@ export const GET_MY_SUPPLIES = gql`
}
`
export const GET_MY_LOGISTICS = gql`
query GetMyLogistics {
myLogistics {
id
fromLocation
toLocation
priceUnder1m3
priceOver1m3
description
createdAt
updatedAt
}
}
`
export const GET_MY_PRODUCTS = gql`
query GetMyProducts {
myProducts {

View File

@ -530,12 +530,31 @@ export const resolvers = {
throw new GraphQLError('У пользователя нет организации')
}
// Проверяем, что это фулфилмент центр
if (currentUser.organization.type !== 'FULFILLMENT') {
throw new GraphQLError('Расходники доступны только для фулфилмент центров')
return await prisma.supply.findMany({
where: { organizationId: currentUser.organization.id },
include: { organization: true },
orderBy: { createdAt: 'desc' }
})
},
// Логистика организации
myLogistics: async (_: unknown, __: unknown, context: Context) => {
if (!context.user) {
throw new GraphQLError('Требуется авторизация', {
extensions: { code: 'UNAUTHENTICATED' }
})
}
return await prisma.supply.findMany({
const currentUser = await prisma.user.findUnique({
where: { id: context.user.id },
include: { organization: true }
})
if (!currentUser?.organization) {
throw new GraphQLError('У пользователя нет организации')
}
return await prisma.logistics.findMany({
where: { organizationId: currentUser.organization.id },
include: { organization: true },
orderBy: { createdAt: 'desc' }
@ -2322,7 +2341,7 @@ export const resolvers = {
},
// Создать расходник
createSupply: async (_: unknown, args: { input: { name: string; description?: string; price: number; quantity: number; imageUrl?: string } }, context: Context) => {
createSupply: async (_: unknown, args: { input: { name: string; description?: string; price: number; imageUrl?: string } }, context: Context) => {
if (!context.user) {
throw new GraphQLError('Требуется авторизация', {
extensions: { code: 'UNAUTHENTICATED' }
@ -2349,7 +2368,7 @@ export const resolvers = {
name: args.input.name,
description: args.input.description,
price: args.input.price,
quantity: args.input.quantity,
quantity: 0, // Временно устанавливаем 0, так как поле убрано из интерфейса
imageUrl: args.input.imageUrl,
organizationId: currentUser.organization.id
},
@ -2371,7 +2390,7 @@ export const resolvers = {
},
// Обновить расходник
updateSupply: async (_: unknown, args: { id: string; input: { name: string; description?: string; price: number; quantity: number; imageUrl?: string } }, context: Context) => {
updateSupply: async (_: unknown, args: { id: string; input: { name: string; description?: string; price: number; imageUrl?: string } }, context: Context) => {
if (!context.user) {
throw new GraphQLError('Требуется авторизация', {
extensions: { code: 'UNAUTHENTICATED' }
@ -2406,7 +2425,7 @@ export const resolvers = {
name: args.input.name,
description: args.input.description,
price: args.input.price,
quantity: args.input.quantity,
quantity: 0, // Временно устанавливаем 0, так как поле убрано из интерфейса
imageUrl: args.input.imageUrl
},
include: { organization: true }
@ -3559,3 +3578,162 @@ export const resolvers = {
}
}
}
// Логистические мутации
const logisticsMutations = {
// Создать логистический маршрут
createLogistics: async (_: unknown, args: { input: { fromLocation: string; toLocation: string; priceUnder1m3: number; priceOver1m3: number; description?: string } }, context: Context) => {
if (!context.user) {
throw new GraphQLError('Требуется авторизация', {
extensions: { code: 'UNAUTHENTICATED' }
})
}
const currentUser = await prisma.user.findUnique({
where: { id: context.user.id },
include: { organization: true }
})
if (!currentUser?.organization) {
throw new GraphQLError('У пользователя нет организации')
}
try {
const logistics = await prisma.logistics.create({
data: {
fromLocation: args.input.fromLocation,
toLocation: args.input.toLocation,
priceUnder1m3: args.input.priceUnder1m3,
priceOver1m3: args.input.priceOver1m3,
description: args.input.description,
organizationId: currentUser.organization.id
},
include: {
organization: true
}
})
console.log('✅ Logistics created:', logistics.id)
return {
success: true,
message: 'Логистический маршрут создан',
logistics
}
} catch (error) {
console.error('❌ Error creating logistics:', error)
return {
success: false,
message: 'Ошибка при создании логистического маршрута'
}
}
},
// Обновить логистический маршрут
updateLogistics: async (_: unknown, args: { id: string; input: { fromLocation: string; toLocation: string; priceUnder1m3: number; priceOver1m3: number; description?: string } }, context: Context) => {
if (!context.user) {
throw new GraphQLError('Требуется авторизация', {
extensions: { code: 'UNAUTHENTICATED' }
})
}
const currentUser = await prisma.user.findUnique({
where: { id: context.user.id },
include: { organization: true }
})
if (!currentUser?.organization) {
throw new GraphQLError('У пользователя нет организации')
}
try {
// Проверяем, что маршрут принадлежит организации пользователя
const existingLogistics = await prisma.logistics.findFirst({
where: {
id: args.id,
organizationId: currentUser.organization.id
}
})
if (!existingLogistics) {
throw new GraphQLError('Логистический маршрут не найден')
}
const logistics = await prisma.logistics.update({
where: { id: args.id },
data: {
fromLocation: args.input.fromLocation,
toLocation: args.input.toLocation,
priceUnder1m3: args.input.priceUnder1m3,
priceOver1m3: args.input.priceOver1m3,
description: args.input.description
},
include: {
organization: true
}
})
console.log('✅ Logistics updated:', logistics.id)
return {
success: true,
message: 'Логистический маршрут обновлен',
logistics
}
} catch (error) {
console.error('❌ Error updating logistics:', error)
return {
success: false,
message: 'Ошибка при обновлении логистического маршрута'
}
}
},
// Удалить логистический маршрут
deleteLogistics: async (_: unknown, args: { id: string }, context: Context) => {
if (!context.user) {
throw new GraphQLError('Требуется авторизация', {
extensions: { code: 'UNAUTHENTICATED' }
})
}
const currentUser = await prisma.user.findUnique({
where: { id: context.user.id },
include: { organization: true }
})
if (!currentUser?.organization) {
throw new GraphQLError('У пользователя нет организации')
}
try {
// Проверяем, что маршрут принадлежит организации пользователя
const existingLogistics = await prisma.logistics.findFirst({
where: {
id: args.id,
organizationId: currentUser.organization.id
}
})
if (!existingLogistics) {
throw new GraphQLError('Логистический маршрут не найден')
}
await prisma.logistics.delete({
where: { id: args.id }
})
console.log('✅ Logistics deleted:', args.id)
return true
} catch (error) {
console.error('❌ Error deleting logistics:', error)
return false
}
}
}
// Добавляем логистические мутации к основным резолверам
resolvers.Mutation = {
...resolvers.Mutation,
...logisticsMutations
}

View File

@ -29,6 +29,9 @@ export const typeDefs = gql`
# Расходники организации
mySupplies: [Supply!]!
# Логистика организации
myLogistics: [Logistics!]!
# Товары оптовика
myProducts: [Product!]!
@ -100,6 +103,11 @@ export const typeDefs = gql`
updateSupply(id: ID!, input: SupplyInput!): SupplyResponse!
deleteSupply(id: ID!): Boolean!
# Работа с логистикой
createLogistics(input: LogisticsInput!): LogisticsResponse!
updateLogistics(id: ID!, input: LogisticsInput!): LogisticsResponse!
deleteLogistics(id: ID!): Boolean!
# Работа с товарами (для оптовиков)
createProduct(input: ProductInput!): ProductResponse!
updateProduct(id: ID!, input: ProductInput!): ProductResponse!
@ -372,7 +380,6 @@ export const typeDefs = gql`
name: String!
description: String
price: Float!
quantity: Int!
imageUrl: String
createdAt: String!
updatedAt: String!
@ -383,7 +390,6 @@ export const typeDefs = gql`
name: String!
description: String
price: Float!
quantity: Int!
imageUrl: String
}
@ -393,6 +399,33 @@ export const typeDefs = gql`
supply: Supply
}
# Типы для логистики
type Logistics {
id: ID!
fromLocation: String!
toLocation: String!
priceUnder1m3: Float!
priceOver1m3: Float!
description: String
createdAt: String!
updatedAt: String!
organization: Organization!
}
input LogisticsInput {
fromLocation: String!
toLocation: String!
priceUnder1m3: Float!
priceOver1m3: Float!
description: String
}
type LogisticsResponse {
success: Boolean!
message: String!
logistics: Logistics
}
# Типы для категорий товаров
type Category {
id: ID!

111
src/lib/input-masks.ts Normal file
View File

@ -0,0 +1,111 @@
// Утилиты для масок ввода в формах сотрудников
// Маска для телефона в формате +7 (999) 123-45-67
export const formatPhoneInput = (value: string): string => {
const cleaned = value.replace(/\D/g, '')
if (cleaned.length === 0) return ''
if (cleaned.length <= 1) return cleaned.startsWith('7') ? '+7' : cleaned
if (cleaned.length <= 4) return `+7 (${cleaned.slice(1)}`
if (cleaned.length <= 7) return `+7 (${cleaned.slice(1, 4)}) ${cleaned.slice(4)}`
if (cleaned.length <= 9) return `+7 (${cleaned.slice(1, 4)}) ${cleaned.slice(4, 7)}-${cleaned.slice(7)}`
return `+7 (${cleaned.slice(1, 4)}) ${cleaned.slice(4, 7)}-${cleaned.slice(7, 9)}-${cleaned.slice(9, 11)}`
}
// Маска для серии паспорта (4 цифры)
export const formatPassportSeries = (value: string): string => {
const cleaned = value.replace(/\D/g, '')
return cleaned.slice(0, 4)
}
// Маска для номера паспорта (6 цифр)
export const formatPassportNumber = (value: string): string => {
const cleaned = value.replace(/\D/g, '')
return cleaned.slice(0, 6)
}
// Маска для зарплаты с разделителями тысяч
export const formatSalary = (value: string): string => {
const cleaned = value.replace(/\D/g, '')
if (!cleaned) return ''
return parseInt(cleaned).toLocaleString('ru-RU')
}
// Маска для имени, фамилии, отчества (только буквы, пробелы, дефисы)
export const formatNameInput = (value: string): string => {
return value.replace(/[^а-яёА-ЯЁa-zA-Z\s-]/g, '')
}
// Валидация email
export const isValidEmail = (email: string): boolean => {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
return emailRegex.test(email)
}
// Валидация телефона
export const isValidPhone = (phone: string): boolean => {
const phoneRegex = /^\+7\s\(\d{3}\)\s\d{3}-\d{2}-\d{2}$/
return phoneRegex.test(phone)
}
// Валидация серии паспорта
export const isValidPassportSeries = (series: string): boolean => {
return /^\d{4}$/.test(series)
}
// Валидация номера паспорта
export const isValidPassportNumber = (number: string): boolean => {
return /^\d{6}$/.test(number)
}
// Валидация возраста
export const isValidBirthDate = (birthDate: string): { valid: boolean; message?: string } => {
if (!birthDate) return { valid: true }
const birth = new Date(birthDate)
const today = new Date()
const age = today.getFullYear() - birth.getFullYear()
if (birth > today) {
return { valid: false, message: 'Дата рождения не может быть в будущем' }
}
if (age < 14) {
return { valid: false, message: 'Возраст должен быть не менее 14 лет' }
}
if (age > 100) {
return { valid: false, message: 'Проверьте корректность даты рождения' }
}
return { valid: true }
}
// Валидация даты приема на работу
export const isValidHireDate = (hireDate: string): { valid: boolean; message?: string } => {
if (!hireDate) return { valid: false, message: 'Дата приема на работу обязательна' }
const hire = new Date(hireDate)
const today = new Date()
if (hire > today) {
return { valid: false, message: 'Дата приема не может быть в будущем' }
}
return { valid: true }
}
// Валидация зарплаты
export const isValidSalary = (salary: number | undefined): { valid: boolean; message?: string } => {
if (salary === undefined || salary === null) return { valid: true }
if (salary < 0) {
return { valid: false, message: 'Зарплата не может быть отрицательной' }
}
if (salary > 10000000) {
return { valid: false, message: 'Слишком большая сумма зарплаты' }
}
return { valid: true }
}

View File

@ -0,0 +1,102 @@
interface WildberriesWarehouse {
id: number
name: string
address: string
cargoType: number
latitude: number
longitude: number
}
interface WildberriesWarehousesResponse {
data: WildberriesWarehouse[]
}
export class WildberriesService {
private static baseUrl = 'https://marketplace-api.wildberries.ru'
/**
* Получить список складов WB
*/
static async getWarehouses(apiKey: string): Promise<WildberriesWarehouse[]> {
try {
const response = await fetch(`${this.baseUrl}/api/v2/warehouses`, {
method: 'GET',
headers: {
'Authorization': apiKey,
'Content-Type': 'application/json',
},
})
if (!response.ok) {
throw new Error(`WB API Error: ${response.status} ${response.statusText}`)
}
const data: WildberriesWarehousesResponse = await response.json()
return data.data || []
} catch (error) {
console.error('Error fetching WB warehouses:', error)
throw new Error('Ошибка получения складов Wildberries')
}
}
/**
* Валидация API ключа WB
*/
static async validateApiKey(apiKey: string): Promise<boolean> {
try {
await this.getWarehouses(apiKey)
return true
} catch (error) {
console.error('WB API key validation failed:', error)
return false
}
}
/**
* Получить информацию о поставке
*/
static async getSupplyInfo(apiKey: string, supplyId: string): Promise<unknown> {
try {
const response = await fetch(`${this.baseUrl}/api/v3/supplies/${supplyId}`, {
method: 'GET',
headers: {
'Authorization': apiKey,
'Content-Type': 'application/json',
},
})
if (!response.ok) {
throw new Error(`WB API Error: ${response.status} ${response.statusText}`)
}
return await response.json()
} catch (error) {
console.error('Error fetching WB supply info:', error)
throw new Error('Ошибка получения информации о поставке')
}
}
/**
* Получить список поставок
*/
static async getSupplies(apiKey: string, limit: number = 1000, next: number = 0): Promise<unknown> {
try {
const response = await fetch(`${this.baseUrl}/api/v3/supplies?limit=${limit}&next=${next}`, {
method: 'GET',
headers: {
'Authorization': apiKey,
'Content-Type': 'application/json',
},
})
if (!response.ok) {
throw new Error(`WB API Error: ${response.status} ${response.statusText}`)
}
return await response.json()
} catch (error) {
console.error('Error fetching WB supplies:', error)
throw new Error('Ошибка получения списка поставок')
}
}
}