Добавлены модели и функциональность для управления логистикой, включая создание, обновление и удаление логистических маршрутов через GraphQL. Обновлены компоненты для отображения и управления логистикой, улучшен интерфейс взаимодействия с пользователем. Реализованы новые типы данных и интерфейсы для логистики, а также улучшена обработка ошибок.
This commit is contained in:
@ -83,6 +83,7 @@ model Organization {
|
|||||||
services Service[]
|
services Service[]
|
||||||
supplies Supply[]
|
supplies Supply[]
|
||||||
users User[]
|
users User[]
|
||||||
|
logistics Logistics[]
|
||||||
|
|
||||||
@@map("organizations")
|
@@map("organizations")
|
||||||
}
|
}
|
||||||
@ -348,3 +349,18 @@ enum ScheduleStatus {
|
|||||||
SICK
|
SICK
|
||||||
ABSENT
|
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")
|
||||||
|
}
|
||||||
|
10
src/app/supplies/create/page.tsx
Normal file
10
src/app/supplies/create/page.tsx
Normal 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
10
src/app/supplies/page.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
@ -12,7 +12,8 @@ import {
|
|||||||
MessageCircle,
|
MessageCircle,
|
||||||
Wrench,
|
Wrench,
|
||||||
Warehouse,
|
Warehouse,
|
||||||
Users
|
Users,
|
||||||
|
Truck
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
|
|
||||||
export function Sidebar() {
|
export function Sidebar() {
|
||||||
@ -76,12 +77,17 @@ export function Sidebar() {
|
|||||||
router.push('/employees')
|
router.push('/employees')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleSuppliesClick = () => {
|
||||||
|
router.push('/supplies')
|
||||||
|
}
|
||||||
|
|
||||||
const isSettingsActive = pathname === '/settings'
|
const isSettingsActive = pathname === '/settings'
|
||||||
const isMarketActive = pathname.startsWith('/market')
|
const isMarketActive = pathname.startsWith('/market')
|
||||||
const isMessengerActive = pathname.startsWith('/messenger')
|
const isMessengerActive = pathname.startsWith('/messenger')
|
||||||
const isServicesActive = pathname.startsWith('/services')
|
const isServicesActive = pathname.startsWith('/services')
|
||||||
const isWarehouseActive = pathname.startsWith('/warehouse')
|
const isWarehouseActive = pathname.startsWith('/warehouse')
|
||||||
const isEmployeesActive = pathname.startsWith('/employees')
|
const isEmployeesActive = pathname.startsWith('/employees')
|
||||||
|
const isSuppliesActive = pathname.startsWith('/supplies')
|
||||||
|
|
||||||
return (
|
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">
|
<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>
|
</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' && (
|
{user?.organization?.type === 'WHOLESALE' && (
|
||||||
<Button
|
<Button
|
||||||
|
@ -7,8 +7,22 @@ import { Label } from '@/components/ui/label'
|
|||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||||
import { Card } from '@/components/ui/card'
|
import { Card } from '@/components/ui/card'
|
||||||
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar'
|
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 { toast } from 'sonner'
|
||||||
|
import {
|
||||||
|
formatPhoneInput,
|
||||||
|
formatPassportSeries,
|
||||||
|
formatPassportNumber,
|
||||||
|
formatSalary,
|
||||||
|
formatNameInput,
|
||||||
|
isValidEmail,
|
||||||
|
isValidPhone,
|
||||||
|
isValidPassportSeries,
|
||||||
|
isValidPassportNumber,
|
||||||
|
isValidBirthDate,
|
||||||
|
isValidHireDate,
|
||||||
|
isValidSalary
|
||||||
|
} from '@/lib/input-masks'
|
||||||
|
|
||||||
interface Employee {
|
interface Employee {
|
||||||
id: string
|
id: string
|
||||||
@ -43,6 +57,10 @@ interface EmployeeFormProps {
|
|||||||
onCancel: () => void
|
onCancel: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface ValidationErrors {
|
||||||
|
[key: string]: string
|
||||||
|
}
|
||||||
|
|
||||||
export function EmployeeForm({ employee, onSave, onCancel }: EmployeeFormProps) {
|
export function EmployeeForm({ employee, onSave, onCancel }: EmployeeFormProps) {
|
||||||
const [formData, setFormData] = useState({
|
const [formData, setFormData] = useState({
|
||||||
firstName: employee?.firstName || '',
|
firstName: employee?.firstName || '',
|
||||||
@ -66,13 +84,154 @@ export function EmployeeForm({ employee, onSave, onCancel }: EmployeeFormProps)
|
|||||||
})
|
})
|
||||||
|
|
||||||
const [isUploadingAvatar, setIsUploadingAvatar] = useState(false)
|
const [isUploadingAvatar, setIsUploadingAvatar] = useState(false)
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
|
||||||
const [loading, setLoading] = useState(false)
|
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) => {
|
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 => ({
|
setFormData(prev => ({
|
||||||
...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) => {
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
|
|
||||||
// Валидация
|
if (!validateForm()) {
|
||||||
if (!formData.firstName || !formData.lastName || !formData.position) {
|
toast.error('Пожалуйста, исправьте ошибки в форме')
|
||||||
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('Введите корректный номер телефона')
|
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Для создания/обновления отправляем только нужные поля
|
|
||||||
const employeeData = {
|
const employeeData = {
|
||||||
firstName: formData.firstName,
|
firstName: formData.firstName,
|
||||||
lastName: formData.lastName,
|
lastName: formData.lastName,
|
||||||
middleName: formData.middleName,
|
middleName: formData.middleName || undefined,
|
||||||
position: formData.position,
|
position: formData.position,
|
||||||
phone: formData.phone,
|
phone: formData.phone,
|
||||||
email: formData.email || undefined,
|
email: formData.email || undefined,
|
||||||
@ -170,13 +330,15 @@ export function EmployeeForm({ employee, onSave, onCancel }: EmployeeFormProps)
|
|||||||
return `${first}${last}`
|
return `${first}${last}`
|
||||||
}
|
}
|
||||||
|
|
||||||
const formatPhoneInput = (value: string) => {
|
// Компонент для отображения ошибок
|
||||||
const cleaned = value.replace(/\D/g, '')
|
const ErrorMessage = ({ error }: { error: string }) => {
|
||||||
if (cleaned.length <= 1) return cleaned
|
if (!error) return null
|
||||||
if (cleaned.length <= 4) return `+7 (${cleaned.slice(1)}`
|
return (
|
||||||
if (cleaned.length <= 7) return `+7 (${cleaned.slice(1, 4)}) ${cleaned.slice(4)}`
|
<div className="flex items-center gap-1 mt-1 text-red-400 text-xs">
|
||||||
if (cleaned.length <= 9) return `+7 (${cleaned.slice(1, 4)}) ${cleaned.slice(4, 7)}-${cleaned.slice(7)}`
|
<AlertCircle className="h-3 w-3 flex-shrink-0" />
|
||||||
return `+7 (${cleaned.slice(1, 4)}) ${cleaned.slice(4, 7)}-${cleaned.slice(7, 9)}-${cleaned.slice(9, 11)}`
|
<span>{error}</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -227,9 +389,10 @@ export function EmployeeForm({ employee, onSave, onCancel }: EmployeeFormProps)
|
|||||||
value={formData.firstName}
|
value={formData.firstName}
|
||||||
onChange={(e) => handleInputChange('firstName', e.target.value)}
|
onChange={(e) => handleInputChange('firstName', e.target.value)}
|
||||||
placeholder="Александр"
|
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
|
required
|
||||||
/>
|
/>
|
||||||
|
<ErrorMessage error={errors.firstName} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
@ -240,9 +403,10 @@ export function EmployeeForm({ employee, onSave, onCancel }: EmployeeFormProps)
|
|||||||
value={formData.lastName}
|
value={formData.lastName}
|
||||||
onChange={(e) => handleInputChange('lastName', e.target.value)}
|
onChange={(e) => handleInputChange('lastName', e.target.value)}
|
||||||
placeholder="Петров"
|
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
|
required
|
||||||
/>
|
/>
|
||||||
|
<ErrorMessage error={errors.lastName} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
@ -251,8 +415,9 @@ export function EmployeeForm({ employee, onSave, onCancel }: EmployeeFormProps)
|
|||||||
value={formData.middleName}
|
value={formData.middleName}
|
||||||
onChange={(e) => handleInputChange('middleName', e.target.value)}
|
onChange={(e) => handleInputChange('middleName', e.target.value)}
|
||||||
placeholder="Иванович"
|
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>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
@ -261,8 +426,9 @@ export function EmployeeForm({ employee, onSave, onCancel }: EmployeeFormProps)
|
|||||||
type="date"
|
type="date"
|
||||||
value={formData.birthDate}
|
value={formData.birthDate}
|
||||||
onChange={(e) => handleInputChange('birthDate', e.target.value)}
|
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>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
@ -271,8 +437,10 @@ export function EmployeeForm({ employee, onSave, onCancel }: EmployeeFormProps)
|
|||||||
value={formData.passportSeries}
|
value={formData.passportSeries}
|
||||||
onChange={(e) => handleInputChange('passportSeries', e.target.value)}
|
onChange={(e) => handleInputChange('passportSeries', e.target.value)}
|
||||||
placeholder="1234"
|
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>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
@ -281,8 +449,10 @@ export function EmployeeForm({ employee, onSave, onCancel }: EmployeeFormProps)
|
|||||||
value={formData.passportNumber}
|
value={formData.passportNumber}
|
||||||
onChange={(e) => handleInputChange('passportNumber', e.target.value)}
|
onChange={(e) => handleInputChange('passportNumber', e.target.value)}
|
||||||
placeholder="567890"
|
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>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
@ -330,35 +500,39 @@ export function EmployeeForm({ employee, onSave, onCancel }: EmployeeFormProps)
|
|||||||
value={formData.position}
|
value={formData.position}
|
||||||
onChange={(e) => handleInputChange('position', e.target.value)}
|
onChange={(e) => handleInputChange('position', e.target.value)}
|
||||||
placeholder="Менеджер склада"
|
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
|
required
|
||||||
/>
|
/>
|
||||||
|
<ErrorMessage error={errors.position} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<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
|
<Input
|
||||||
type="date"
|
type="date"
|
||||||
value={formData.hireDate}
|
value={formData.hireDate}
|
||||||
onChange={(e) => handleInputChange('hireDate', e.target.value)}
|
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>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<Label className="text-white/80 text-sm mb-2 block">Статус</Label>
|
<Label className="text-white/80 text-sm mb-2 block">Статус</Label>
|
||||||
<Select
|
<Select
|
||||||
value={formData.status}
|
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">
|
<SelectTrigger className="glass-input text-white h-10">
|
||||||
<SelectValue />
|
<SelectValue />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent className="bg-gray-900 border-white/20">
|
<SelectContent className="bg-gray-900 border-white/20">
|
||||||
<SelectItem value="active" 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="VACATION" className="text-white hover:bg-white/10">В отпуске</SelectItem>
|
||||||
<SelectItem value="sick" 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="FIRED" className="text-white hover:bg-white/10">Уволен</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
@ -367,13 +541,12 @@ export function EmployeeForm({ employee, onSave, onCancel }: EmployeeFormProps)
|
|||||||
<div className="mt-4">
|
<div className="mt-4">
|
||||||
<Label className="text-white/80 text-sm mb-2 block">Зарплата (₽)</Label>
|
<Label className="text-white/80 text-sm mb-2 block">Зарплата (₽)</Label>
|
||||||
<Input
|
<Input
|
||||||
type="number"
|
value={formData.salary ? formatSalary(formData.salary.toString()) : ''}
|
||||||
min="0"
|
onChange={(e) => handleSalaryChange(e.target.value)}
|
||||||
value={formData.salary || ''}
|
placeholder="80 000"
|
||||||
onChange={(e) => handleInputChange('salary', parseInt(e.target.value) || 0)}
|
className={`glass-input text-white placeholder:text-white/40 h-10 ${errors.salary ? 'border-red-400' : ''}`}
|
||||||
placeholder="80000"
|
|
||||||
className="glass-input text-white placeholder:text-white/40 h-10"
|
|
||||||
/>
|
/>
|
||||||
|
<ErrorMessage error={errors.salary} />
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
@ -382,16 +555,16 @@ export function EmployeeForm({ employee, onSave, onCancel }: EmployeeFormProps)
|
|||||||
<h3 className="text-white font-medium mb-4">Контактные данные</h3>
|
<h3 className="text-white font-medium mb-4">Контактные данные</h3>
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
<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
|
<Input
|
||||||
value={formData.phone}
|
value={formData.phone}
|
||||||
onChange={(e) => {
|
onChange={(e) => handleInputChange('phone', e.target.value)}
|
||||||
const formatted = formatPhoneInput(e.target.value)
|
|
||||||
handleInputChange('phone', formatted)
|
|
||||||
}}
|
|
||||||
placeholder="+7 (999) 123-45-67"
|
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>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
@ -401,8 +574,9 @@ export function EmployeeForm({ employee, onSave, onCancel }: EmployeeFormProps)
|
|||||||
value={formData.email}
|
value={formData.email}
|
||||||
onChange={(e) => handleInputChange('email', e.target.value)}
|
onChange={(e) => handleInputChange('email', e.target.value)}
|
||||||
placeholder="a.petrov@company.com"
|
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>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
@ -419,13 +593,11 @@ export function EmployeeForm({ employee, onSave, onCancel }: EmployeeFormProps)
|
|||||||
<Label className="text-white/80 text-sm mb-2 block">Телефон экстренного контакта</Label>
|
<Label className="text-white/80 text-sm mb-2 block">Телефон экстренного контакта</Label>
|
||||||
<Input
|
<Input
|
||||||
value={formData.emergencyPhone}
|
value={formData.emergencyPhone}
|
||||||
onChange={(e) => {
|
onChange={(e) => handleInputChange('emergencyPhone', e.target.value)}
|
||||||
const formatted = formatPhoneInput(e.target.value)
|
|
||||||
handleInputChange('emergencyPhone', formatted)
|
|
||||||
}}
|
|
||||||
placeholder="+7 (999) 123-45-67"
|
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>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
|
@ -20,11 +20,25 @@ import {
|
|||||||
Mail,
|
Mail,
|
||||||
Briefcase,
|
Briefcase,
|
||||||
DollarSign,
|
DollarSign,
|
||||||
|
|
||||||
FileText,
|
FileText,
|
||||||
MessageCircle
|
MessageCircle,
|
||||||
|
AlertCircle
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
|
import {
|
||||||
|
formatPhoneInput,
|
||||||
|
formatPassportSeries,
|
||||||
|
formatPassportNumber,
|
||||||
|
formatSalary,
|
||||||
|
formatNameInput,
|
||||||
|
isValidEmail,
|
||||||
|
isValidPhone,
|
||||||
|
isValidPassportSeries,
|
||||||
|
isValidPassportNumber,
|
||||||
|
isValidBirthDate,
|
||||||
|
isValidHireDate,
|
||||||
|
isValidSalary
|
||||||
|
} from '@/lib/input-masks'
|
||||||
|
|
||||||
interface EmployeeInlineFormProps {
|
interface EmployeeInlineFormProps {
|
||||||
onSave: (employeeData: {
|
onSave: (employeeData: {
|
||||||
@ -46,6 +60,10 @@ interface EmployeeInlineFormProps {
|
|||||||
isLoading?: boolean
|
isLoading?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface ValidationErrors {
|
||||||
|
[key: string]: string
|
||||||
|
}
|
||||||
|
|
||||||
export function EmployeeInlineForm({ onSave, onCancel, isLoading = false }: EmployeeInlineFormProps) {
|
export function EmployeeInlineForm({ onSave, onCancel, isLoading = false }: EmployeeInlineFormProps) {
|
||||||
const [formData, setFormData] = useState({
|
const [formData, setFormData] = useState({
|
||||||
firstName: '',
|
firstName: '',
|
||||||
@ -65,13 +83,123 @@ export function EmployeeInlineForm({ onSave, onCancel, isLoading = false }: Empl
|
|||||||
const [isUploadingAvatar, setIsUploadingAvatar] = useState(false)
|
const [isUploadingAvatar, setIsUploadingAvatar] = useState(false)
|
||||||
const [isUploadingPassport, setIsUploadingPassport] = useState(false)
|
const [isUploadingPassport, setIsUploadingPassport] = useState(false)
|
||||||
const [showPassportPreview, setShowPassportPreview] = useState(false)
|
const [showPassportPreview, setShowPassportPreview] = useState(false)
|
||||||
|
const [errors, setErrors] = useState<ValidationErrors>({})
|
||||||
const avatarInputRef = useRef<HTMLInputElement>(null)
|
const avatarInputRef = useRef<HTMLInputElement>(null)
|
||||||
const passportInputRef = 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) => {
|
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 => ({
|
setFormData(prev => ({
|
||||||
...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
|
const validateForm = (): boolean => {
|
||||||
if (cleaned.length <= 4) return `+7 (${cleaned.slice(1)}`
|
const newErrors: ValidationErrors = {}
|
||||||
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)}`
|
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) => {
|
const handleSubmit = (e: React.FormEvent) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
|
|
||||||
// Валидация обязательных полей
|
if (!validateForm()) {
|
||||||
if (!formData.firstName || !formData.lastName || !formData.phone || !formData.position) {
|
toast.error('Пожалуйста, исправьте ошибки в форме')
|
||||||
toast.error('Пожалуйста, заполните все обязательные поля')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (formData.email && !/\S+@\S+\.\S+/.test(formData.email)) {
|
|
||||||
toast.error('Введите корректный email адрес')
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -169,6 +299,17 @@ export function EmployeeInlineForm({ onSave, onCancel, isLoading = false }: Empl
|
|||||||
onSave(employeeData)
|
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 getInitials = () => {
|
||||||
const first = formData.firstName.charAt(0).toUpperCase()
|
const first = formData.firstName.charAt(0).toUpperCase()
|
||||||
const last = formData.lastName.charAt(0).toUpperCase()
|
const last = formData.lastName.charAt(0).toUpperCase()
|
||||||
@ -316,7 +457,7 @@ export function EmployeeInlineForm({ onSave, onCancel, isLoading = false }: Empl
|
|||||||
</Label>
|
</Label>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||||
<div>
|
<div>
|
||||||
<Label className="text-white/80 text-sm mb-2 block">
|
<Label className="text-white/80 text-sm mb-2 block">
|
||||||
Имя <span className="text-red-400">*</span>
|
Имя <span className="text-red-400">*</span>
|
||||||
</Label>
|
</Label>
|
||||||
@ -324,11 +465,12 @@ export function EmployeeInlineForm({ onSave, onCancel, isLoading = false }: Empl
|
|||||||
value={formData.firstName}
|
value={formData.firstName}
|
||||||
onChange={(e) => handleInputChange('firstName', e.target.value)}
|
onChange={(e) => handleInputChange('firstName', e.target.value)}
|
||||||
placeholder="Александр"
|
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
|
required
|
||||||
/>
|
/>
|
||||||
|
<ErrorMessage error={errors.firstName} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<Label className="text-white/80 text-sm mb-2 block">
|
<Label className="text-white/80 text-sm mb-2 block">
|
||||||
Фамилия <span className="text-red-400">*</span>
|
Фамилия <span className="text-red-400">*</span>
|
||||||
@ -337,9 +479,10 @@ export function EmployeeInlineForm({ onSave, onCancel, isLoading = false }: Empl
|
|||||||
value={formData.lastName}
|
value={formData.lastName}
|
||||||
onChange={(e) => handleInputChange('lastName', e.target.value)}
|
onChange={(e) => handleInputChange('lastName', e.target.value)}
|
||||||
placeholder="Петров"
|
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
|
required
|
||||||
/>
|
/>
|
||||||
|
<ErrorMessage error={errors.lastName} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
@ -348,8 +491,9 @@ export function EmployeeInlineForm({ onSave, onCancel, isLoading = false }: Empl
|
|||||||
value={formData.middleName}
|
value={formData.middleName}
|
||||||
onChange={(e) => handleInputChange('middleName', e.target.value)}
|
onChange={(e) => handleInputChange('middleName', e.target.value)}
|
||||||
placeholder="Иванович"
|
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>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
@ -382,14 +526,12 @@ export function EmployeeInlineForm({ onSave, onCancel, isLoading = false }: Empl
|
|||||||
</Label>
|
</Label>
|
||||||
<Input
|
<Input
|
||||||
value={formData.phone}
|
value={formData.phone}
|
||||||
onChange={(e) => {
|
onChange={(e) => handleInputChange('phone', e.target.value)}
|
||||||
const formatted = formatPhoneInput(e.target.value)
|
|
||||||
handleInputChange('phone', formatted)
|
|
||||||
}}
|
|
||||||
placeholder="+7 (999) 123-45-67"
|
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
|
required
|
||||||
/>
|
/>
|
||||||
|
<ErrorMessage error={errors.phone} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
@ -412,13 +554,11 @@ export function EmployeeInlineForm({ onSave, onCancel, isLoading = false }: Empl
|
|||||||
</Label>
|
</Label>
|
||||||
<Input
|
<Input
|
||||||
value={formData.whatsapp}
|
value={formData.whatsapp}
|
||||||
onChange={(e) => {
|
onChange={(e) => handleInputChange('whatsapp', e.target.value)}
|
||||||
const formatted = formatPhoneInput(e.target.value)
|
|
||||||
handleInputChange('whatsapp', formatted)
|
|
||||||
}}
|
|
||||||
placeholder="+7 (999) 123-45-67"
|
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>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
@ -431,8 +571,9 @@ export function EmployeeInlineForm({ onSave, onCancel, isLoading = false }: Empl
|
|||||||
value={formData.email}
|
value={formData.email}
|
||||||
onChange={(e) => handleInputChange('email', e.target.value)}
|
onChange={(e) => handleInputChange('email', e.target.value)}
|
||||||
placeholder="a.petrov@company.com"
|
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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -455,9 +596,10 @@ export function EmployeeInlineForm({ onSave, onCancel, isLoading = false }: Empl
|
|||||||
value={formData.position}
|
value={formData.position}
|
||||||
onChange={(e) => handleInputChange('position', e.target.value)}
|
onChange={(e) => handleInputChange('position', e.target.value)}
|
||||||
placeholder="Менеджер склада"
|
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
|
required
|
||||||
/>
|
/>
|
||||||
|
<ErrorMessage error={errors.position} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
@ -466,13 +608,12 @@ export function EmployeeInlineForm({ onSave, onCancel, isLoading = false }: Empl
|
|||||||
Зарплата
|
Зарплата
|
||||||
</Label>
|
</Label>
|
||||||
<Input
|
<Input
|
||||||
type="number"
|
value={formData.salary ? formatSalary(formData.salary.toString()) : ''}
|
||||||
min="0"
|
onChange={(e) => handleSalaryChange(e.target.value)}
|
||||||
value={formData.salary || ''}
|
placeholder="80 000"
|
||||||
onChange={(e) => handleInputChange('salary', parseInt(e.target.value) || 0)}
|
className={`glass-input text-white placeholder:text-white/40 ${errors.salary ? 'border-red-400' : ''}`}
|
||||||
placeholder="80000"
|
|
||||||
className="glass-input text-white placeholder:text-white/40"
|
|
||||||
/>
|
/>
|
||||||
|
<ErrorMessage error={errors.salary} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -1,34 +1,688 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
|
import { useState, useEffect, useMemo } from 'react'
|
||||||
|
import { useQuery, useMutation } from '@apollo/client'
|
||||||
import { Card } from '@/components/ui/card'
|
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() {
|
export function LogisticsTab() {
|
||||||
return (
|
const { user } = useAuth()
|
||||||
<div className="h-full">
|
const [editableLogistics, setEditableLogistics] = useState<EditableLogistics[]>([])
|
||||||
<Card className="h-full bg-white/5 backdrop-blur border-white/10 p-6">
|
const [isSaving, setIsSaving] = useState(false)
|
||||||
<div className="h-full flex items-center justify-center">
|
const [isInitialized, setIsInitialized] = useState(false)
|
||||||
<div className="text-center">
|
const [warehouses, setWarehouses] = useState<LocationOption[]>([])
|
||||||
<div className="w-16 h-16 bg-white/10 rounded-full flex items-center justify-center mx-auto mb-4">
|
|
||||||
<svg
|
// GraphQL запросы и мутации
|
||||||
className="w-8 h-8 text-white/50"
|
const { data, loading, error, refetch } = useQuery(GET_MY_LOGISTICS, {
|
||||||
fill="none"
|
skip: user?.organization?.type !== 'FULFILLMENT'
|
||||||
stroke="currentColor"
|
})
|
||||||
viewBox="0 0 24 24"
|
const [createLogistics] = useMutation(CREATE_LOGISTICS)
|
||||||
>
|
const [updateLogistics] = useMutation(UPDATE_LOGISTICS)
|
||||||
<path
|
const [deleteLogistics] = useMutation(DELETE_LOGISTICS)
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
const logistics = data?.myLogistics || []
|
||||||
strokeWidth={2}
|
|
||||||
d="M8 7h12m0 0l-4-4m4 4l-4 4m0 6H4m0 0l4 4m-4-4l4-4"
|
// Загружаем склады из API WB
|
||||||
/>
|
useEffect(() => {
|
||||||
</svg>
|
const loadWarehouses = async () => {
|
||||||
</div>
|
try {
|
||||||
<h3 className="text-lg font-semibold text-white mb-2">Логистика</h3>
|
// Получаем API ключ из организации пользователя
|
||||||
<p className="text-white/70 text-sm max-w-md">
|
const wbApiKey = user?.organization?.apiKeys?.find(key => key.marketplace === 'WILDBERRIES')
|
||||||
Раздел логистики находится в разработке.
|
if (wbApiKey?.isActive && wbApiKey.validationData) {
|
||||||
Здесь будут инструменты для управления доставкой и складскими операциями.
|
const validationData = wbApiKey.validationData as Record<string, string>
|
||||||
</p>
|
const apiToken = validationData?.token || validationData?.apiKey
|
||||||
|
if (apiToken) {
|
||||||
|
const wbWarehouses = await WildberriesService.getWarehouses(apiToken)
|
||||||
|
const warehouseOptions = wbWarehouses.map(w => ({
|
||||||
|
value: `wb-${w.id}`,
|
||||||
|
label: `Склад WB ${w.name}`
|
||||||
|
}))
|
||||||
|
setWarehouses(warehouseOptions)
|
||||||
|
console.log('Loaded WB warehouses:', warehouseOptions)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load warehouses:', error)
|
||||||
|
// Используем только статичные склады
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (user?.organization) {
|
||||||
|
loadWarehouses()
|
||||||
|
}
|
||||||
|
}, [user])
|
||||||
|
|
||||||
|
// Преобразуем загруженные маршруты в редактируемый формат
|
||||||
|
useEffect(() => {
|
||||||
|
if (data?.myLogistics && !isInitialized) {
|
||||||
|
const convertedLogistics: EditableLogistics[] = data.myLogistics.map((route: LogisticsRoute) => ({
|
||||||
|
id: route.id,
|
||||||
|
fromLocation: route.fromLocation,
|
||||||
|
toLocation: route.toLocation,
|
||||||
|
priceUnder1m3: route.priceUnder1m3.toString(),
|
||||||
|
priceOver1m3: route.priceOver1m3.toString(),
|
||||||
|
description: route.description || '',
|
||||||
|
isNew: false,
|
||||||
|
isEditing: false,
|
||||||
|
hasChanges: false
|
||||||
|
}))
|
||||||
|
|
||||||
|
setEditableLogistics(convertedLogistics)
|
||||||
|
setIsInitialized(true)
|
||||||
|
}
|
||||||
|
}, [data, isInitialized])
|
||||||
|
|
||||||
|
// Получить все опции локаций с группировкой
|
||||||
|
const getAllLocationOptions = () => {
|
||||||
|
const myFulfillment = user?.organization?.name ? [
|
||||||
|
{ value: 'my-fulfillment', label: user.organization.name }
|
||||||
|
] : []
|
||||||
|
|
||||||
|
return [
|
||||||
|
...myFulfillment,
|
||||||
|
...BASE_LOCATIONS.markets,
|
||||||
|
...BASE_LOCATIONS.wbWarehouses,
|
||||||
|
...BASE_LOCATIONS.ozonWarehouses,
|
||||||
|
...warehouses
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Создать группированные опции для селекта
|
||||||
|
const getGroupedLocationOptions = () => {
|
||||||
|
const myFulfillment = user?.organization?.name ? [
|
||||||
|
{ value: 'my-fulfillment', label: user.organization.name }
|
||||||
|
] : []
|
||||||
|
|
||||||
|
return {
|
||||||
|
myFulfillment,
|
||||||
|
markets: BASE_LOCATIONS.markets,
|
||||||
|
wbWarehouses: [...BASE_LOCATIONS.wbWarehouses, ...warehouses],
|
||||||
|
ozonWarehouses: BASE_LOCATIONS.ozonWarehouses
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Получить название локации по ID
|
||||||
|
const getLocationLabel = (locationId: string) => {
|
||||||
|
const allOptions = getAllLocationOptions()
|
||||||
|
return allOptions.find(opt => opt.value === locationId)?.label || locationId
|
||||||
|
}
|
||||||
|
|
||||||
|
// Добавить новую строку
|
||||||
|
const addNewRow = () => {
|
||||||
|
const tempId = `temp-${Date.now()}-${Math.random()}`
|
||||||
|
const newRow: EditableLogistics = {
|
||||||
|
id: tempId,
|
||||||
|
fromLocation: '',
|
||||||
|
toLocation: '',
|
||||||
|
priceUnder1m3: '',
|
||||||
|
priceOver1m3: '',
|
||||||
|
description: '',
|
||||||
|
isNew: true,
|
||||||
|
isEditing: true,
|
||||||
|
hasChanges: false
|
||||||
|
}
|
||||||
|
setEditableLogistics(prev => [...prev, newRow])
|
||||||
|
}
|
||||||
|
|
||||||
|
// Удалить строку
|
||||||
|
const removeRow = async (routeId: string, isNew: boolean) => {
|
||||||
|
if (isNew) {
|
||||||
|
setEditableLogistics(prev => prev.filter(r => r.id !== routeId))
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
await deleteLogistics({
|
||||||
|
variables: { id: routeId },
|
||||||
|
update: (cache, { data }) => {
|
||||||
|
if (data?.deleteLogistics) {
|
||||||
|
const existingData = cache.readQuery({ query: GET_MY_LOGISTICS }) as { myLogistics: LogisticsRoute[] } | null
|
||||||
|
if (existingData && existingData.myLogistics) {
|
||||||
|
cache.writeQuery({
|
||||||
|
query: GET_MY_LOGISTICS,
|
||||||
|
data: {
|
||||||
|
myLogistics: existingData.myLogistics.filter((route: LogisticsRoute) => route.id !== routeId)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
setEditableLogistics(prev => prev.filter(r => r.id !== routeId))
|
||||||
|
toast.success('Маршрут успешно удален')
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error deleting logistics:', error)
|
||||||
|
toast.error('Ошибка при удалении маршрута')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Начать редактирование
|
||||||
|
const startEditing = (routeId: string) => {
|
||||||
|
setEditableLogistics(prev =>
|
||||||
|
prev.map(route =>
|
||||||
|
route.id === routeId ? { ...route, isEditing: true } : route
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Отменить редактирование
|
||||||
|
const cancelEditing = (routeId: string) => {
|
||||||
|
const route = editableLogistics.find(r => r.id === routeId)
|
||||||
|
if (!route) return
|
||||||
|
|
||||||
|
if (route.isNew) {
|
||||||
|
setEditableLogistics(prev => prev.filter(r => r.id !== routeId))
|
||||||
|
} else {
|
||||||
|
const originalRoute = logistics.find((r: LogisticsRoute) => r.id === route.id)
|
||||||
|
if (originalRoute) {
|
||||||
|
setEditableLogistics(prev =>
|
||||||
|
prev.map(r =>
|
||||||
|
r.id === routeId
|
||||||
|
? {
|
||||||
|
id: originalRoute.id,
|
||||||
|
fromLocation: originalRoute.fromLocation,
|
||||||
|
toLocation: originalRoute.toLocation,
|
||||||
|
priceUnder1m3: originalRoute.priceUnder1m3.toString(),
|
||||||
|
priceOver1m3: originalRoute.priceOver1m3.toString(),
|
||||||
|
description: originalRoute.description || '',
|
||||||
|
isNew: false,
|
||||||
|
isEditing: false,
|
||||||
|
hasChanges: false
|
||||||
|
}
|
||||||
|
: r
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Обновить поле
|
||||||
|
const updateField = (routeId: string, field: keyof EditableLogistics, value: string) => {
|
||||||
|
setEditableLogistics(prev =>
|
||||||
|
prev.map((route) => {
|
||||||
|
if (route.id !== routeId) return route
|
||||||
|
|
||||||
|
const updated = { ...route, hasChanges: true }
|
||||||
|
if (field === 'fromLocation') updated.fromLocation = value
|
||||||
|
else if (field === 'toLocation') updated.toLocation = value
|
||||||
|
else if (field === 'priceUnder1m3') updated.priceUnder1m3 = value
|
||||||
|
else if (field === 'priceOver1m3') updated.priceOver1m3 = value
|
||||||
|
else if (field === 'description') updated.description = value
|
||||||
|
|
||||||
|
return updated
|
||||||
|
})
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Сохранить все изменения
|
||||||
|
const saveAllChanges = async () => {
|
||||||
|
setIsSaving(true)
|
||||||
|
console.log('Saving all changes...')
|
||||||
|
|
||||||
|
try {
|
||||||
|
const routesToSave = editableLogistics.filter(r => {
|
||||||
|
if (r.isNew) {
|
||||||
|
return r.fromLocation && r.toLocation && r.priceUnder1m3 && r.priceOver1m3
|
||||||
|
}
|
||||||
|
return r.hasChanges
|
||||||
|
})
|
||||||
|
|
||||||
|
console.log('Routes to save:', routesToSave)
|
||||||
|
|
||||||
|
for (const route of routesToSave) {
|
||||||
|
if (!route.fromLocation || !route.toLocation || !route.priceUnder1m3 || !route.priceOver1m3) {
|
||||||
|
toast.error('Заполните все обязательные поля')
|
||||||
|
setIsSaving(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const input = {
|
||||||
|
fromLocation: route.fromLocation,
|
||||||
|
toLocation: route.toLocation,
|
||||||
|
priceUnder1m3: parseFloat(route.priceUnder1m3),
|
||||||
|
priceOver1m3: parseFloat(route.priceOver1m3),
|
||||||
|
description: route.description || undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('Saving route with input:', input)
|
||||||
|
|
||||||
|
if (route.isNew) {
|
||||||
|
const result = await createLogistics({
|
||||||
|
variables: { input },
|
||||||
|
update: (cache, { data }) => {
|
||||||
|
if (data?.createLogistics?.success && data.createLogistics.logistics) {
|
||||||
|
const existingData = cache.readQuery({ query: GET_MY_LOGISTICS }) as { myLogistics: LogisticsRoute[] } | null
|
||||||
|
if (existingData && existingData.myLogistics) {
|
||||||
|
cache.writeQuery({
|
||||||
|
query: GET_MY_LOGISTICS,
|
||||||
|
data: {
|
||||||
|
myLogistics: [...existingData.myLogistics, data.createLogistics.logistics]
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
console.log('Create result:', result)
|
||||||
|
} else if (route.id) {
|
||||||
|
const result = await updateLogistics({
|
||||||
|
variables: { id: route.id, input },
|
||||||
|
update: (cache, { data }) => {
|
||||||
|
if (data?.updateLogistics?.success && data.updateLogistics.logistics) {
|
||||||
|
const existingData = cache.readQuery({ query: GET_MY_LOGISTICS }) as { myLogistics: LogisticsRoute[] } | null
|
||||||
|
if (existingData && existingData.myLogistics) {
|
||||||
|
cache.writeQuery({
|
||||||
|
query: GET_MY_LOGISTICS,
|
||||||
|
data: {
|
||||||
|
myLogistics: existingData.myLogistics.map((route: LogisticsRoute) =>
|
||||||
|
route.id === data.updateLogistics.logistics.id ? data.updateLogistics.logistics : route
|
||||||
|
)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
console.log('Update result:', result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
toast.success('Все изменения успешно сохранены')
|
||||||
|
// Обновляем локальное состояние - убираем флаги isNew и hasChanges
|
||||||
|
setEditableLogistics(prev => prev.map(route => ({
|
||||||
|
...route,
|
||||||
|
isNew: false,
|
||||||
|
hasChanges: false,
|
||||||
|
isEditing: false
|
||||||
|
})))
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error saving changes:', error)
|
||||||
|
toast.error(`Ошибка при сохранении: ${error}`)
|
||||||
|
} finally {
|
||||||
|
setIsSaving(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Проверяем есть ли несохраненные изменения
|
||||||
|
const hasUnsavedChanges = useMemo(() => {
|
||||||
|
return editableLogistics.some(r => {
|
||||||
|
if (r.isNew) {
|
||||||
|
return r.fromLocation || r.toLocation || r.priceUnder1m3 || r.priceOver1m3
|
||||||
|
}
|
||||||
|
return r.hasChanges
|
||||||
|
})
|
||||||
|
}, [editableLogistics])
|
||||||
|
|
||||||
|
// Компонент группированного селекта
|
||||||
|
const GroupedLocationSelect = ({ value, onValueChange, placeholder }: {
|
||||||
|
value: string
|
||||||
|
onValueChange: (value: string) => void
|
||||||
|
placeholder: string
|
||||||
|
}) => {
|
||||||
|
const groups = getGroupedLocationOptions()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Select value={value} onValueChange={onValueChange}>
|
||||||
|
<SelectTrigger className="bg-white/5 border-white/20 text-white">
|
||||||
|
<SelectValue placeholder={placeholder} />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{/* Мой фулфилмент */}
|
||||||
|
{groups.myFulfillment.length > 0 && (
|
||||||
|
<>
|
||||||
|
<div className="px-2 py-1.5 text-xs font-semibold text-muted-foreground bg-muted/50 flex items-center gap-2">
|
||||||
|
<Building2 className="w-3 h-3" />
|
||||||
|
Мой фулфилмент
|
||||||
|
</div>
|
||||||
|
{groups.myFulfillment.map(option => (
|
||||||
|
<SelectItem key={option.value} value={option.value}>
|
||||||
|
{option.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Рынки */}
|
||||||
|
<div className="px-2 py-1.5 text-xs font-semibold text-muted-foreground bg-muted/50 flex items-center gap-2">
|
||||||
|
<Store className="w-3 h-3" />
|
||||||
|
Рынки
|
||||||
</div>
|
</div>
|
||||||
|
{groups.markets.map(option => (
|
||||||
|
<SelectItem key={option.value} value={option.value}>
|
||||||
|
{option.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Склады Wildberries */}
|
||||||
|
<div className="px-2 py-1.5 text-xs font-semibold text-muted-foreground bg-muted/50 flex items-center gap-2">
|
||||||
|
<Package className="w-3 h-3" />
|
||||||
|
Склады Wildberries
|
||||||
|
</div>
|
||||||
|
{groups.wbWarehouses.map((option: LocationOption) => (
|
||||||
|
<SelectItem key={option.value} value={option.value}>
|
||||||
|
{option.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Склады Ozon */}
|
||||||
|
<div className="px-2 py-1.5 text-xs font-semibold text-muted-foreground bg-muted/50 flex items-center gap-2">
|
||||||
|
<ShoppingCart className="w-3 h-3" />
|
||||||
|
Склады Ozon
|
||||||
|
</div>
|
||||||
|
{groups.ozonWarehouses.map((option: LocationOption) => (
|
||||||
|
<SelectItem key={option.value} value={option.value}>
|
||||||
|
{option.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="h-full flex flex-col">
|
||||||
|
<Card className="flex-1 bg-white/5 backdrop-blur border-white/10 p-6 overflow-hidden">
|
||||||
|
{/* Заголовок и кнопки */}
|
||||||
|
<div className="flex items-center justify-between mb-6">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-lg font-semibold text-white mb-1">Логистические маршруты</h2>
|
||||||
|
<p className="text-white/70 text-sm">Доставка между точками</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<Button
|
||||||
|
onClick={addNewRow}
|
||||||
|
className="bg-gradient-to-r from-purple-600 to-pink-600 hover:from-purple-700 hover:to-pink-700 text-white border-0 shadow-lg shadow-purple-500/25 hover:shadow-purple-500/40 transition-all duration-300 hover:scale-105"
|
||||||
|
>
|
||||||
|
<Plus className="w-4 h-4 mr-2" />
|
||||||
|
Добавить маршрут
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{hasUnsavedChanges && (
|
||||||
|
<Button
|
||||||
|
onClick={saveAllChanges}
|
||||||
|
disabled={isSaving}
|
||||||
|
className="bg-gradient-to-r from-green-600 to-emerald-600 hover:from-green-700 hover:to-emerald-700 text-white border-0 shadow-lg shadow-green-500/25 hover:shadow-green-500/40 transition-all duration-300 hover:scale-105 disabled:hover:scale-100"
|
||||||
|
>
|
||||||
|
<Save className="w-4 h-4 mr-2" />
|
||||||
|
{isSaving ? 'Сохранение...' : 'Сохранить все'}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Таблица маршрутов */}
|
||||||
|
<div className="overflow-auto flex-1">
|
||||||
|
{loading ? (
|
||||||
|
<div className="h-full flex items-center justify-center">
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="w-16 h-16 bg-white/10 rounded-full flex items-center justify-center mx-auto mb-4 animate-spin">
|
||||||
|
<Truck className="w-8 h-8 text-white/50" />
|
||||||
|
</div>
|
||||||
|
<p className="text-white/70 text-sm">Загрузка маршрутов...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : editableLogistics.length === 0 ? (
|
||||||
|
<div className="h-full flex items-center justify-center">
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="w-16 h-16 bg-white/10 rounded-full flex items-center justify-center mx-auto mb-4">
|
||||||
|
<Plus className="w-8 h-8 text-white/50" />
|
||||||
|
</div>
|
||||||
|
<h3 className="text-lg font-semibold text-white mb-2">Пока нет маршрутов</h3>
|
||||||
|
<p className="text-white/70 text-sm mb-4">
|
||||||
|
Создайте свой первый логистический маршрут
|
||||||
|
</p>
|
||||||
|
<Button
|
||||||
|
onClick={addNewRow}
|
||||||
|
className="bg-gradient-to-r from-purple-500 to-purple-600 hover:from-purple-600 hover:to-purple-700 text-white"
|
||||||
|
>
|
||||||
|
<Plus className="w-4 h-4 mr-2" />
|
||||||
|
Добавить маршрут
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="bg-white/5 rounded-lg border border-white/10 overflow-hidden">
|
||||||
|
<table className="w-full">
|
||||||
|
<thead className="bg-white/5">
|
||||||
|
<tr>
|
||||||
|
<th className="text-left p-4 text-white font-medium">№</th>
|
||||||
|
<th className="text-left p-4 text-white font-medium">Откуда *</th>
|
||||||
|
<th className="text-left p-4 text-white font-medium">Куда *</th>
|
||||||
|
<th className="text-left p-4 text-white font-medium">До 1м³ (₽) *</th>
|
||||||
|
<th className="text-left p-4 text-white font-medium">Свыше 1м³ (₽) *</th>
|
||||||
|
<th className="text-left p-4 text-white font-medium">Описание</th>
|
||||||
|
<th className="text-left p-4 text-white font-medium">Действия</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{editableLogistics.map((route, index) => (
|
||||||
|
<tr key={route.id || index} className={`border-t border-white/10 hover:bg-white/5 ${route.isNew || route.hasChanges ? 'bg-blue-500/10' : ''}`}>
|
||||||
|
<td className="p-4 text-white/80">{index + 1}</td>
|
||||||
|
|
||||||
|
{/* Откуда */}
|
||||||
|
<td className="p-4">
|
||||||
|
{route.isEditing ? (
|
||||||
|
<GroupedLocationSelect
|
||||||
|
value={route.fromLocation}
|
||||||
|
onValueChange={(value) => updateField(route.id!, 'fromLocation', value)}
|
||||||
|
placeholder="Выберите точку отправления"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<span className="text-white/80">{getLocationLabel(route.fromLocation)}</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
|
||||||
|
{/* Куда */}
|
||||||
|
<td className="p-4">
|
||||||
|
{route.isEditing ? (
|
||||||
|
<GroupedLocationSelect
|
||||||
|
value={route.toLocation}
|
||||||
|
onValueChange={(value) => updateField(route.id!, 'toLocation', value)}
|
||||||
|
placeholder="Выберите точку назначения"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<span className="text-white/80">{getLocationLabel(route.toLocation)}</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
|
||||||
|
{/* Цена до 1м³ */}
|
||||||
|
<td className="p-4">
|
||||||
|
{route.isEditing ? (
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
step="0.01"
|
||||||
|
min="0"
|
||||||
|
value={route.priceUnder1m3}
|
||||||
|
onChange={(e) => updateField(route.id!, 'priceUnder1m3', e.target.value)}
|
||||||
|
className="bg-white/5 border-white/20 text-white"
|
||||||
|
placeholder="0.00"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<span className="text-white/80">
|
||||||
|
{route.priceUnder1m3 ? parseFloat(route.priceUnder1m3).toLocaleString() : '0'} ₽
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
|
||||||
|
{/* Цена свыше 1м³ */}
|
||||||
|
<td className="p-4">
|
||||||
|
{route.isEditing ? (
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
step="0.01"
|
||||||
|
min="0"
|
||||||
|
value={route.priceOver1m3}
|
||||||
|
onChange={(e) => updateField(route.id!, 'priceOver1m3', e.target.value)}
|
||||||
|
className="bg-white/5 border-white/20 text-white"
|
||||||
|
placeholder="0.00"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<span className="text-white/80">
|
||||||
|
{route.priceOver1m3 ? parseFloat(route.priceOver1m3).toLocaleString() : '0'} ₽
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
|
||||||
|
{/* Описание */}
|
||||||
|
<td className="p-4">
|
||||||
|
{route.isEditing ? (
|
||||||
|
<Input
|
||||||
|
value={route.description}
|
||||||
|
onChange={(e) => updateField(route.id!, 'description', e.target.value)}
|
||||||
|
className="bg-white/5 border-white/20 text-white"
|
||||||
|
placeholder="Дополнительная информация"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<span className="text-white/80">{route.description || '—'}</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
|
||||||
|
{/* Действия */}
|
||||||
|
<td className="p-4">
|
||||||
|
<div className="flex gap-2">
|
||||||
|
{route.isEditing ? (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
onClick={() => {
|
||||||
|
if (route.fromLocation && route.toLocation && route.priceUnder1m3 && route.priceOver1m3) {
|
||||||
|
saveAllChanges()
|
||||||
|
} else {
|
||||||
|
toast.error('Заполните обязательные поля')
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
disabled={!route.fromLocation || !route.toLocation || !route.priceUnder1m3 || !route.priceOver1m3 || isSaving}
|
||||||
|
className="h-8 w-8 p-0 bg-gradient-to-r from-green-500/20 to-emerald-500/20 hover:from-green-500/30 hover:to-emerald-500/30 border border-green-500/30 hover:border-green-400/50 text-green-300 hover:text-white transition-all duration-200 shadow-lg shadow-green-500/10 hover:shadow-green-500/20 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
title="Сохранить"
|
||||||
|
>
|
||||||
|
<Check className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
onClick={() => cancelEditing(route.id!)}
|
||||||
|
className="h-8 w-8 p-0 bg-gradient-to-r from-red-500/20 to-red-600/20 hover:from-red-500/30 hover:to-red-600/30 border border-red-500/30 hover:border-red-400/50 text-red-300 hover:text-white transition-all duration-200 shadow-lg shadow-red-500/10 hover:shadow-red-500/20"
|
||||||
|
title="Отменить"
|
||||||
|
>
|
||||||
|
<X className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
onClick={() => startEditing(route.id!)}
|
||||||
|
className="h-8 w-8 p-0 bg-gradient-to-r from-purple-500/20 to-pink-500/20 hover:from-purple-500/30 hover:to-pink-500/30 border border-purple-500/30 hover:border-purple-400/50 text-purple-300 hover:text-white transition-all duration-200 shadow-lg shadow-purple-500/10 hover:shadow-purple-500/20"
|
||||||
|
title="Редактировать"
|
||||||
|
>
|
||||||
|
<Edit className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<AlertDialog>
|
||||||
|
<AlertDialogTrigger asChild>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
className="h-8 w-8 p-0 bg-gradient-to-r from-red-500/20 to-red-600/20 hover:from-red-500/30 hover:to-red-600/30 border border-red-500/30 hover:border-red-400/50 text-red-300 hover:text-white transition-all duration-200 shadow-lg shadow-red-500/10 hover:shadow-red-500/20"
|
||||||
|
title="Удалить"
|
||||||
|
>
|
||||||
|
<Trash2 className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
</AlertDialogTrigger>
|
||||||
|
<AlertDialogContent className="bg-gradient-to-br from-red-900/95 via-red-800/95 to-red-900/95 backdrop-blur-xl border border-red-500/30 text-white shadow-2xl shadow-red-500/20">
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle className="text-xl font-bold bg-gradient-to-r from-red-300 to-red-300 bg-clip-text text-transparent">
|
||||||
|
Подтвердите удаление
|
||||||
|
</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription className="text-red-200">
|
||||||
|
Вы действительно хотите удалить маршрут? Это действие необратимо.
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter className="gap-3">
|
||||||
|
<AlertDialogCancel className="border-red-400/30 text-red-200 hover:bg-red-500/10 hover:border-red-300 transition-all duration-300">
|
||||||
|
Отмена
|
||||||
|
</AlertDialogCancel>
|
||||||
|
<AlertDialogAction
|
||||||
|
onClick={() => removeRow(route.id!, route.isNew)}
|
||||||
|
className="bg-gradient-to-r from-red-600 to-red-700 hover:from-red-700 hover:to-red-800 text-white border-0 shadow-lg shadow-red-500/25 hover:shadow-red-500/40 transition-all duration-300"
|
||||||
|
>
|
||||||
|
Удалить
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
@ -1,13 +1,11 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { useState } from 'react'
|
import { useState, useEffect, useMemo } from 'react'
|
||||||
import { useQuery, useMutation } from '@apollo/client'
|
import { useQuery, useMutation } from '@apollo/client'
|
||||||
import { Card } from '@/components/ui/card'
|
import { Card } from '@/components/ui/card'
|
||||||
import Image from 'next/image'
|
import Image from 'next/image'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Input } from '@/components/ui/input'
|
import { Input } from '@/components/ui/input'
|
||||||
import { Label } from '@/components/ui/label'
|
|
||||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog'
|
|
||||||
import {
|
import {
|
||||||
AlertDialog,
|
AlertDialog,
|
||||||
AlertDialogAction,
|
AlertDialogAction,
|
||||||
@ -19,7 +17,7 @@ import {
|
|||||||
AlertDialogTitle,
|
AlertDialogTitle,
|
||||||
AlertDialogTrigger
|
AlertDialogTrigger
|
||||||
} from '@/components/ui/alert-dialog'
|
} 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 { toast } from "sonner"
|
||||||
import { useAuth } from '@/hooks/useAuth'
|
import { useAuth } from '@/hooks/useAuth'
|
||||||
import { GET_MY_SERVICES } from '@/graphql/queries'
|
import { GET_MY_SERVICES } from '@/graphql/queries'
|
||||||
@ -35,18 +33,28 @@ interface Service {
|
|||||||
updatedAt: string
|
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() {
|
export function ServicesTab() {
|
||||||
const { user } = useAuth()
|
const { user } = useAuth()
|
||||||
const [isDialogOpen, setIsDialogOpen] = useState(false)
|
const [editableServices, setEditableServices] = useState<EditableService[]>([])
|
||||||
const [editingService, setEditingService] = useState<Service | null>(null)
|
const [pendingChanges, setPendingChanges] = useState<PendingChange[]>([])
|
||||||
const [formData, setFormData] = useState({
|
const [isSaving, setIsSaving] = useState(false)
|
||||||
name: '',
|
const [isInitialized, setIsInitialized] = useState(false)
|
||||||
description: '',
|
|
||||||
price: '',
|
|
||||||
imageUrl: ''
|
|
||||||
})
|
|
||||||
const [imageFile, setImageFile] = useState<File | null>(null)
|
|
||||||
const [isUploading] = useState(false)
|
|
||||||
|
|
||||||
// GraphQL запросы и мутации
|
// GraphQL запросы и мутации
|
||||||
const { data, loading, error, refetch } = useQuery(GET_MY_SERVICES, {
|
const { data, loading, error, refetch } = useQuery(GET_MY_SERVICES, {
|
||||||
@ -57,45 +65,140 @@ export function ServicesTab() {
|
|||||||
const [deleteService] = useMutation(DELETE_SERVICE)
|
const [deleteService] = useMutation(DELETE_SERVICE)
|
||||||
|
|
||||||
const services = data?.myServices || []
|
const services = data?.myServices || []
|
||||||
|
|
||||||
// Логирование для отладки
|
|
||||||
console.log('Services data:', services)
|
|
||||||
|
|
||||||
const resetForm = () => {
|
// Преобразуем загруженные услуги в редактируемый формат
|
||||||
setFormData({
|
useEffect(() => {
|
||||||
|
if (data?.myServices && !isInitialized) {
|
||||||
|
const convertedServices: EditableService[] = data.myServices.map((service: Service) => ({
|
||||||
|
id: service.id,
|
||||||
|
name: service.name,
|
||||||
|
description: service.description || '',
|
||||||
|
price: service.price.toString(),
|
||||||
|
imageUrl: service.imageUrl || '',
|
||||||
|
isNew: false,
|
||||||
|
isEditing: false,
|
||||||
|
hasChanges: false
|
||||||
|
}))
|
||||||
|
|
||||||
|
setEditableServices(convertedServices)
|
||||||
|
setPendingChanges([])
|
||||||
|
setIsInitialized(true)
|
||||||
|
}
|
||||||
|
}, [data, isInitialized])
|
||||||
|
|
||||||
|
// Добавить новую строку
|
||||||
|
const addNewRow = () => {
|
||||||
|
const tempId = `temp-${Date.now()}-${Math.random()}`
|
||||||
|
const newRow: EditableService = {
|
||||||
|
id: tempId,
|
||||||
name: '',
|
name: '',
|
||||||
description: '',
|
description: '',
|
||||||
price: '',
|
price: '',
|
||||||
imageUrl: ''
|
imageUrl: '',
|
||||||
})
|
isNew: true,
|
||||||
setImageFile(null)
|
isEditing: true,
|
||||||
setEditingService(null)
|
hasChanges: false
|
||||||
|
}
|
||||||
|
setEditableServices(prev => [...prev, newRow])
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleEdit = (service: Service) => {
|
// Удалить строку
|
||||||
setEditingService(service)
|
const removeRow = async (serviceId: string, isNew: boolean) => {
|
||||||
setFormData({
|
if (isNew) {
|
||||||
name: service.name,
|
// Просто удаляем из массива если это новая строка
|
||||||
description: service.description || '',
|
setEditableServices(prev => prev.filter(s => s.id !== serviceId))
|
||||||
price: service.price.toString(),
|
} else {
|
||||||
imageUrl: service.imageUrl || ''
|
// Удаляем существующую запись сразу
|
||||||
})
|
try {
|
||||||
setIsDialogOpen(true)
|
await deleteService({
|
||||||
}
|
variables: { id: serviceId },
|
||||||
|
update: (cache, { data }) => {
|
||||||
const handleDelete = async (serviceId: string) => {
|
// Обновляем кэш Apollo Client
|
||||||
try {
|
const existingData = cache.readQuery({ query: GET_MY_SERVICES }) as { myServices: Service[] } | null
|
||||||
await deleteService({
|
if (existingData && existingData.myServices) {
|
||||||
variables: { id: serviceId }
|
cache.writeQuery({
|
||||||
})
|
query: GET_MY_SERVICES,
|
||||||
await refetch()
|
data: {
|
||||||
toast.success('Услуга успешно удалена')
|
myServices: existingData.myServices.filter((s: Service) => s.id !== serviceId)
|
||||||
} catch (error) {
|
}
|
||||||
console.error('Error deleting service:', error)
|
})
|
||||||
toast.error('Ошибка при удалении услуги')
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Удаляем из локального состояния по 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> => {
|
const uploadImageAndGetUrl = async (file: File): Promise<string> => {
|
||||||
if (!user?.id) throw new Error('User not found')
|
if (!user?.id) throw new Error('User not found')
|
||||||
|
|
||||||
@ -114,175 +217,137 @@ export function ServicesTab() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const result = await response.json()
|
const result = await response.json()
|
||||||
console.log('Upload result:', result)
|
|
||||||
return result.url
|
return result.url
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
// Сохранить все изменения
|
||||||
e.preventDefault()
|
const saveAllChanges = async () => {
|
||||||
|
setIsSaving(true)
|
||||||
if (!formData.name.trim() || !formData.price) {
|
|
||||||
toast.error('Заполните обязательные поля')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
let imageUrl = formData.imageUrl
|
const servicesToSave = editableServices.filter(s => {
|
||||||
|
if (s.isNew) {
|
||||||
// Загружаем изображение если выбрано
|
// Для новых записей проверяем что обязательные поля заполнены
|
||||||
if (imageFile) {
|
return s.name.trim() && s.price
|
||||||
const uploadResult = await uploadImageAndGetUrl(imageFile)
|
}
|
||||||
imageUrl = uploadResult
|
// Для существующих записей проверяем флаг изменений
|
||||||
}
|
return s.hasChanges
|
||||||
|
})
|
||||||
const input = {
|
|
||||||
name: formData.name,
|
|
||||||
description: formData.description || undefined,
|
|
||||||
price: parseFloat(formData.price),
|
|
||||||
imageUrl: imageUrl || undefined
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log('Submitting service with data:', input)
|
console.log('Services to save:', servicesToSave.length, servicesToSave)
|
||||||
|
|
||||||
|
for (const service of servicesToSave) {
|
||||||
|
if (!service.name.trim() || !service.price) {
|
||||||
|
toast.error(`Заполните обязательные поля для всех услуг`)
|
||||||
|
setIsSaving(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if (editingService) {
|
let imageUrl = service.imageUrl
|
||||||
await updateService({
|
|
||||||
variables: { id: editingService.id, input }
|
// Загружаем изображение если выбрано
|
||||||
})
|
if (service.imageFile) {
|
||||||
} else {
|
imageUrl = await uploadImageAndGetUrl(service.imageFile)
|
||||||
await createService({
|
}
|
||||||
variables: { input }
|
|
||||||
})
|
const input = {
|
||||||
|
name: service.name,
|
||||||
|
description: service.description || undefined,
|
||||||
|
price: parseFloat(service.price),
|
||||||
|
imageUrl: imageUrl || undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
if (service.isNew) {
|
||||||
|
await createService({
|
||||||
|
variables: { input },
|
||||||
|
update: (cache, { data }) => {
|
||||||
|
if (data?.createService?.service) {
|
||||||
|
const existingData = cache.readQuery({ query: GET_MY_SERVICES }) as { myServices: Service[] } | null
|
||||||
|
if (existingData) {
|
||||||
|
cache.writeQuery({
|
||||||
|
query: GET_MY_SERVICES,
|
||||||
|
data: {
|
||||||
|
myServices: [...existingData.myServices, data.createService.service]
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
} else if (service.id) {
|
||||||
|
await updateService({
|
||||||
|
variables: { id: service.id, input },
|
||||||
|
update: (cache, { data }) => {
|
||||||
|
if (data?.updateService?.service) {
|
||||||
|
const existingData = cache.readQuery({ query: GET_MY_SERVICES }) as { myServices: Service[] } | null
|
||||||
|
if (existingData) {
|
||||||
|
cache.writeQuery({
|
||||||
|
query: GET_MY_SERVICES,
|
||||||
|
data: {
|
||||||
|
myServices: existingData.myServices.map((s: Service) =>
|
||||||
|
s.id === data.updateService.service.id ? data.updateService.service : s
|
||||||
|
)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
await refetch()
|
// Удаления теперь происходят сразу в removeRow, так что здесь обрабатываем только обновления
|
||||||
setIsDialogOpen(false)
|
|
||||||
resetForm()
|
toast.success('Все изменения успешно сохранены')
|
||||||
|
setPendingChanges([])
|
||||||
toast.success(editingService ? 'Услуга успешно обновлена' : 'Услуга успешно создана')
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error saving service:', error)
|
console.error('Error saving changes:', error)
|
||||||
toast.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 (
|
return (
|
||||||
<div className="h-full flex flex-col">
|
<div className="h-full flex flex-col">
|
||||||
<Card className="flex-1 bg-white/5 backdrop-blur border-white/10 p-6 overflow-hidden">
|
<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 className="flex items-center justify-between mb-6">
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-lg font-semibold text-white mb-1">Мои услуги</h2>
|
<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>
|
</div>
|
||||||
|
|
||||||
<Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
|
<div className="flex gap-3">
|
||||||
<DialogTrigger asChild>
|
<Button
|
||||||
<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 transition-all duration-300"
|
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"
|
||||||
onClick={() => {
|
>
|
||||||
resetForm()
|
<Plus className="w-4 h-4 mr-2" />
|
||||||
setIsDialogOpen(true)
|
Добавить услугу
|
||||||
}}
|
</Button>
|
||||||
>
|
|
||||||
<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">
|
{hasUnsavedChanges && (
|
||||||
<DialogHeader className="pb-6">
|
<Button
|
||||||
<DialogTitle className="text-2xl font-bold bg-gradient-to-r from-purple-300 to-pink-300 bg-clip-text text-transparent">
|
onClick={saveAllChanges}
|
||||||
{editingService ? 'Редактировать услугу' : 'Добавить услугу'}
|
disabled={isSaving}
|
||||||
</DialogTitle>
|
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"
|
||||||
</DialogHeader>
|
>
|
||||||
<form onSubmit={handleSubmit} className="space-y-6">
|
<Save className="w-4 h-4 mr-2" />
|
||||||
<div className="space-y-2">
|
{isSaving ? 'Сохранение...' : 'Сохранить все'}
|
||||||
<Label htmlFor="name" className="text-purple-200 text-sm font-medium">Название услуги *</Label>
|
</Button>
|
||||||
<Input
|
)}
|
||||||
id="name"
|
</div>
|
||||||
value={formData.name}
|
|
||||||
onChange={(e) => setFormData(prev => ({ ...prev, name: e.target.value }))}
|
|
||||||
className="bg-white/5 border-purple-400/30 text-white placeholder:text-purple-300/50 focus:border-purple-300 focus:ring-2 focus:ring-purple-500/20 transition-all"
|
|
||||||
placeholder="Введите название услуги"
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="price" className="text-purple-200 text-sm font-medium">Цена за единицу (₽) *</Label>
|
|
||||||
<Input
|
|
||||||
id="price"
|
|
||||||
type="number"
|
|
||||||
step="0.01"
|
|
||||||
min="0"
|
|
||||||
value={formData.price}
|
|
||||||
onChange={(e) => setFormData(prev => ({ ...prev, price: e.target.value }))}
|
|
||||||
className="bg-white/5 border-purple-400/30 text-white placeholder:text-purple-300/50 focus:border-purple-300 focus:ring-2 focus:ring-purple-500/20 transition-all"
|
|
||||||
placeholder="0.00"
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="description" className="text-purple-200 text-sm font-medium">Описание</Label>
|
|
||||||
<Input
|
|
||||||
id="description"
|
|
||||||
value={formData.description}
|
|
||||||
onChange={(e) => setFormData(prev => ({ ...prev, description: e.target.value }))}
|
|
||||||
className="bg-white/5 border-purple-400/30 text-white placeholder:text-purple-300/50 focus:border-purple-300 focus:ring-2 focus:ring-purple-500/20 transition-all"
|
|
||||||
placeholder="Описание услуги"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label className="text-purple-200 text-sm font-medium">Изображение</Label>
|
|
||||||
<Input
|
|
||||||
type="file"
|
|
||||||
accept="image/*"
|
|
||||||
onChange={(e) => {
|
|
||||||
const file = e.target.files?.[0]
|
|
||||||
if (file) {
|
|
||||||
setImageFile(file)
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
className="bg-white/5 border-purple-400/30 text-white file:bg-gradient-to-r file:from-purple-500 file:to-pink-500 file:text-white file:border-0 file:rounded-lg file:px-4 file:py-2 file:mr-3 focus:border-purple-300 focus:ring-2 focus:ring-purple-500/20 transition-all"
|
|
||||||
/>
|
|
||||||
{formData.imageUrl && (
|
|
||||||
<div className="mt-3">
|
|
||||||
<Image
|
|
||||||
src={formData.imageUrl}
|
|
||||||
alt="Preview"
|
|
||||||
width={80}
|
|
||||||
height={80}
|
|
||||||
className="w-20 h-20 object-cover rounded-lg border border-purple-400/30 shadow-lg"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex gap-3 pt-8">
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="outline"
|
|
||||||
onClick={() => {
|
|
||||||
setIsDialogOpen(false)
|
|
||||||
resetForm()
|
|
||||||
}}
|
|
||||||
className="flex-1 border-purple-400/30 text-purple-200 hover:bg-purple-500/10 hover:border-purple-300 transition-all duration-300"
|
|
||||||
>
|
|
||||||
Отмена
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
type="submit"
|
|
||||||
disabled={loading || isUploading}
|
|
||||||
className="flex-1 bg-gradient-to-r from-purple-600 to-pink-600 hover:from-purple-500 hover:to-pink-500 text-white border-0 shadow-lg shadow-purple-500/25 hover:shadow-purple-500/40 transition-all duration-300"
|
|
||||||
>
|
|
||||||
{loading || isUploading ? 'Сохранение...' : 'Сохранить'}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Таблица услуг */}
|
{/* Таблица услуг */}
|
||||||
@ -318,7 +383,7 @@ export function ServicesTab() {
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : services.length === 0 ? (
|
) : editableServices.length === 0 ? (
|
||||||
<div className="h-full flex items-center justify-center">
|
<div className="h-full flex items-center justify-center">
|
||||||
<div className="text-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">
|
<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>
|
</p>
|
||||||
<Button
|
<Button
|
||||||
onClick={() => {
|
onClick={addNewRow}
|
||||||
resetForm()
|
|
||||||
setIsDialogOpen(true)
|
|
||||||
}}
|
|
||||||
className="bg-gradient-to-r from-purple-500 to-purple-600 hover:from-purple-600 hover:to-purple-700 text-white"
|
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" />
|
<Plus className="w-4 h-4 mr-2" />
|
||||||
@ -347,54 +409,184 @@ export function ServicesTab() {
|
|||||||
<tr>
|
<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>
|
<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>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{services.map((service: Service, index: number) => (
|
{editableServices.map((service, index) => (
|
||||||
<tr key={service.id} className="border-t border-white/10 hover:bg-white/5">
|
<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 text-white/80">{index + 1}</td>
|
||||||
<td className="p-4">
|
|
||||||
{service.imageUrl ? (
|
{/* Фото */}
|
||||||
<Image
|
<td className="p-4 relative">
|
||||||
src={service.imageUrl}
|
{service.isEditing ? (
|
||||||
alt={service.name}
|
<div className="flex items-center gap-3">
|
||||||
width={48}
|
<Input
|
||||||
height={48}
|
type="file"
|
||||||
className="w-12 h-12 object-cover rounded border border-white/20"
|
accept="image/*"
|
||||||
onError={(e) => {
|
onChange={(e) => {
|
||||||
console.error('Image failed to load:', service.imageUrl, e)
|
const file = e.target.files?.[0]
|
||||||
}}
|
if (file) {
|
||||||
onLoad={() => console.log('Image loaded successfully:', service.imageUrl)}
|
updateField(service.id!, 'imageFile', file)
|
||||||
/>
|
}
|
||||||
|
}}
|
||||||
|
className="bg-white/5 border-white/20 text-white text-xs file:bg-gradient-to-r file:from-purple-500 file:to-pink-500 file:text-white file:border-0 file:rounded file:px-2 file:py-1 file:mr-2 file:text-xs flex-1"
|
||||||
|
/>
|
||||||
|
{service.imageUrl && (
|
||||||
|
<div className="relative group w-12 h-12 flex-shrink-0">
|
||||||
|
<Image
|
||||||
|
src={service.imageUrl}
|
||||||
|
alt="Preview"
|
||||||
|
width={48}
|
||||||
|
height={48}
|
||||||
|
className="w-12 h-12 object-cover rounded border border-white/20 cursor-pointer transition-all duration-300 group-hover:ring-2 group-hover:ring-purple-400/50"
|
||||||
|
/>
|
||||||
|
{/* Увеличенная версия при hover */}
|
||||||
|
<div className="absolute top-0 left-0 opacity-0 group-hover:opacity-100 transition-all duration-300 pointer-events-none z-50 transform group-hover:scale-100 scale-75">
|
||||||
|
<div className="relative">
|
||||||
|
<Image
|
||||||
|
src={service.imageUrl}
|
||||||
|
alt="Preview"
|
||||||
|
width={200}
|
||||||
|
height={200}
|
||||||
|
className="w-50 h-50 object-cover rounded-lg border-2 border-purple-400 shadow-2xl shadow-purple-500/30 bg-black/90 backdrop-blur"
|
||||||
|
/>
|
||||||
|
<div className="absolute inset-0 bg-gradient-to-t from-black/20 to-transparent rounded-lg"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : service.imageUrl ? (
|
||||||
|
<div className="relative group w-12 h-12">
|
||||||
|
<Image
|
||||||
|
src={service.imageUrl}
|
||||||
|
alt={service.name}
|
||||||
|
width={48}
|
||||||
|
height={48}
|
||||||
|
className="w-12 h-12 object-cover rounded border border-white/20 cursor-pointer transition-all duration-300 group-hover:ring-2 group-hover:ring-purple-400/50"
|
||||||
|
/>
|
||||||
|
{/* Увеличенная версия при hover */}
|
||||||
|
<div className="absolute top-0 left-0 opacity-0 group-hover:opacity-100 transition-all duration-300 pointer-events-none z-50 transform group-hover:scale-100 scale-75">
|
||||||
|
<div className="relative">
|
||||||
|
<Image
|
||||||
|
src={service.imageUrl}
|
||||||
|
alt={service.name}
|
||||||
|
width={240}
|
||||||
|
height={240}
|
||||||
|
className="w-60 h-60 object-cover rounded-lg border-2 border-purple-400 shadow-2xl shadow-purple-500/30 bg-black/90 backdrop-blur"
|
||||||
|
/>
|
||||||
|
<div className="absolute inset-0 bg-gradient-to-t from-black/20 to-transparent rounded-lg"></div>
|
||||||
|
<div className="absolute bottom-2 left-2 right-2 bg-black/60 backdrop-blur rounded px-2 py-1">
|
||||||
|
<p className="text-white text-xs font-medium truncate">{service.name}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="w-12 h-12 bg-white/10 rounded flex items-center justify-center">
|
<div className="w-12 h-12 bg-white/10 rounded flex items-center justify-center">
|
||||||
<Upload className="w-5 h-5 text-white/50" />
|
<Upload className="w-5 h-5 text-white/50" />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</td>
|
</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">
|
<td className="p-4">
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<Button
|
{service.isEditing ? (
|
||||||
size="sm"
|
<>
|
||||||
variant="outline"
|
<Button
|
||||||
onClick={() => handleEdit(service)}
|
size="sm"
|
||||||
className="border-white/20 text-white hover:bg-white/10"
|
onClick={() => {
|
||||||
>
|
// Сохраняем только эту услугу если заполнены обязательные поля
|
||||||
<Edit className="w-4 h-4" />
|
if (service.name.trim() && service.price) {
|
||||||
</Button>
|
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>
|
<AlertDialog>
|
||||||
<AlertDialogTrigger asChild>
|
<AlertDialogTrigger asChild>
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="outline"
|
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"
|
||||||
className="border-red-500/50 text-red-400 hover:bg-red-500/10"
|
title="Удалить"
|
||||||
>
|
>
|
||||||
<Trash2 className="w-4 h-4" />
|
<Trash2 className="w-4 h-4" />
|
||||||
</Button>
|
</Button>
|
||||||
@ -413,7 +605,7 @@ export function ServicesTab() {
|
|||||||
Отмена
|
Отмена
|
||||||
</AlertDialogCancel>
|
</AlertDialogCancel>
|
||||||
<AlertDialogAction
|
<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"
|
className="bg-gradient-to-r from-red-600 to-red-700 hover:from-red-700 hover:to-red-800 text-white border-0 shadow-lg shadow-red-500/25 hover:shadow-red-500/40 transition-all duration-300"
|
||||||
>
|
>
|
||||||
Удалить
|
Удалить
|
||||||
@ -433,4 +625,4 @@ export function ServicesTab() {
|
|||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
@ -1,13 +1,11 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { useState } from 'react'
|
import { useState, useEffect, useMemo } from 'react'
|
||||||
import { useQuery, useMutation } from '@apollo/client'
|
import { useQuery, useMutation } from '@apollo/client'
|
||||||
import { Card } from '@/components/ui/card'
|
import { Card } from '@/components/ui/card'
|
||||||
import Image from 'next/image'
|
import Image from 'next/image'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Input } from '@/components/ui/input'
|
import { Input } from '@/components/ui/input'
|
||||||
import { Label } from '@/components/ui/label'
|
|
||||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog'
|
|
||||||
import {
|
import {
|
||||||
AlertDialog,
|
AlertDialog,
|
||||||
AlertDialogAction,
|
AlertDialogAction,
|
||||||
@ -19,7 +17,7 @@ import {
|
|||||||
AlertDialogTitle,
|
AlertDialogTitle,
|
||||||
AlertDialogTrigger
|
AlertDialogTrigger
|
||||||
} from '@/components/ui/alert-dialog'
|
} 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 { toast } from "sonner"
|
||||||
import { useAuth } from '@/hooks/useAuth'
|
import { useAuth } from '@/hooks/useAuth'
|
||||||
import { GET_MY_SUPPLIES } from '@/graphql/queries'
|
import { GET_MY_SUPPLIES } from '@/graphql/queries'
|
||||||
@ -30,25 +28,33 @@ interface Supply {
|
|||||||
name: string
|
name: string
|
||||||
description?: string
|
description?: string
|
||||||
price: number
|
price: number
|
||||||
quantity: number
|
|
||||||
imageUrl?: string
|
imageUrl?: string
|
||||||
createdAt: string
|
createdAt: string
|
||||||
updatedAt: 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() {
|
export function SuppliesTab() {
|
||||||
const { user } = useAuth()
|
const { user } = useAuth()
|
||||||
const [isDialogOpen, setIsDialogOpen] = useState(false)
|
const [editableSupplies, setEditableSupplies] = useState<EditableSupply[]>([])
|
||||||
const [editingSupply, setEditingSupply] = useState<Supply | null>(null)
|
const [pendingChanges, setPendingChanges] = useState<PendingChange[]>([])
|
||||||
const [formData, setFormData] = useState({
|
const [isSaving, setIsSaving] = useState(false)
|
||||||
name: '',
|
const [isInitialized, setIsInitialized] = useState(false)
|
||||||
description: '',
|
|
||||||
price: '',
|
|
||||||
quantity: '',
|
|
||||||
imageUrl: ''
|
|
||||||
})
|
|
||||||
const [imageFile, setImageFile] = useState<File | null>(null)
|
|
||||||
const [isUploading] = useState(false)
|
|
||||||
|
|
||||||
// GraphQL запросы и мутации
|
// GraphQL запросы и мутации
|
||||||
const { data, loading, error, refetch } = useQuery(GET_MY_SUPPLIES, {
|
const { data, loading, error, refetch } = useQuery(GET_MY_SUPPLIES, {
|
||||||
@ -60,45 +66,139 @@ export function SuppliesTab() {
|
|||||||
|
|
||||||
const supplies = data?.mySupplies || []
|
const supplies = data?.mySupplies || []
|
||||||
|
|
||||||
const resetForm = () => {
|
// Преобразуем загруженные расходники в редактируемый формат
|
||||||
setFormData({
|
useEffect(() => {
|
||||||
|
if (data?.mySupplies && !isInitialized) {
|
||||||
|
const convertedSupplies: EditableSupply[] = data.mySupplies.map((supply: Supply) => ({
|
||||||
|
id: supply.id,
|
||||||
|
name: supply.name,
|
||||||
|
description: supply.description || '',
|
||||||
|
price: supply.price.toString(),
|
||||||
|
imageUrl: supply.imageUrl || '',
|
||||||
|
isNew: false,
|
||||||
|
isEditing: false,
|
||||||
|
hasChanges: false
|
||||||
|
}))
|
||||||
|
|
||||||
|
setEditableSupplies(convertedSupplies)
|
||||||
|
setPendingChanges([])
|
||||||
|
setIsInitialized(true)
|
||||||
|
}
|
||||||
|
}, [data, isInitialized])
|
||||||
|
|
||||||
|
// Добавить новую строку
|
||||||
|
const addNewRow = () => {
|
||||||
|
const tempId = `temp-${Date.now()}-${Math.random()}`
|
||||||
|
const newRow: EditableSupply = {
|
||||||
|
id: tempId,
|
||||||
name: '',
|
name: '',
|
||||||
description: '',
|
description: '',
|
||||||
price: '',
|
price: '',
|
||||||
quantity: '',
|
imageUrl: '',
|
||||||
imageUrl: ''
|
isNew: true,
|
||||||
})
|
isEditing: true,
|
||||||
setImageFile(null)
|
hasChanges: false
|
||||||
setEditingSupply(null)
|
}
|
||||||
|
setEditableSupplies(prev => [...prev, newRow])
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleEdit = (supply: Supply) => {
|
// Удалить строку
|
||||||
setEditingSupply(supply)
|
const removeRow = async (supplyId: string, isNew: boolean) => {
|
||||||
setFormData({
|
if (isNew) {
|
||||||
name: supply.name,
|
// Просто удаляем из массива если это новая строка
|
||||||
description: supply.description || '',
|
setEditableSupplies(prev => prev.filter(s => s.id !== supplyId))
|
||||||
price: supply.price.toString(),
|
} else {
|
||||||
quantity: supply.quantity.toString(),
|
// Удаляем существующую запись сразу
|
||||||
imageUrl: supply.imageUrl || ''
|
try {
|
||||||
})
|
await deleteSupply({
|
||||||
setIsDialogOpen(true)
|
variables: { id: supplyId },
|
||||||
}
|
update: (cache, { data }) => {
|
||||||
|
// Обновляем кэш Apollo Client
|
||||||
const handleDelete = async (supplyId: string) => {
|
const existingData = cache.readQuery({ query: GET_MY_SUPPLIES }) as { mySupplies: Supply[] } | null
|
||||||
try {
|
if (existingData && existingData.mySupplies) {
|
||||||
await deleteSupply({
|
cache.writeQuery({
|
||||||
variables: { id: supplyId }
|
query: GET_MY_SUPPLIES,
|
||||||
})
|
data: {
|
||||||
await refetch()
|
mySupplies: existingData.mySupplies.filter((s: Supply) => s.id !== supplyId)
|
||||||
toast.success('Расходник успешно удален')
|
}
|
||||||
} catch (error) {
|
})
|
||||||
console.error('Error deleting supply:', error)
|
}
|
||||||
toast.error('Ошибка при удалении расходника')
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Удаляем из локального состояния по 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> => {
|
const uploadImageAndGetUrl = async (file: File): Promise<string> => {
|
||||||
if (!user?.id) throw new Error('User not found')
|
if (!user?.id) throw new Error('User not found')
|
||||||
|
|
||||||
@ -117,246 +217,184 @@ export function SuppliesTab() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const result = await response.json()
|
const result = await response.json()
|
||||||
console.log('Upload result:', result)
|
|
||||||
return result.url
|
return result.url
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
// Сохранить все изменения
|
||||||
e.preventDefault()
|
const saveAllChanges = async () => {
|
||||||
|
setIsSaving(true)
|
||||||
if (!formData.name.trim() || !formData.price || !formData.quantity) {
|
|
||||||
toast.error('Заполните обязательные поля')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const quantity = parseInt(formData.quantity)
|
|
||||||
if (quantity < 0) {
|
|
||||||
toast.error('Количество не может быть отрицательным')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
let imageUrl = formData.imageUrl
|
const suppliesToSave = editableSupplies.filter(s => {
|
||||||
|
if (s.isNew) {
|
||||||
// Загружаем изображение если выбрано
|
// Для новых записей проверяем что обязательные поля заполнены
|
||||||
if (imageFile) {
|
return s.name.trim() && s.price
|
||||||
const uploadResult = await uploadImageAndGetUrl(imageFile)
|
}
|
||||||
imageUrl = uploadResult
|
// Для существующих записей проверяем флаг изменений
|
||||||
}
|
return s.hasChanges
|
||||||
|
})
|
||||||
const input = {
|
|
||||||
name: formData.name,
|
|
||||||
description: formData.description || undefined,
|
|
||||||
price: parseFloat(formData.price),
|
|
||||||
quantity: quantity,
|
|
||||||
imageUrl: imageUrl || undefined
|
|
||||||
}
|
|
||||||
|
|
||||||
if (editingSupply) {
|
|
||||||
await updateSupply({
|
|
||||||
variables: { id: editingSupply.id, input }
|
|
||||||
})
|
|
||||||
} else {
|
|
||||||
await createSupply({
|
|
||||||
variables: { input }
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
await refetch()
|
|
||||||
setIsDialogOpen(false)
|
|
||||||
resetForm()
|
|
||||||
|
|
||||||
toast.success(editingSupply ? 'Расходник успешно обновлен' : 'Расходник успешно создан')
|
console.log('Supplies to save:', suppliesToSave.length, suppliesToSave)
|
||||||
|
|
||||||
|
for (const supply of suppliesToSave) {
|
||||||
|
if (!supply.name.trim() || !supply.price) {
|
||||||
|
toast.error(`Заполните обязательные поля для всех расходников`)
|
||||||
|
setIsSaving(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let imageUrl = supply.imageUrl
|
||||||
|
|
||||||
|
// Загружаем изображение если выбрано
|
||||||
|
if (supply.imageFile) {
|
||||||
|
imageUrl = await uploadImageAndGetUrl(supply.imageFile)
|
||||||
|
}
|
||||||
|
|
||||||
|
const input = {
|
||||||
|
name: supply.name,
|
||||||
|
description: supply.description || undefined,
|
||||||
|
price: parseFloat(supply.price),
|
||||||
|
imageUrl: imageUrl || undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
if (supply.isNew) {
|
||||||
|
await createSupply({
|
||||||
|
variables: { input },
|
||||||
|
update: (cache, { data }) => {
|
||||||
|
if (data?.createSupply?.supply) {
|
||||||
|
const existingData = cache.readQuery({ query: GET_MY_SUPPLIES }) as { mySupplies: Supply[] } | null
|
||||||
|
if (existingData) {
|
||||||
|
cache.writeQuery({
|
||||||
|
query: GET_MY_SUPPLIES,
|
||||||
|
data: {
|
||||||
|
mySupplies: [...existingData.mySupplies, data.createSupply.supply]
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
} else if (supply.id) {
|
||||||
|
await updateSupply({
|
||||||
|
variables: { id: supply.id, input },
|
||||||
|
update: (cache, { data }) => {
|
||||||
|
if (data?.updateSupply?.supply) {
|
||||||
|
const existingData = cache.readQuery({ query: GET_MY_SUPPLIES }) as { mySupplies: Supply[] } | null
|
||||||
|
if (existingData) {
|
||||||
|
cache.writeQuery({
|
||||||
|
query: GET_MY_SUPPLIES,
|
||||||
|
data: {
|
||||||
|
mySupplies: existingData.mySupplies.map((s: Supply) =>
|
||||||
|
s.id === data.updateSupply.supply.id ? data.updateSupply.supply : s
|
||||||
|
)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Удаления теперь происходят сразу в removeRow, так что здесь обрабатываем только обновления
|
||||||
|
|
||||||
|
toast.success('Все изменения успешно сохранены')
|
||||||
|
setPendingChanges([])
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error saving supply:', error)
|
console.error('Error saving changes:', error)
|
||||||
toast.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 (
|
return (
|
||||||
<div className="h-full flex flex-col">
|
<div className="h-full flex flex-col">
|
||||||
<Card className="flex-1 bg-white/5 backdrop-blur border-white/10 p-6 overflow-hidden">
|
<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 className="flex items-center justify-between mb-6">
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-lg font-semibold text-white mb-1">Мои расходники</h2>
|
<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>
|
</div>
|
||||||
|
|
||||||
<Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
|
<div className="flex gap-3">
|
||||||
<DialogTrigger asChild>
|
<Button
|
||||||
<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 transition-all duration-300"
|
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"
|
||||||
onClick={() => {
|
>
|
||||||
resetForm()
|
<Plus className="w-4 h-4 mr-2" />
|
||||||
setIsDialogOpen(true)
|
Добавить расходник
|
||||||
}}
|
</Button>
|
||||||
>
|
|
||||||
<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">
|
{hasUnsavedChanges && (
|
||||||
<DialogHeader className="pb-6">
|
<Button
|
||||||
<DialogTitle className="text-2xl font-bold bg-gradient-to-r from-purple-300 to-pink-300 bg-clip-text text-transparent">
|
onClick={saveAllChanges}
|
||||||
{editingSupply ? 'Редактировать расходник' : 'Добавить расходник'}
|
disabled={isSaving}
|
||||||
</DialogTitle>
|
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"
|
||||||
</DialogHeader>
|
>
|
||||||
<form onSubmit={handleSubmit} className="space-y-6">
|
<Save className="w-4 h-4 mr-2" />
|
||||||
<div className="space-y-2">
|
{isSaving ? 'Сохранение...' : 'Сохранить все'}
|
||||||
<Label htmlFor="name" className="text-purple-200 text-sm font-medium">Название расходника *</Label>
|
</Button>
|
||||||
<Input
|
)}
|
||||||
id="name"
|
</div>
|
||||||
value={formData.name}
|
</div>
|
||||||
onChange={(e) => setFormData(prev => ({ ...prev, name: e.target.value }))}
|
|
||||||
className="bg-white/5 border-purple-400/30 text-white placeholder:text-purple-300/50 focus:border-purple-300 focus:ring-2 focus:ring-purple-500/20 transition-all"
|
|
||||||
placeholder="Введите название расходника"
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-3">
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="price" className="text-purple-200 text-sm font-medium">Цена за единицу (₽) *</Label>
|
|
||||||
<Input
|
|
||||||
id="price"
|
|
||||||
type="number"
|
|
||||||
step="0.01"
|
|
||||||
min="0"
|
|
||||||
value={formData.price}
|
|
||||||
onChange={(e) => setFormData(prev => ({ ...prev, price: e.target.value }))}
|
|
||||||
className="bg-white/5 border-purple-400/30 text-white placeholder:text-purple-300/50 focus:border-purple-300 focus:ring-2 focus:ring-purple-500/20 transition-all"
|
|
||||||
placeholder="0.00"
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="quantity" className="text-purple-200 text-sm font-medium">Количество *</Label>
|
|
||||||
<Input
|
|
||||||
id="quantity"
|
|
||||||
type="number"
|
|
||||||
min="0"
|
|
||||||
value={formData.quantity}
|
|
||||||
onChange={(e) => setFormData(prev => ({ ...prev, quantity: e.target.value }))}
|
|
||||||
className="bg-white/5 border-purple-400/30 text-white placeholder:text-purple-300/50 focus:border-purple-300 focus:ring-2 focus:ring-purple-500/20 transition-all"
|
|
||||||
placeholder="0"
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="description" className="text-purple-200 text-sm font-medium">Описание</Label>
|
|
||||||
<Input
|
|
||||||
id="description"
|
|
||||||
value={formData.description}
|
|
||||||
onChange={(e) => setFormData(prev => ({ ...prev, description: e.target.value }))}
|
|
||||||
className="bg-white/5 border-purple-400/30 text-white placeholder:text-purple-300/50 focus:border-purple-300 focus:ring-2 focus:ring-purple-500/20 transition-all"
|
|
||||||
placeholder="Описание расходника"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label className="text-purple-200 text-sm font-medium">Изображение</Label>
|
|
||||||
<Input
|
|
||||||
type="file"
|
|
||||||
accept="image/*"
|
|
||||||
onChange={(e) => {
|
|
||||||
const file = e.target.files?.[0]
|
|
||||||
if (file) {
|
|
||||||
setImageFile(file)
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
className="bg-white/5 border-purple-400/30 text-white file:bg-gradient-to-r file:from-purple-500 file:to-pink-500 file:text-white file:border-0 file:rounded-lg file:px-4 file:py-2 file:mr-3 focus:border-purple-300 focus:ring-2 focus:ring-purple-500/20 transition-all"
|
|
||||||
/>
|
|
||||||
{formData.imageUrl && (
|
|
||||||
<div className="mt-3">
|
|
||||||
<Image
|
|
||||||
src={formData.imageUrl}
|
|
||||||
alt="Preview"
|
|
||||||
width={80}
|
|
||||||
height={80}
|
|
||||||
className="w-20 h-20 object-cover rounded-lg border border-purple-400/30 shadow-lg"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex gap-3 pt-8">
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="outline"
|
|
||||||
onClick={() => {
|
|
||||||
setIsDialogOpen(false)
|
|
||||||
resetForm()
|
|
||||||
}}
|
|
||||||
className="flex-1 border-purple-400/30 text-purple-200 hover:bg-purple-500/10 hover:border-purple-300 transition-all duration-300"
|
|
||||||
>
|
|
||||||
Отмена
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
type="submit"
|
|
||||||
disabled={loading || isUploading}
|
|
||||||
className="flex-1 bg-gradient-to-r from-purple-600 to-pink-600 hover:from-purple-500 hover:to-pink-500 text-white border-0 shadow-lg shadow-purple-500/25 hover:shadow-purple-500/40 transition-all duration-300"
|
|
||||||
>
|
|
||||||
{loading || isUploading ? 'Сохранение...' : 'Сохранить'}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Таблица расходников */}
|
{/* Таблица расходников */}
|
||||||
<div className="overflow-auto flex-1">
|
<div className="overflow-auto flex-1">
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div className="h-full flex items-center justify-center">
|
<div className="h-full flex items-center justify-center">
|
||||||
<div className="text-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">
|
<div className="w-16 h-16 bg-white/10 rounded-full flex items-center justify-center mx-auto mb-4 animate-spin">
|
||||||
<svg className="w-8 h-8 text-white/50" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg className="w-8 h-8 text-white/50" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-white/70 text-sm">Загрузка расходников...</p>
|
<p className="text-white/70 text-sm">Загрузка расходников...</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : error ? (
|
) : error ? (
|
||||||
<div className="h-full flex items-center justify-center">
|
<div className="h-full flex items-center justify-center">
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<div className="w-16 h-16 bg-red-500/20 rounded-full flex items-center justify-center mx-auto mb-4">
|
<div className="w-16 h-16 bg-red-500/20 rounded-full flex items-center justify-center mx-auto mb-4">
|
||||||
<svg className="w-8 h-8 text-red-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg className="w-8 h-8 text-red-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<h3 className="text-lg font-semibold text-white mb-2">Ошибка загрузки</h3>
|
<h3 className="text-lg font-semibold text-white mb-2">Ошибка загрузки</h3>
|
||||||
<p className="text-white/70 text-sm mb-4">
|
<p className="text-white/70 text-sm mb-4">
|
||||||
Не удалось загрузить расходники
|
Не удалось загрузить расходники
|
||||||
</p>
|
</p>
|
||||||
<Button
|
<Button
|
||||||
onClick={() => refetch()}
|
onClick={() => refetch()}
|
||||||
className="bg-gradient-to-r from-purple-500 to-purple-600 hover:from-purple-600 hover:to-purple-700 text-white"
|
className="bg-gradient-to-r from-purple-500 to-purple-600 hover:from-purple-600 hover:to-purple-700 text-white"
|
||||||
>
|
>
|
||||||
Попробовать снова
|
Попробовать снова
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : supplies.length === 0 ? (
|
) : editableSupplies.length === 0 ? (
|
||||||
<div className="h-full flex items-center justify-center">
|
<div className="h-full flex items-center justify-center">
|
||||||
<div className="text-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">
|
<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>
|
</div>
|
||||||
<h3 className="text-lg font-semibold text-white mb-2">Пока нет расходников</h3>
|
<h3 className="text-lg font-semibold text-white mb-2">Пока нет расходников</h3>
|
||||||
<p className="text-white/70 text-sm mb-4">
|
<p className="text-white/70 text-sm mb-4">
|
||||||
Добавьте свой первый расходник для управления складскими запасами
|
Создайте свой первый расходник, чтобы начать работу
|
||||||
</p>
|
</p>
|
||||||
<Button
|
<Button
|
||||||
onClick={() => {
|
onClick={addNewRow}
|
||||||
resetForm()
|
|
||||||
setIsDialogOpen(true)
|
|
||||||
}}
|
|
||||||
className="bg-gradient-to-r from-purple-500 to-purple-600 hover:from-purple-600 hover:to-purple-700 text-white"
|
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" />
|
<Plus className="w-4 h-4 mr-2" />
|
||||||
@ -371,66 +409,184 @@ export function SuppliesTab() {
|
|||||||
<tr>
|
<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>
|
|
||||||
<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>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{supplies.map((supply: Supply, index: number) => (
|
{editableSupplies.map((supply, index) => (
|
||||||
<tr key={supply.id} className="border-t border-white/10 hover:bg-white/5">
|
<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 text-white/80">{index + 1}</td>
|
||||||
<td className="p-4">
|
|
||||||
{supply.imageUrl ? (
|
{/* Фото */}
|
||||||
<Image
|
<td className="p-4 relative">
|
||||||
src={supply.imageUrl}
|
{supply.isEditing ? (
|
||||||
alt={supply.name}
|
<div className="flex items-center gap-3">
|
||||||
width={48}
|
<Input
|
||||||
height={48}
|
type="file"
|
||||||
className="w-12 h-12 object-cover rounded border border-white/20"
|
accept="image/*"
|
||||||
/>
|
onChange={(e) => {
|
||||||
|
const file = e.target.files?.[0]
|
||||||
|
if (file) {
|
||||||
|
updateField(supply.id!, 'imageFile', file)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="bg-white/5 border-white/20 text-white text-xs file:bg-gradient-to-r file:from-purple-500 file:to-pink-500 file:text-white file:border-0 file:rounded file:px-2 file:py-1 file:mr-2 file:text-xs flex-1"
|
||||||
|
/>
|
||||||
|
{supply.imageUrl && (
|
||||||
|
<div className="relative group w-12 h-12 flex-shrink-0">
|
||||||
|
<Image
|
||||||
|
src={supply.imageUrl}
|
||||||
|
alt="Preview"
|
||||||
|
width={48}
|
||||||
|
height={48}
|
||||||
|
className="w-12 h-12 object-cover rounded border border-white/20 cursor-pointer transition-all duration-300 group-hover:ring-2 group-hover:ring-purple-400/50"
|
||||||
|
/>
|
||||||
|
{/* Увеличенная версия при hover */}
|
||||||
|
<div className="absolute top-0 left-0 opacity-0 group-hover:opacity-100 transition-all duration-300 pointer-events-none z-50 transform group-hover:scale-100 scale-75">
|
||||||
|
<div className="relative">
|
||||||
|
<Image
|
||||||
|
src={supply.imageUrl}
|
||||||
|
alt="Preview"
|
||||||
|
width={200}
|
||||||
|
height={200}
|
||||||
|
className="w-50 h-50 object-cover rounded-lg border-2 border-purple-400 shadow-2xl shadow-purple-500/30 bg-black/90 backdrop-blur"
|
||||||
|
/>
|
||||||
|
<div className="absolute inset-0 bg-gradient-to-t from-black/20 to-transparent rounded-lg"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : supply.imageUrl ? (
|
||||||
|
<div className="relative group w-12 h-12">
|
||||||
|
<Image
|
||||||
|
src={supply.imageUrl}
|
||||||
|
alt={supply.name}
|
||||||
|
width={48}
|
||||||
|
height={48}
|
||||||
|
className="w-12 h-12 object-cover rounded border border-white/20 cursor-pointer transition-all duration-300 group-hover:ring-2 group-hover:ring-purple-400/50"
|
||||||
|
/>
|
||||||
|
{/* Увеличенная версия при hover */}
|
||||||
|
<div className="absolute top-0 left-0 opacity-0 group-hover:opacity-100 transition-all duration-300 pointer-events-none z-50 transform group-hover:scale-100 scale-75">
|
||||||
|
<div className="relative">
|
||||||
|
<Image
|
||||||
|
src={supply.imageUrl}
|
||||||
|
alt={supply.name}
|
||||||
|
width={240}
|
||||||
|
height={240}
|
||||||
|
className="w-60 h-60 object-cover rounded-lg border-2 border-purple-400 shadow-2xl shadow-purple-500/30 bg-black/90 backdrop-blur"
|
||||||
|
/>
|
||||||
|
<div className="absolute inset-0 bg-gradient-to-t from-black/20 to-transparent rounded-lg"></div>
|
||||||
|
<div className="absolute bottom-2 left-2 right-2 bg-black/60 backdrop-blur rounded px-2 py-1">
|
||||||
|
<p className="text-white text-xs font-medium truncate">{supply.name}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="w-12 h-12 bg-white/10 rounded flex items-center justify-center">
|
<div className="w-12 h-12 bg-white/10 rounded flex items-center justify-center">
|
||||||
<Upload className="w-5 h-5 text-white/50" />
|
<Upload className="w-5 h-5 text-white/50" />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</td>
|
</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">
|
<td className="p-4">
|
||||||
<div className="flex items-center gap-2">
|
{supply.isEditing ? (
|
||||||
<span className="text-white/80">{supply.quantity} шт.</span>
|
<Input
|
||||||
{supply.quantity <= 10 && (
|
value={supply.name}
|
||||||
<span className="text-xs bg-yellow-500/20 text-yellow-400 px-2 py-1 rounded-full">
|
onChange={(e) => updateField(supply.id!, 'name', e.target.value)}
|
||||||
Мало
|
className="bg-white/5 border-white/20 text-white"
|
||||||
</span>
|
placeholder="Название расходника"
|
||||||
)}
|
/>
|
||||||
{supply.quantity === 0 && (
|
) : (
|
||||||
<span className="text-xs bg-red-500/20 text-red-400 px-2 py-1 rounded-full">
|
<span className="text-white font-medium">{supply.name}</span>
|
||||||
Нет в наличии
|
)}
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</td>
|
</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">
|
<td className="p-4">
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<Button
|
{supply.isEditing ? (
|
||||||
size="sm"
|
<>
|
||||||
variant="outline"
|
<Button
|
||||||
onClick={() => handleEdit(supply)}
|
size="sm"
|
||||||
className="border-white/20 text-white hover:bg-white/10"
|
onClick={() => {
|
||||||
>
|
// Сохраняем только этот расходник если заполнены обязательные поля
|
||||||
<Edit className="w-4 h-4" />
|
if (supply.name.trim() && supply.price) {
|
||||||
</Button>
|
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>
|
<AlertDialog>
|
||||||
<AlertDialogTrigger asChild>
|
<AlertDialogTrigger asChild>
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="outline"
|
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"
|
||||||
className="border-red-500/50 text-red-400 hover:bg-red-500/10"
|
title="Удалить"
|
||||||
>
|
>
|
||||||
<Trash2 className="w-4 h-4" />
|
<Trash2 className="w-4 h-4" />
|
||||||
</Button>
|
</Button>
|
||||||
@ -449,7 +605,7 @@ export function SuppliesTab() {
|
|||||||
Отмена
|
Отмена
|
||||||
</AlertDialogCancel>
|
</AlertDialogCancel>
|
||||||
<AlertDialogAction
|
<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"
|
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>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
301
src/components/supplies/create-supply-form.tsx
Normal file
301
src/components/supplies/create-supply-form.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
782
src/components/supplies/create-supply-page.tsx
Normal file
782
src/components/supplies/create-supply-page.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
724
src/components/supplies/supplies-dashboard.tsx
Normal file
724
src/components/supplies/supplies-dashboard.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
425
src/components/supplies/wholesaler-products.tsx
Normal file
425
src/components/supplies/wholesaler-products.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
260
src/components/supplies/wholesaler-selection.tsx
Normal file
260
src/components/supplies/wholesaler-selection.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
@ -602,7 +602,6 @@ export const CREATE_SUPPLY = gql`
|
|||||||
name
|
name
|
||||||
description
|
description
|
||||||
price
|
price
|
||||||
quantity
|
|
||||||
imageUrl
|
imageUrl
|
||||||
createdAt
|
createdAt
|
||||||
updatedAt
|
updatedAt
|
||||||
@ -621,7 +620,6 @@ export const UPDATE_SUPPLY = gql`
|
|||||||
name
|
name
|
||||||
description
|
description
|
||||||
price
|
price
|
||||||
quantity
|
|
||||||
imageUrl
|
imageUrl
|
||||||
createdAt
|
createdAt
|
||||||
updatedAt
|
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`
|
export const CREATE_PRODUCT = gql`
|
||||||
mutation CreateProduct($input: ProductInput!) {
|
mutation CreateProduct($input: ProductInput!) {
|
||||||
|
@ -69,7 +69,6 @@ export const GET_MY_SUPPLIES = gql`
|
|||||||
name
|
name
|
||||||
description
|
description
|
||||||
price
|
price
|
||||||
quantity
|
|
||||||
imageUrl
|
imageUrl
|
||||||
createdAt
|
createdAt
|
||||||
updatedAt
|
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`
|
export const GET_MY_PRODUCTS = gql`
|
||||||
query GetMyProducts {
|
query GetMyProducts {
|
||||||
myProducts {
|
myProducts {
|
||||||
|
@ -530,12 +530,31 @@ export const resolvers = {
|
|||||||
throw new GraphQLError('У пользователя нет организации')
|
throw new GraphQLError('У пользователя нет организации')
|
||||||
}
|
}
|
||||||
|
|
||||||
// Проверяем, что это фулфилмент центр
|
return await prisma.supply.findMany({
|
||||||
if (currentUser.organization.type !== 'FULFILLMENT') {
|
where: { organizationId: currentUser.organization.id },
|
||||||
throw new GraphQLError('Расходники доступны только для фулфилмент центров')
|
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 },
|
where: { organizationId: currentUser.organization.id },
|
||||||
include: { organization: true },
|
include: { organization: true },
|
||||||
orderBy: { createdAt: 'desc' }
|
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) {
|
if (!context.user) {
|
||||||
throw new GraphQLError('Требуется авторизация', {
|
throw new GraphQLError('Требуется авторизация', {
|
||||||
extensions: { code: 'UNAUTHENTICATED' }
|
extensions: { code: 'UNAUTHENTICATED' }
|
||||||
@ -2349,7 +2368,7 @@ export const resolvers = {
|
|||||||
name: args.input.name,
|
name: args.input.name,
|
||||||
description: args.input.description,
|
description: args.input.description,
|
||||||
price: args.input.price,
|
price: args.input.price,
|
||||||
quantity: args.input.quantity,
|
quantity: 0, // Временно устанавливаем 0, так как поле убрано из интерфейса
|
||||||
imageUrl: args.input.imageUrl,
|
imageUrl: args.input.imageUrl,
|
||||||
organizationId: currentUser.organization.id
|
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) {
|
if (!context.user) {
|
||||||
throw new GraphQLError('Требуется авторизация', {
|
throw new GraphQLError('Требуется авторизация', {
|
||||||
extensions: { code: 'UNAUTHENTICATED' }
|
extensions: { code: 'UNAUTHENTICATED' }
|
||||||
@ -2406,7 +2425,7 @@ export const resolvers = {
|
|||||||
name: args.input.name,
|
name: args.input.name,
|
||||||
description: args.input.description,
|
description: args.input.description,
|
||||||
price: args.input.price,
|
price: args.input.price,
|
||||||
quantity: args.input.quantity,
|
quantity: 0, // Временно устанавливаем 0, так как поле убрано из интерфейса
|
||||||
imageUrl: args.input.imageUrl
|
imageUrl: args.input.imageUrl
|
||||||
},
|
},
|
||||||
include: { organization: true }
|
include: { organization: true }
|
||||||
@ -3558,4 +3577,163 @@ 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
|
||||||
}
|
}
|
@ -29,6 +29,9 @@ export const typeDefs = gql`
|
|||||||
# Расходники организации
|
# Расходники организации
|
||||||
mySupplies: [Supply!]!
|
mySupplies: [Supply!]!
|
||||||
|
|
||||||
|
# Логистика организации
|
||||||
|
myLogistics: [Logistics!]!
|
||||||
|
|
||||||
# Товары оптовика
|
# Товары оптовика
|
||||||
myProducts: [Product!]!
|
myProducts: [Product!]!
|
||||||
|
|
||||||
@ -100,6 +103,11 @@ export const typeDefs = gql`
|
|||||||
updateSupply(id: ID!, input: SupplyInput!): SupplyResponse!
|
updateSupply(id: ID!, input: SupplyInput!): SupplyResponse!
|
||||||
deleteSupply(id: ID!): Boolean!
|
deleteSupply(id: ID!): Boolean!
|
||||||
|
|
||||||
|
# Работа с логистикой
|
||||||
|
createLogistics(input: LogisticsInput!): LogisticsResponse!
|
||||||
|
updateLogistics(id: ID!, input: LogisticsInput!): LogisticsResponse!
|
||||||
|
deleteLogistics(id: ID!): Boolean!
|
||||||
|
|
||||||
# Работа с товарами (для оптовиков)
|
# Работа с товарами (для оптовиков)
|
||||||
createProduct(input: ProductInput!): ProductResponse!
|
createProduct(input: ProductInput!): ProductResponse!
|
||||||
updateProduct(id: ID!, input: ProductInput!): ProductResponse!
|
updateProduct(id: ID!, input: ProductInput!): ProductResponse!
|
||||||
@ -372,7 +380,6 @@ export const typeDefs = gql`
|
|||||||
name: String!
|
name: String!
|
||||||
description: String
|
description: String
|
||||||
price: Float!
|
price: Float!
|
||||||
quantity: Int!
|
|
||||||
imageUrl: String
|
imageUrl: String
|
||||||
createdAt: String!
|
createdAt: String!
|
||||||
updatedAt: String!
|
updatedAt: String!
|
||||||
@ -383,7 +390,6 @@ export const typeDefs = gql`
|
|||||||
name: String!
|
name: String!
|
||||||
description: String
|
description: String
|
||||||
price: Float!
|
price: Float!
|
||||||
quantity: Int!
|
|
||||||
imageUrl: String
|
imageUrl: String
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -393,6 +399,33 @@ export const typeDefs = gql`
|
|||||||
supply: Supply
|
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 {
|
type Category {
|
||||||
id: ID!
|
id: ID!
|
||||||
|
111
src/lib/input-masks.ts
Normal file
111
src/lib/input-masks.ts
Normal 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 }
|
||||||
|
}
|
102
src/services/wildberries-service.ts
Normal file
102
src/services/wildberries-service.ts
Normal 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('Ошибка получения списка поставок')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
Reference in New Issue
Block a user