From 93bb5827d258c8ef5bb58b59bd1b90122886836a Mon Sep 17 00:00:00 2001 From: Bivekich Date: Fri, 18 Jul 2025 15:40:12 +0300 Subject: [PATCH] =?UTF-8?q?=D0=94=D0=BE=D0=B1=D0=B0=D0=B2=D0=BB=D0=B5?= =?UTF-8?q?=D0=BD=D1=8B=20=D0=BC=D0=BE=D0=B4=D0=B5=D0=BB=D0=B8=20=D0=B8=20?= =?UTF-8?q?=D1=84=D1=83=D0=BD=D0=BA=D1=86=D0=B8=D0=BE=D0=BD=D0=B0=D0=BB?= =?UTF-8?q?=D1=8C=D0=BD=D0=BE=D1=81=D1=82=D1=8C=20=D0=B4=D0=BB=D1=8F=20?= =?UTF-8?q?=D1=83=D0=BF=D1=80=D0=B0=D0=B2=D0=BB=D0=B5=D0=BD=D0=B8=D1=8F=20?= =?UTF-8?q?=D0=BB=D0=BE=D0=B3=D0=B8=D1=81=D1=82=D0=B8=D0=BA=D0=BE=D0=B9,?= =?UTF-8?q?=20=D0=B2=D0=BA=D0=BB=D1=8E=D1=87=D0=B0=D1=8F=20=D1=81=D0=BE?= =?UTF-8?q?=D0=B7=D0=B4=D0=B0=D0=BD=D0=B8=D0=B5,=20=D0=BE=D0=B1=D0=BD?= =?UTF-8?q?=D0=BE=D0=B2=D0=BB=D0=B5=D0=BD=D0=B8=D0=B5=20=D0=B8=20=D1=83?= =?UTF-8?q?=D0=B4=D0=B0=D0=BB=D0=B5=D0=BD=D0=B8=D0=B5=20=D0=BB=D0=BE=D0=B3?= =?UTF-8?q?=D0=B8=D1=81=D1=82=D0=B8=D1=87=D0=B5=D1=81=D0=BA=D0=B8=D1=85=20?= =?UTF-8?q?=D0=BC=D0=B0=D1=80=D1=88=D1=80=D1=83=D1=82=D0=BE=D0=B2=20=D1=87?= =?UTF-8?q?=D0=B5=D1=80=D0=B5=D0=B7=20GraphQL.=20=D0=9E=D0=B1=D0=BD=D0=BE?= =?UTF-8?q?=D0=B2=D0=BB=D0=B5=D0=BD=D1=8B=20=D0=BA=D0=BE=D0=BC=D0=BF=D0=BE?= =?UTF-8?q?=D0=BD=D0=B5=D0=BD=D1=82=D1=8B=20=D0=B4=D0=BB=D1=8F=20=D0=BE?= =?UTF-8?q?=D1=82=D0=BE=D0=B1=D1=80=D0=B0=D0=B6=D0=B5=D0=BD=D0=B8=D1=8F=20?= =?UTF-8?q?=D0=B8=20=D1=83=D0=BF=D1=80=D0=B0=D0=B2=D0=BB=D0=B5=D0=BD=D0=B8?= =?UTF-8?q?=D1=8F=20=D0=BB=D0=BE=D0=B3=D0=B8=D1=81=D1=82=D0=B8=D0=BA=D0=BE?= =?UTF-8?q?=D0=B9,=20=D1=83=D0=BB=D1=83=D1=87=D1=88=D0=B5=D0=BD=20=D0=B8?= =?UTF-8?q?=D0=BD=D1=82=D0=B5=D1=80=D1=84=D0=B5=D0=B9=D1=81=20=D0=B2=D0=B7?= =?UTF-8?q?=D0=B0=D0=B8=D0=BC=D0=BE=D0=B4=D0=B5=D0=B9=D1=81=D1=82=D0=B2?= =?UTF-8?q?=D0=B8=D1=8F=20=D1=81=20=D0=BF=D0=BE=D0=BB=D1=8C=D0=B7=D0=BE?= =?UTF-8?q?=D0=B2=D0=B0=D1=82=D0=B5=D0=BB=D0=B5=D0=BC.=20=D0=A0=D0=B5?= =?UTF-8?q?=D0=B0=D0=BB=D0=B8=D0=B7=D0=BE=D0=B2=D0=B0=D0=BD=D1=8B=20=D0=BD?= =?UTF-8?q?=D0=BE=D0=B2=D1=8B=D0=B5=20=D1=82=D0=B8=D0=BF=D1=8B=20=D0=B4?= =?UTF-8?q?=D0=B0=D0=BD=D0=BD=D1=8B=D1=85=20=D0=B8=20=D0=B8=D0=BD=D1=82?= =?UTF-8?q?=D0=B5=D1=80=D1=84=D0=B5=D0=B9=D1=81=D1=8B=20=D0=B4=D0=BB=D1=8F?= =?UTF-8?q?=20=D0=BB=D0=BE=D0=B3=D0=B8=D1=81=D1=82=D0=B8=D0=BA=D0=B8,=20?= =?UTF-8?q?=D0=B0=20=D1=82=D0=B0=D0=BA=D0=B6=D0=B5=20=D1=83=D0=BB=D1=83?= =?UTF-8?q?=D1=87=D1=88=D0=B5=D0=BD=D0=B0=20=D0=BE=D0=B1=D1=80=D0=B0=D0=B1?= =?UTF-8?q?=D0=BE=D1=82=D0=BA=D0=B0=20=D0=BE=D1=88=D0=B8=D0=B1=D0=BE=D0=BA?= =?UTF-8?q?.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- prisma/schema.prisma | 16 + src/app/supplies/create/page.tsx | 10 + src/app/supplies/page.tsx | 10 + src/components/dashboard/sidebar.tsx | 24 +- src/components/employees/employee-form.tsx | 290 +++++-- .../employees/employee-inline-form.tsx | 223 ++++- src/components/services/logistics-tab.tsx | 704 +++++++++++++++- src/components/services/services-tab.tsx | 648 ++++++++++----- src/components/services/supplies-tab.tsx | 758 ++++++++++------- .../supplies/create-supply-form.tsx | 301 +++++++ .../supplies/create-supply-page.tsx | 782 ++++++++++++++++++ .../supplies/supplies-dashboard.tsx | 724 ++++++++++++++++ .../supplies/wholesaler-products.tsx | 425 ++++++++++ .../supplies/wholesaler-selection.tsx | 260 ++++++ src/graphql/mutations.ts | 47 +- src/graphql/queries.ts | 16 +- src/graphql/resolvers.ts | 194 ++++- src/graphql/typedefs.ts | 37 +- src/lib/input-masks.ts | 111 +++ src/services/wildberries-service.ts | 102 +++ 20 files changed, 5015 insertions(+), 667 deletions(-) create mode 100644 src/app/supplies/create/page.tsx create mode 100644 src/app/supplies/page.tsx create mode 100644 src/components/supplies/create-supply-form.tsx create mode 100644 src/components/supplies/create-supply-page.tsx create mode 100644 src/components/supplies/supplies-dashboard.tsx create mode 100644 src/components/supplies/wholesaler-products.tsx create mode 100644 src/components/supplies/wholesaler-selection.tsx create mode 100644 src/lib/input-masks.ts create mode 100644 src/services/wildberries-service.ts diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 2355bf7..0db3750 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -83,6 +83,7 @@ model Organization { services Service[] supplies Supply[] users User[] + logistics Logistics[] @@map("organizations") } @@ -348,3 +349,18 @@ enum ScheduleStatus { SICK ABSENT } + +model Logistics { + id String @id @default(cuid()) + fromLocation String + toLocation String + priceUnder1m3 Float + priceOver1m3 Float + description String? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + organizationId String + organization Organization @relation(fields: [organizationId], references: [id]) + + @@map("logistics") +} diff --git a/src/app/supplies/create/page.tsx b/src/app/supplies/create/page.tsx new file mode 100644 index 0000000..e2a037f --- /dev/null +++ b/src/app/supplies/create/page.tsx @@ -0,0 +1,10 @@ +import { AuthGuard } from "@/components/auth-guard" +import { CreateSupplyPage } from "@/components/supplies/create-supply-page" + +export default function CreateSupplyPageRoute() { + return ( + + + + ) +} \ No newline at end of file diff --git a/src/app/supplies/page.tsx b/src/app/supplies/page.tsx new file mode 100644 index 0000000..c5b3e2d --- /dev/null +++ b/src/app/supplies/page.tsx @@ -0,0 +1,10 @@ +import { AuthGuard } from "@/components/auth-guard" +import { SuppliesDashboard } from "@/components/supplies/supplies-dashboard" + +export default function SuppliesPage() { + return ( + + + + ) +} \ No newline at end of file diff --git a/src/components/dashboard/sidebar.tsx b/src/components/dashboard/sidebar.tsx index 0f4c9ae..f6f04c1 100644 --- a/src/components/dashboard/sidebar.tsx +++ b/src/components/dashboard/sidebar.tsx @@ -12,7 +12,8 @@ import { MessageCircle, Wrench, Warehouse, - Users + Users, + Truck } from 'lucide-react' export function Sidebar() { @@ -76,12 +77,17 @@ export function Sidebar() { router.push('/employees') } + const handleSuppliesClick = () => { + router.push('/supplies') + } + const isSettingsActive = pathname === '/settings' const isMarketActive = pathname.startsWith('/market') const isMessengerActive = pathname.startsWith('/messenger') const isServicesActive = pathname.startsWith('/services') const isWarehouseActive = pathname.startsWith('/warehouse') const isEmployeesActive = pathname.startsWith('/employees') + const isSuppliesActive = pathname.startsWith('/supplies') return (
@@ -180,6 +186,22 @@ export function Sidebar() { )} + {/* Поставки - только для селлеров */} + {user?.organization?.type === 'SELLER' && ( + + )} + {/* Склад - только для оптовиков */} {user?.organization?.type === 'WHOLESALE' && (
@@ -240,9 +403,10 @@ export function EmployeeForm({ employee, onSave, onCancel }: EmployeeFormProps) value={formData.lastName} onChange={(e) => handleInputChange('lastName', e.target.value)} placeholder="Петров" - className="glass-input text-white placeholder:text-white/40 h-10" + className={`glass-input text-white placeholder:text-white/40 h-10 ${errors.lastName ? 'border-red-400' : ''}`} required /> +
@@ -251,8 +415,9 @@ export function EmployeeForm({ employee, onSave, onCancel }: EmployeeFormProps) value={formData.middleName} onChange={(e) => handleInputChange('middleName', e.target.value)} placeholder="Иванович" - className="glass-input text-white placeholder:text-white/40 h-10" + className={`glass-input text-white placeholder:text-white/40 h-10 ${errors.middleName ? 'border-red-400' : ''}`} /> +
@@ -261,8 +426,9 @@ export function EmployeeForm({ employee, onSave, onCancel }: EmployeeFormProps) type="date" value={formData.birthDate} onChange={(e) => handleInputChange('birthDate', e.target.value)} - className="glass-input text-white h-10" + className={`glass-input text-white h-10 ${errors.birthDate ? 'border-red-400' : ''}`} /> +
@@ -271,8 +437,10 @@ export function EmployeeForm({ employee, onSave, onCancel }: EmployeeFormProps) value={formData.passportSeries} onChange={(e) => handleInputChange('passportSeries', e.target.value)} placeholder="1234" - className="glass-input text-white placeholder:text-white/40 h-10" + maxLength={4} + className={`glass-input text-white placeholder:text-white/40 h-10 ${errors.passportSeries ? 'border-red-400' : ''}`} /> +
@@ -281,8 +449,10 @@ export function EmployeeForm({ employee, onSave, onCancel }: EmployeeFormProps) value={formData.passportNumber} onChange={(e) => handleInputChange('passportNumber', e.target.value)} placeholder="567890" - className="glass-input text-white placeholder:text-white/40 h-10" + maxLength={6} + className={`glass-input text-white placeholder:text-white/40 h-10 ${errors.passportNumber ? 'border-red-400' : ''}`} /> +
@@ -330,35 +500,39 @@ export function EmployeeForm({ employee, onSave, onCancel }: EmployeeFormProps) value={formData.position} onChange={(e) => handleInputChange('position', e.target.value)} placeholder="Менеджер склада" - className="glass-input text-white placeholder:text-white/40 h-10" + className={`glass-input text-white placeholder:text-white/40 h-10 ${errors.position ? 'border-red-400' : ''}`} required /> +
- + handleInputChange('hireDate', e.target.value)} - className="glass-input text-white h-10" + className={`glass-input text-white h-10 ${errors.hireDate ? 'border-red-400' : ''}`} /> +
@@ -367,13 +541,12 @@ export function EmployeeForm({ employee, onSave, onCancel }: EmployeeFormProps)
handleInputChange('salary', parseInt(e.target.value) || 0)} - placeholder="80000" - className="glass-input text-white placeholder:text-white/40 h-10" + value={formData.salary ? formatSalary(formData.salary.toString()) : ''} + onChange={(e) => handleSalaryChange(e.target.value)} + placeholder="80 000" + className={`glass-input text-white placeholder:text-white/40 h-10 ${errors.salary ? 'border-red-400' : ''}`} /> +
@@ -382,16 +555,16 @@ export function EmployeeForm({ employee, onSave, onCancel }: EmployeeFormProps)

Контактные данные

- + { - const formatted = formatPhoneInput(e.target.value) - handleInputChange('phone', formatted) - }} + onChange={(e) => handleInputChange('phone', e.target.value)} placeholder="+7 (999) 123-45-67" - className="glass-input text-white placeholder:text-white/40 h-10" + className={`glass-input text-white placeholder:text-white/40 h-10 ${errors.phone ? 'border-red-400' : ''}`} /> +
@@ -401,8 +574,9 @@ export function EmployeeForm({ employee, onSave, onCancel }: EmployeeFormProps) value={formData.email} onChange={(e) => handleInputChange('email', e.target.value)} placeholder="a.petrov@company.com" - className="glass-input text-white placeholder:text-white/40 h-10" + className={`glass-input text-white placeholder:text-white/40 h-10 ${errors.email ? 'border-red-400' : ''}`} /> +
@@ -419,13 +593,11 @@ export function EmployeeForm({ employee, onSave, onCancel }: EmployeeFormProps) { - const formatted = formatPhoneInput(e.target.value) - handleInputChange('emergencyPhone', formatted) - }} + onChange={(e) => handleInputChange('emergencyPhone', e.target.value)} placeholder="+7 (999) 123-45-67" - className="glass-input text-white placeholder:text-white/40 h-10" + className={`glass-input text-white placeholder:text-white/40 h-10 ${errors.emergencyPhone ? 'border-red-400' : ''}`} /> +
diff --git a/src/components/employees/employee-inline-form.tsx b/src/components/employees/employee-inline-form.tsx index 75b8dca..9bd128a 100644 --- a/src/components/employees/employee-inline-form.tsx +++ b/src/components/employees/employee-inline-form.tsx @@ -20,11 +20,25 @@ import { Mail, Briefcase, DollarSign, - FileText, - MessageCircle + MessageCircle, + AlertCircle } from 'lucide-react' import { toast } from 'sonner' +import { + formatPhoneInput, + formatPassportSeries, + formatPassportNumber, + formatSalary, + formatNameInput, + isValidEmail, + isValidPhone, + isValidPassportSeries, + isValidPassportNumber, + isValidBirthDate, + isValidHireDate, + isValidSalary +} from '@/lib/input-masks' interface EmployeeInlineFormProps { onSave: (employeeData: { @@ -46,6 +60,10 @@ interface EmployeeInlineFormProps { isLoading?: boolean } +interface ValidationErrors { + [key: string]: string +} + export function EmployeeInlineForm({ onSave, onCancel, isLoading = false }: EmployeeInlineFormProps) { const [formData, setFormData] = useState({ firstName: '', @@ -65,13 +83,123 @@ export function EmployeeInlineForm({ onSave, onCancel, isLoading = false }: Empl const [isUploadingAvatar, setIsUploadingAvatar] = useState(false) const [isUploadingPassport, setIsUploadingPassport] = useState(false) const [showPassportPreview, setShowPassportPreview] = useState(false) + const [errors, setErrors] = useState({}) const avatarInputRef = useRef(null) const passportInputRef = useRef(null) + const validateField = (field: string, value: string | number): string | null => { + switch (field) { + case 'firstName': + case 'lastName': + if (!value || String(value).trim() === '') { + return field === 'firstName' ? 'Имя обязательно для заполнения' : 'Фамилия обязательна для заполнения' + } + if (String(value).length < 2) { + return field === 'firstName' ? 'Имя должно содержать минимум 2 символа' : 'Фамилия должна содержать минимум 2 символа' + } + if (!/^[а-яёА-ЯЁa-zA-Z\s-]+$/.test(String(value))) { + return field === 'firstName' ? 'Имя может содержать только буквы, пробелы и дефисы' : 'Фамилия может содержать только буквы, пробелы и дефисы' + } + break + + case 'middleName': + if (value && String(value).length > 0) { + if (String(value).length < 2) { + return 'Отчество должно содержать минимум 2 символа' + } + if (!/^[а-яёА-ЯЁa-zA-Z\s-]+$/.test(String(value))) { + return 'Отчество может содержать только буквы, пробелы и дефисы' + } + } + break + + case 'position': + if (!value || String(value).trim() === '') { + return 'Должность обязательна для заполнения' + } + if (String(value).length < 2) { + return 'Должность должна содержать минимум 2 символа' + } + break + + case 'phone': + case 'whatsapp': + if (field === 'phone' && (!value || String(value).trim() === '')) { + return 'Телефон обязателен для заполнения' + } + if (value && String(value).trim() !== '' && !isValidPhone(String(value))) { + return 'Введите корректный номер телефона в формате +7 (999) 123-45-67' + } + break + + case 'email': + if (value && String(value).trim() !== '' && !isValidEmail(String(value))) { + return 'Введите корректный email адрес' + } + break + + case 'birthDate': + if (value && String(value).trim() !== '') { + const validation = isValidBirthDate(String(value)) + if (!validation.valid) { + return validation.message || 'Некорректная дата рождения' + } + } + break + + case 'salary': + const salaryValidation = isValidSalary(Number(value)) + if (!salaryValidation.valid) { + return salaryValidation.message || 'Некорректная сумма зарплаты' + } + break + } + + return null + } + const handleInputChange = (field: string, value: string | number) => { + let processedValue = value + + // Применяем маски ввода + if (typeof value === 'string') { + switch (field) { + case 'phone': + case 'whatsapp': + processedValue = formatPhoneInput(value) + break + case 'firstName': + case 'lastName': + case 'middleName': + processedValue = formatNameInput(value) + break + } + } + setFormData(prev => ({ ...prev, - [field]: value + [field]: processedValue + })) + + // Валидация в реальном времени + const error = validateField(field, processedValue) + setErrors(prev => ({ + ...prev, + [field]: error || '' + })) + } + + const handleSalaryChange = (value: string) => { + const numericValue = parseInt(value.replace(/\D/g, '')) || 0 + setFormData(prev => ({ + ...prev, + salary: numericValue + })) + + const error = validateField('salary', numericValue) + setErrors(prev => ({ + ...prev, + salary: error || '' })) } @@ -126,26 +254,28 @@ export function EmployeeInlineForm({ onSave, onCancel, isLoading = false }: Empl } } - const formatPhoneInput = (value: string) => { - const cleaned = value.replace(/\D/g, '') - if (cleaned.length <= 1) return cleaned - if (cleaned.length <= 4) return `+7 (${cleaned.slice(1)}` - if (cleaned.length <= 7) return `+7 (${cleaned.slice(1, 4)}) ${cleaned.slice(4)}` - if (cleaned.length <= 9) return `+7 (${cleaned.slice(1, 4)}) ${cleaned.slice(4, 7)}-${cleaned.slice(7)}` - return `+7 (${cleaned.slice(1, 4)}) ${cleaned.slice(4, 7)}-${cleaned.slice(7, 9)}-${cleaned.slice(9, 11)}` + + + const validateForm = (): boolean => { + const newErrors: ValidationErrors = {} + + // Валидируем все поля + Object.keys(formData).forEach(field => { + const error = validateField(field, formData[field as keyof typeof formData]) + if (error) { + newErrors[field] = error + } + }) + + setErrors(newErrors) + return Object.keys(newErrors).filter(key => newErrors[key]).length === 0 } const handleSubmit = (e: React.FormEvent) => { e.preventDefault() - // Валидация обязательных полей - if (!formData.firstName || !formData.lastName || !formData.phone || !formData.position) { - toast.error('Пожалуйста, заполните все обязательные поля') - return - } - - if (formData.email && !/\S+@\S+\.\S+/.test(formData.email)) { - toast.error('Введите корректный email адрес') + if (!validateForm()) { + toast.error('Пожалуйста, исправьте ошибки в форме') return } @@ -169,6 +299,17 @@ export function EmployeeInlineForm({ onSave, onCancel, isLoading = false }: Empl onSave(employeeData) } + // Компонент для отображения ошибок + const ErrorMessage = ({ error }: { error: string }) => { + if (!error) return null + return ( +
+ + {error} +
+ ) + } + const getInitials = () => { const first = formData.firstName.charAt(0).toUpperCase() const last = formData.lastName.charAt(0).toUpperCase() @@ -316,7 +457,7 @@ export function EmployeeInlineForm({ onSave, onCancel, isLoading = false }: Empl
-
+
@@ -324,11 +465,12 @@ export function EmployeeInlineForm({ onSave, onCancel, isLoading = false }: Empl value={formData.firstName} onChange={(e) => handleInputChange('firstName', e.target.value)} placeholder="Александр" - className="glass-input text-white placeholder:text-white/40" + className={`glass-input text-white placeholder:text-white/40 ${errors.firstName ? 'border-red-400' : ''}`} required /> +
- +
@@ -348,8 +491,9 @@ export function EmployeeInlineForm({ onSave, onCancel, isLoading = false }: Empl value={formData.middleName} onChange={(e) => handleInputChange('middleName', e.target.value)} placeholder="Иванович" - className="glass-input text-white placeholder:text-white/40" + className={`glass-input text-white placeholder:text-white/40 ${errors.middleName ? 'border-red-400' : ''}`} /> +
@@ -382,14 +526,12 @@ export function EmployeeInlineForm({ onSave, onCancel, isLoading = false }: Empl { - const formatted = formatPhoneInput(e.target.value) - handleInputChange('phone', formatted) - }} + onChange={(e) => handleInputChange('phone', e.target.value)} placeholder="+7 (999) 123-45-67" - className="glass-input text-white placeholder:text-white/40" + className={`glass-input text-white placeholder:text-white/40 ${errors.phone ? 'border-red-400' : ''}`} required /> +
@@ -412,13 +554,11 @@ export function EmployeeInlineForm({ onSave, onCancel, isLoading = false }: Empl { - const formatted = formatPhoneInput(e.target.value) - handleInputChange('whatsapp', formatted) - }} + onChange={(e) => handleInputChange('whatsapp', e.target.value)} placeholder="+7 (999) 123-45-67" - className="glass-input text-white placeholder:text-white/40" + className={`glass-input text-white placeholder:text-white/40 ${errors.whatsapp ? 'border-red-400' : ''}`} /> +
@@ -431,8 +571,9 @@ export function EmployeeInlineForm({ onSave, onCancel, isLoading = false }: Empl value={formData.email} onChange={(e) => handleInputChange('email', e.target.value)} placeholder="a.petrov@company.com" - className="glass-input text-white placeholder:text-white/40" + className={`glass-input text-white placeholder:text-white/40 ${errors.email ? 'border-red-400' : ''}`} /> +
@@ -455,9 +596,10 @@ export function EmployeeInlineForm({ onSave, onCancel, isLoading = false }: Empl value={formData.position} onChange={(e) => handleInputChange('position', e.target.value)} placeholder="Менеджер склада" - className="glass-input text-white placeholder:text-white/40" + className={`glass-input text-white placeholder:text-white/40 ${errors.position ? 'border-red-400' : ''}`} required /> +
@@ -466,13 +608,12 @@ export function EmployeeInlineForm({ onSave, onCancel, isLoading = false }: Empl Зарплата handleInputChange('salary', parseInt(e.target.value) || 0)} - placeholder="80000" - className="glass-input text-white placeholder:text-white/40" + value={formData.salary ? formatSalary(formData.salary.toString()) : ''} + onChange={(e) => handleSalaryChange(e.target.value)} + placeholder="80 000" + className={`glass-input text-white placeholder:text-white/40 ${errors.salary ? 'border-red-400' : ''}`} /> +
diff --git a/src/components/services/logistics-tab.tsx b/src/components/services/logistics-tab.tsx index 6c201f1..2ac8e84 100644 --- a/src/components/services/logistics-tab.tsx +++ b/src/components/services/logistics-tab.tsx @@ -1,34 +1,688 @@ "use client" +import { useState, useEffect, useMemo } from 'react' +import { useQuery, useMutation } from '@apollo/client' import { Card } from '@/components/ui/card' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select' +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogTrigger +} from '@/components/ui/alert-dialog' +import { Plus, Trash2, Save, X, Edit, Check, Truck, Building2, Store, Package, ShoppingCart } from 'lucide-react' +import { toast } from "sonner" +import { useAuth } from '@/hooks/useAuth' +import { GET_MY_LOGISTICS } from '@/graphql/queries' +import { CREATE_LOGISTICS, UPDATE_LOGISTICS, DELETE_LOGISTICS } from '@/graphql/mutations' +import { WildberriesService } from '@/services/wildberries-service' + +interface LogisticsRoute { + id: string + fromLocation: string + toLocation: string + priceUnder1m3: number + priceOver1m3: number + description?: string + createdAt: string + updatedAt: string +} + +interface EditableLogistics { + id?: string + fromLocation: string + toLocation: string + priceUnder1m3: string + priceOver1m3: string + description: string + isNew: boolean + isEditing: boolean + hasChanges: boolean +} + +interface LocationOption { + value: string + label: string +} + +// Базовые локации (без своего фулфилмента) +const BASE_LOCATIONS = { + markets: [ + { value: 'sadovod', label: 'Садовод' }, + { value: 'tyak-moscow', label: 'ТЯК Москва' } + ], + wbWarehouses: [ + // Статичные склады WB как fallback + { value: 'wb-warehouse-1', label: 'Подольск' }, + { value: 'wb-warehouse-2', label: 'Электросталь' }, + { value: 'wb-warehouse-3', label: 'Коледино' } + ], + ozonWarehouses: [ + // Статичные склады Ozon + { value: 'ozon-warehouse-1', label: 'Тверь' }, + { value: 'ozon-warehouse-2', label: 'Казань' } + ] +} export function LogisticsTab() { - return ( -
- -
-
-
- - - -
-

Логистика

-

- Раздел логистики находится в разработке. - Здесь будут инструменты для управления доставкой и складскими операциями. -

+ const { user } = useAuth() + const [editableLogistics, setEditableLogistics] = useState([]) + const [isSaving, setIsSaving] = useState(false) + const [isInitialized, setIsInitialized] = useState(false) + const [warehouses, setWarehouses] = useState([]) + + // GraphQL запросы и мутации + const { data, loading, error, refetch } = useQuery(GET_MY_LOGISTICS, { + skip: user?.organization?.type !== 'FULFILLMENT' + }) + const [createLogistics] = useMutation(CREATE_LOGISTICS) + const [updateLogistics] = useMutation(UPDATE_LOGISTICS) + const [deleteLogistics] = useMutation(DELETE_LOGISTICS) + + const logistics = data?.myLogistics || [] + + // Загружаем склады из API WB + useEffect(() => { + const loadWarehouses = async () => { + try { + // Получаем API ключ из организации пользователя + const wbApiKey = user?.organization?.apiKeys?.find(key => key.marketplace === 'WILDBERRIES') + if (wbApiKey?.isActive && wbApiKey.validationData) { + const validationData = wbApiKey.validationData as Record + 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 ( + + ) + } + + return ( +
+ + {/* Заголовок и кнопки */} +
+
+

Логистические маршруты

+

Доставка между точками

+
+ +
+ + + {hasUnsavedChanges && ( + + )} +
+
+ + {/* Таблица маршрутов */} +
+ {loading ? ( +
+
+
+ +
+

Загрузка маршрутов...

+
+
+ ) : editableLogistics.length === 0 ? ( +
+
+
+ +
+

Пока нет маршрутов

+

+ Создайте свой первый логистический маршрут +

+ +
+
+ ) : ( +
+ + + + + + + + + + + + + + {editableLogistics.map((route, index) => ( + + + + {/* Откуда */} + + + {/* Куда */} + + + {/* Цена до 1м³ */} + + + {/* Цена свыше 1м³ */} + + + {/* Описание */} + + + {/* Действия */} + + + ))} + +
Откуда *Куда *До 1м³ (₽) *Свыше 1м³ (₽) *ОписаниеДействия
{index + 1} + {route.isEditing ? ( + updateField(route.id!, 'fromLocation', value)} + placeholder="Выберите точку отправления" + /> + ) : ( + {getLocationLabel(route.fromLocation)} + )} + + {route.isEditing ? ( + updateField(route.id!, 'toLocation', value)} + placeholder="Выберите точку назначения" + /> + ) : ( + {getLocationLabel(route.toLocation)} + )} + + {route.isEditing ? ( + updateField(route.id!, 'priceUnder1m3', e.target.value)} + className="bg-white/5 border-white/20 text-white" + placeholder="0.00" + /> + ) : ( + + {route.priceUnder1m3 ? parseFloat(route.priceUnder1m3).toLocaleString() : '0'} ₽ + + )} + + {route.isEditing ? ( + updateField(route.id!, 'priceOver1m3', e.target.value)} + className="bg-white/5 border-white/20 text-white" + placeholder="0.00" + /> + ) : ( + + {route.priceOver1m3 ? parseFloat(route.priceOver1m3).toLocaleString() : '0'} ₽ + + )} + + {route.isEditing ? ( + updateField(route.id!, 'description', e.target.value)} + className="bg-white/5 border-white/20 text-white" + placeholder="Дополнительная информация" + /> + ) : ( + {route.description || '—'} + )} + +
+ {route.isEditing ? ( + <> + + + + ) : ( + + )} + + + + + + + + + Подтвердите удаление + + + Вы действительно хотите удалить маршрут? Это действие необратимо. + + + + + Отмена + + 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" + > + Удалить + + + + +
+
+
+ )}
diff --git a/src/components/services/services-tab.tsx b/src/components/services/services-tab.tsx index 828a83c..594e768 100644 --- a/src/components/services/services-tab.tsx +++ b/src/components/services/services-tab.tsx @@ -1,13 +1,11 @@ "use client" -import { useState } from 'react' +import { useState, useEffect, useMemo } from 'react' import { useQuery, useMutation } from '@apollo/client' import { Card } from '@/components/ui/card' import Image from 'next/image' import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' -import { Label } from '@/components/ui/label' -import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog' import { AlertDialog, AlertDialogAction, @@ -19,7 +17,7 @@ import { AlertDialogTitle, AlertDialogTrigger } from '@/components/ui/alert-dialog' -import { Plus, Edit, Trash2, Upload } from 'lucide-react' +import { Plus, Trash2, Save, X, Edit, Upload, Check } from 'lucide-react' import { toast } from "sonner" import { useAuth } from '@/hooks/useAuth' import { GET_MY_SERVICES } from '@/graphql/queries' @@ -35,18 +33,28 @@ interface Service { updatedAt: string } +interface EditableService { + id?: string // undefined для новых записей + name: string + description: string + price: string + imageUrl: string + imageFile?: File + isNew: boolean + isEditing: boolean + hasChanges: boolean +} + +interface PendingChange extends EditableService { + isDeleted?: boolean +} + export function ServicesTab() { const { user } = useAuth() - const [isDialogOpen, setIsDialogOpen] = useState(false) - const [editingService, setEditingService] = useState(null) - const [formData, setFormData] = useState({ - name: '', - description: '', - price: '', - imageUrl: '' - }) - const [imageFile, setImageFile] = useState(null) - const [isUploading] = useState(false) + const [editableServices, setEditableServices] = useState([]) + const [pendingChanges, setPendingChanges] = useState([]) + const [isSaving, setIsSaving] = useState(false) + const [isInitialized, setIsInitialized] = useState(false) // GraphQL запросы и мутации const { data, loading, error, refetch } = useQuery(GET_MY_SERVICES, { @@ -57,45 +65,140 @@ export function ServicesTab() { const [deleteService] = useMutation(DELETE_SERVICE) const services = data?.myServices || [] - - // Логирование для отладки - console.log('Services data:', services) - const resetForm = () => { - setFormData({ + // Преобразуем загруженные услуги в редактируемый формат + useEffect(() => { + if (data?.myServices && !isInitialized) { + const convertedServices: EditableService[] = data.myServices.map((service: Service) => ({ + id: service.id, + name: service.name, + description: service.description || '', + price: service.price.toString(), + imageUrl: service.imageUrl || '', + isNew: false, + isEditing: false, + hasChanges: false + })) + + setEditableServices(convertedServices) + setPendingChanges([]) + setIsInitialized(true) + } + }, [data, isInitialized]) + + // Добавить новую строку + const addNewRow = () => { + const tempId = `temp-${Date.now()}-${Math.random()}` + const newRow: EditableService = { + id: tempId, name: '', description: '', price: '', - imageUrl: '' - }) - setImageFile(null) - setEditingService(null) + imageUrl: '', + isNew: true, + isEditing: true, + hasChanges: false + } + setEditableServices(prev => [...prev, newRow]) } - const handleEdit = (service: Service) => { - setEditingService(service) - setFormData({ - name: service.name, - description: service.description || '', - price: service.price.toString(), - imageUrl: service.imageUrl || '' - }) - setIsDialogOpen(true) - } - - const handleDelete = async (serviceId: string) => { - try { - await deleteService({ - variables: { id: serviceId } - }) - await refetch() - toast.success('Услуга успешно удалена') - } catch (error) { - console.error('Error deleting service:', error) - toast.error('Ошибка при удалении услуги') + // Удалить строку + const removeRow = async (serviceId: string, isNew: boolean) => { + if (isNew) { + // Просто удаляем из массива если это новая строка + setEditableServices(prev => prev.filter(s => s.id !== serviceId)) + } else { + // Удаляем существующую запись сразу + try { + await deleteService({ + variables: { id: serviceId }, + update: (cache, { data }) => { + // Обновляем кэш Apollo Client + const existingData = cache.readQuery({ query: GET_MY_SERVICES }) as { myServices: Service[] } | null + if (existingData && existingData.myServices) { + cache.writeQuery({ + query: GET_MY_SERVICES, + data: { + myServices: existingData.myServices.filter((s: Service) => s.id !== serviceId) + } + }) + } + } + }) + + // Удаляем из локального состояния по ID, а не по индексу + setEditableServices(prev => prev.filter(s => s.id !== serviceId)) + toast.success('Услуга успешно удалена') + } catch (error) { + console.error('Error deleting service:', error) + toast.error('Ошибка при удалении услуги') + } } } + // Начать редактирование существующей строки + const startEditing = (serviceId: string) => { + setEditableServices(prev => + prev.map(service => + service.id === serviceId ? { ...service, isEditing: true } : service + ) + ) + } + + // Отменить редактирование + const cancelEditing = (serviceId: string) => { + const service = editableServices.find(s => s.id === serviceId) + if (!service) return + + if (service.isNew) { + // Удаляем новую строку + setEditableServices(prev => prev.filter(s => s.id !== serviceId)) + } else { + // Возвращаем к исходному состоянию + const originalService = services.find((s: Service) => s.id === service.id) + if (originalService) { + setEditableServices(prev => + prev.map(s => + s.id === serviceId + ? { + id: originalService.id, + name: originalService.name, + description: originalService.description || '', + price: originalService.price.toString(), + imageUrl: originalService.imageUrl || '', + isNew: false, + isEditing: false, + hasChanges: false + } + : s + ) + ) + } + } + } + + // Обновить поле + const updateField = (serviceId: string, field: keyof EditableService, value: string | File) => { + setEditableServices(prev => + prev.map((service) => { + if (service.id !== serviceId) return service + + const updated = { ...service, hasChanges: true } + if (field === 'imageFile' && value instanceof File) { + updated.imageFile = value + updated.imageUrl = URL.createObjectURL(value) + } else if (typeof value === 'string') { + if (field === 'name') updated.name = value + else if (field === 'description') updated.description = value + else if (field === 'price') updated.price = value + else if (field === 'imageUrl') updated.imageUrl = value + } + return updated + }) + ) + } + + // Загрузка изображения const uploadImageAndGetUrl = async (file: File): Promise => { if (!user?.id) throw new Error('User not found') @@ -114,175 +217,137 @@ export function ServicesTab() { } const result = await response.json() - console.log('Upload result:', result) return result.url } - const handleSubmit = async (e: React.FormEvent) => { - e.preventDefault() - - if (!formData.name.trim() || !formData.price) { - toast.error('Заполните обязательные поля') - return - } - + // Сохранить все изменения + const saveAllChanges = async () => { + setIsSaving(true) try { - let imageUrl = formData.imageUrl - - // Загружаем изображение если выбрано - if (imageFile) { - const uploadResult = await uploadImageAndGetUrl(imageFile) - imageUrl = uploadResult - } - - const input = { - name: formData.name, - description: formData.description || undefined, - price: parseFloat(formData.price), - imageUrl: imageUrl || undefined - } + const servicesToSave = editableServices.filter(s => { + if (s.isNew) { + // Для новых записей проверяем что обязательные поля заполнены + return s.name.trim() && s.price + } + // Для существующих записей проверяем флаг изменений + return s.hasChanges + }) - console.log('Submitting service with data:', input) + console.log('Services to save:', servicesToSave.length, servicesToSave) + + for (const service of servicesToSave) { + if (!service.name.trim() || !service.price) { + toast.error(`Заполните обязательные поля для всех услуг`) + setIsSaving(false) + return + } - if (editingService) { - await updateService({ - variables: { id: editingService.id, input } - }) - } else { - await createService({ - variables: { input } - }) + let imageUrl = service.imageUrl + + // Загружаем изображение если выбрано + if (service.imageFile) { + imageUrl = await uploadImageAndGetUrl(service.imageFile) + } + + const input = { + name: service.name, + description: service.description || undefined, + price: parseFloat(service.price), + imageUrl: imageUrl || undefined + } + + if (service.isNew) { + await createService({ + variables: { input }, + update: (cache, { data }) => { + if (data?.createService?.service) { + const existingData = cache.readQuery({ query: GET_MY_SERVICES }) as { myServices: Service[] } | null + if (existingData) { + cache.writeQuery({ + query: GET_MY_SERVICES, + data: { + myServices: [...existingData.myServices, data.createService.service] + } + }) + } + } + } + }) + } else if (service.id) { + await updateService({ + variables: { id: service.id, input }, + update: (cache, { data }) => { + if (data?.updateService?.service) { + const existingData = cache.readQuery({ query: GET_MY_SERVICES }) as { myServices: Service[] } | null + if (existingData) { + cache.writeQuery({ + query: GET_MY_SERVICES, + data: { + myServices: existingData.myServices.map((s: Service) => + s.id === data.updateService.service.id ? data.updateService.service : s + ) + } + }) + } + } + } + }) + } } - await refetch() - setIsDialogOpen(false) - resetForm() - - toast.success(editingService ? 'Услуга успешно обновлена' : 'Услуга успешно создана') + // Удаления теперь происходят сразу в removeRow, так что здесь обрабатываем только обновления + + toast.success('Все изменения успешно сохранены') + setPendingChanges([]) } catch (error) { - console.error('Error saving service:', error) - toast.error('Ошибка при сохранении услуги') + console.error('Error saving changes:', error) + toast.error('Ошибка при сохранении изменений') + } finally { + setIsSaving(false) } } + // Проверяем есть ли несохраненные изменения + const hasUnsavedChanges = useMemo(() => { + return editableServices.some(s => { + if (s.isNew) { + // Для новых записей проверяем что есть данные для сохранения + return s.name.trim() || s.price || s.description.trim() + } + return s.hasChanges + }) + }, [editableServices]) + return (
- {/* Заголовок и кнопка добавления */} + {/* Заголовок и кнопки */}

Мои услуги

Управление вашими услугами

- - - - +
+ - - - - {editingService ? 'Редактировать услугу' : 'Добавить услугу'} - - -
-
- - 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 - /> -
- -
- - 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 - /> -
- -
- - 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="Описание услуги" - /> -
- -
- - { - 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 && ( -
- Preview -
- )} -
- -
- - -
-
-
-
+ {hasUnsavedChanges && ( + + )} +
{/* Таблица услуг */} @@ -318,7 +383,7 @@ export function ServicesTab() {
- ) : services.length === 0 ? ( + ) : editableServices.length === 0 ? (
@@ -329,10 +394,7 @@ export function ServicesTab() { Создайте свою первую услугу, чтобы начать работу

+ {service.isEditing ? ( + <> + + + + ) : ( + + )} + @@ -413,7 +605,7 @@ export function ServicesTab() { Отмена handleDelete(service.id)} + onClick={() => removeRow(service.id!, service.isNew)} className="bg-gradient-to-r from-red-600 to-red-700 hover:from-red-700 hover:to-red-800 text-white border-0 shadow-lg shadow-red-500/25 hover:shadow-red-500/40 transition-all duration-300" > Удалить @@ -433,4 +625,4 @@ export function ServicesTab() {
) -} \ No newline at end of file +} \ No newline at end of file diff --git a/src/components/services/supplies-tab.tsx b/src/components/services/supplies-tab.tsx index 7586f4b..54b8696 100644 --- a/src/components/services/supplies-tab.tsx +++ b/src/components/services/supplies-tab.tsx @@ -1,13 +1,11 @@ "use client" -import { useState } from 'react' +import { useState, useEffect, useMemo } from 'react' import { useQuery, useMutation } from '@apollo/client' import { Card } from '@/components/ui/card' import Image from 'next/image' import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' -import { Label } from '@/components/ui/label' -import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog' import { AlertDialog, AlertDialogAction, @@ -19,7 +17,7 @@ import { AlertDialogTitle, AlertDialogTrigger } from '@/components/ui/alert-dialog' -import { Plus, Edit, Trash2, Upload, Package } from 'lucide-react' +import { Plus, Trash2, Save, X, Edit, Upload, Check } from 'lucide-react' import { toast } from "sonner" import { useAuth } from '@/hooks/useAuth' import { GET_MY_SUPPLIES } from '@/graphql/queries' @@ -30,25 +28,33 @@ interface Supply { name: string description?: string price: number - quantity: number imageUrl?: string createdAt: string updatedAt: string } +interface EditableSupply { + id?: string // undefined для новых записей + name: string + description: string + price: string + imageUrl: string + imageFile?: File + isNew: boolean + isEditing: boolean + hasChanges: boolean +} + +interface PendingChange extends EditableSupply { + isDeleted?: boolean +} + export function SuppliesTab() { const { user } = useAuth() - const [isDialogOpen, setIsDialogOpen] = useState(false) - const [editingSupply, setEditingSupply] = useState(null) - const [formData, setFormData] = useState({ - name: '', - description: '', - price: '', - quantity: '', - imageUrl: '' - }) - const [imageFile, setImageFile] = useState(null) - const [isUploading] = useState(false) + const [editableSupplies, setEditableSupplies] = useState([]) + const [pendingChanges, setPendingChanges] = useState([]) + const [isSaving, setIsSaving] = useState(false) + const [isInitialized, setIsInitialized] = useState(false) // GraphQL запросы и мутации const { data, loading, error, refetch } = useQuery(GET_MY_SUPPLIES, { @@ -60,45 +66,139 @@ export function SuppliesTab() { const supplies = data?.mySupplies || [] - const resetForm = () => { - setFormData({ + // Преобразуем загруженные расходники в редактируемый формат + useEffect(() => { + if (data?.mySupplies && !isInitialized) { + const convertedSupplies: EditableSupply[] = data.mySupplies.map((supply: Supply) => ({ + id: supply.id, + name: supply.name, + description: supply.description || '', + price: supply.price.toString(), + imageUrl: supply.imageUrl || '', + isNew: false, + isEditing: false, + hasChanges: false + })) + + setEditableSupplies(convertedSupplies) + setPendingChanges([]) + setIsInitialized(true) + } + }, [data, isInitialized]) + + // Добавить новую строку + const addNewRow = () => { + const tempId = `temp-${Date.now()}-${Math.random()}` + const newRow: EditableSupply = { + id: tempId, name: '', description: '', price: '', - quantity: '', - imageUrl: '' - }) - setImageFile(null) - setEditingSupply(null) + imageUrl: '', + isNew: true, + isEditing: true, + hasChanges: false + } + setEditableSupplies(prev => [...prev, newRow]) } - const handleEdit = (supply: Supply) => { - setEditingSupply(supply) - setFormData({ - name: supply.name, - description: supply.description || '', - price: supply.price.toString(), - quantity: supply.quantity.toString(), - imageUrl: supply.imageUrl || '' - }) - setIsDialogOpen(true) - } - - const handleDelete = async (supplyId: string) => { - try { - await deleteSupply({ - variables: { id: supplyId } - }) - await refetch() - toast.success('Расходник успешно удален') - } catch (error) { - console.error('Error deleting supply:', error) - toast.error('Ошибка при удалении расходника') + // Удалить строку + const removeRow = async (supplyId: string, isNew: boolean) => { + if (isNew) { + // Просто удаляем из массива если это новая строка + setEditableSupplies(prev => prev.filter(s => s.id !== supplyId)) + } else { + // Удаляем существующую запись сразу + try { + await deleteSupply({ + variables: { id: supplyId }, + update: (cache, { data }) => { + // Обновляем кэш Apollo Client + const existingData = cache.readQuery({ query: GET_MY_SUPPLIES }) as { mySupplies: Supply[] } | null + if (existingData && existingData.mySupplies) { + cache.writeQuery({ + query: GET_MY_SUPPLIES, + data: { + mySupplies: existingData.mySupplies.filter((s: Supply) => s.id !== supplyId) + } + }) + } + } + }) + + // Удаляем из локального состояния по ID, а не по индексу + setEditableSupplies(prev => prev.filter(s => s.id !== supplyId)) + toast.success('Расходник успешно удален') + } catch (error) { + console.error('Error deleting supply:', error) + toast.error('Ошибка при удалении расходника') + } } } + // Начать редактирование существующей строки + const startEditing = (supplyId: string) => { + setEditableSupplies(prev => + prev.map(supply => + supply.id === supplyId ? { ...supply, isEditing: true } : supply + ) + ) + } + // Отменить редактирование + const cancelEditing = (supplyId: string) => { + const supply = editableSupplies.find(s => s.id === supplyId) + if (!supply) return + + if (supply.isNew) { + // Удаляем новую строку + setEditableSupplies(prev => prev.filter(s => s.id !== supplyId)) + } else { + // Возвращаем к исходному состоянию + const originalSupply = supplies.find((s: Supply) => s.id === supply.id) + if (originalSupply) { + setEditableSupplies(prev => + prev.map(s => + s.id === supplyId + ? { + id: originalSupply.id, + name: originalSupply.name, + description: originalSupply.description || '', + price: originalSupply.price.toString(), + imageUrl: originalSupply.imageUrl || '', + isNew: false, + isEditing: false, + hasChanges: false + } + : s + ) + ) + } + } + } + // Обновить поле + const updateField = (supplyId: string, field: keyof EditableSupply, value: string | File) => { + setEditableSupplies(prev => + prev.map((supply) => { + if (supply.id !== supplyId) return supply + + const updated = { ...supply, hasChanges: true } + if (field === 'imageFile' && value instanceof File) { + updated.imageFile = value + updated.imageUrl = URL.createObjectURL(value) + } else if (typeof value === 'string') { + if (field === 'name') updated.name = value + else if (field === 'description') updated.description = value + else if (field === 'price') updated.price = value + else if (field === 'imageUrl') updated.imageUrl = value + } + return updated + }) + ) + } + + // Загрузка изображения const uploadImageAndGetUrl = async (file: File): Promise => { if (!user?.id) throw new Error('User not found') @@ -117,246 +217,184 @@ export function SuppliesTab() { } const result = await response.json() - console.log('Upload result:', result) return result.url } - const handleSubmit = async (e: React.FormEvent) => { - e.preventDefault() - - if (!formData.name.trim() || !formData.price || !formData.quantity) { - toast.error('Заполните обязательные поля') - return - } - - const quantity = parseInt(formData.quantity) - if (quantity < 0) { - toast.error('Количество не может быть отрицательным') - return - } - + // Сохранить все изменения + const saveAllChanges = async () => { + setIsSaving(true) try { - let imageUrl = formData.imageUrl - - // Загружаем изображение если выбрано - if (imageFile) { - const uploadResult = await uploadImageAndGetUrl(imageFile) - imageUrl = uploadResult - } - - const input = { - name: formData.name, - description: formData.description || undefined, - price: parseFloat(formData.price), - quantity: quantity, - imageUrl: imageUrl || undefined - } - - if (editingSupply) { - await updateSupply({ - variables: { id: editingSupply.id, input } - }) - } else { - await createSupply({ - variables: { input } - }) - } - - await refetch() - setIsDialogOpen(false) - resetForm() + const suppliesToSave = editableSupplies.filter(s => { + if (s.isNew) { + // Для новых записей проверяем что обязательные поля заполнены + return s.name.trim() && s.price + } + // Для существующих записей проверяем флаг изменений + return s.hasChanges + }) - toast.success(editingSupply ? 'Расходник успешно обновлен' : 'Расходник успешно создан') + console.log('Supplies to save:', suppliesToSave.length, suppliesToSave) + + for (const supply of suppliesToSave) { + if (!supply.name.trim() || !supply.price) { + toast.error(`Заполните обязательные поля для всех расходников`) + setIsSaving(false) + return + } + + let imageUrl = supply.imageUrl + + // Загружаем изображение если выбрано + if (supply.imageFile) { + imageUrl = await uploadImageAndGetUrl(supply.imageFile) + } + + const input = { + name: supply.name, + description: supply.description || undefined, + price: parseFloat(supply.price), + imageUrl: imageUrl || undefined + } + + if (supply.isNew) { + await createSupply({ + variables: { input }, + update: (cache, { data }) => { + if (data?.createSupply?.supply) { + const existingData = cache.readQuery({ query: GET_MY_SUPPLIES }) as { mySupplies: Supply[] } | null + if (existingData) { + cache.writeQuery({ + query: GET_MY_SUPPLIES, + data: { + mySupplies: [...existingData.mySupplies, data.createSupply.supply] + } + }) + } + } + } + }) + } else if (supply.id) { + await updateSupply({ + variables: { id: supply.id, input }, + update: (cache, { data }) => { + if (data?.updateSupply?.supply) { + const existingData = cache.readQuery({ query: GET_MY_SUPPLIES }) as { mySupplies: Supply[] } | null + if (existingData) { + cache.writeQuery({ + query: GET_MY_SUPPLIES, + data: { + mySupplies: existingData.mySupplies.map((s: Supply) => + s.id === data.updateSupply.supply.id ? data.updateSupply.supply : s + ) + } + }) + } + } + } + }) + } + } + + // Удаления теперь происходят сразу в removeRow, так что здесь обрабатываем только обновления + + toast.success('Все изменения успешно сохранены') + setPendingChanges([]) } catch (error) { - console.error('Error saving supply:', error) - toast.error('Ошибка при сохранении расходника') + console.error('Error saving changes:', error) + toast.error('Ошибка при сохранении изменений') + } finally { + setIsSaving(false) } } + // Проверяем есть ли несохраненные изменения + const hasUnsavedChanges = useMemo(() => { + return editableSupplies.some(s => { + if (s.isNew) { + // Для новых записей проверяем что есть данные для сохранения + return s.name.trim() || s.price || s.description.trim() + } + return s.hasChanges + }) + }, [editableSupplies]) + return (
- {/* Заголовок и кнопка добавления */} + {/* Заголовок и кнопки */}

Мои расходники

-

Управление вашими расходными материалами

+

Управление вашими расходниками

- - - - +
+ - - - - {editingSupply ? 'Редактировать расходник' : 'Добавить расходник'} - - -
-
- - 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 - /> -
- -
-
- - 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 - /> -
- -
- - 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 - /> -
-
- -
- - 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="Описание расходника" - /> -
- -
- - { - 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 && ( -
- Preview -
- )} -
- -
- - -
-
-
-
-
+ {hasUnsavedChanges && ( + + )} +
+
- {/* Таблица расходников */} -
- {loading ? ( -
-
-
- - - -
-

Загрузка расходников...

-
-
- ) : error ? ( -
-
-
- - - -
-

Ошибка загрузки

-

- Не удалось загрузить расходники -

- -
-
- ) : supplies.length === 0 ? ( + {/* Таблица расходников */} +
+ {loading ? ( +
+
+
+ + + +
+

Загрузка расходников...

+
+
+ ) : error ? ( +
+
+
+ + + +
+

Ошибка загрузки

+

+ Не удалось загрузить расходники +

+ +
+
+ ) : editableSupplies.length === 0 ? (
- +

Пока нет расходников

- Добавьте свой первый расходник для управления складскими запасами + Создайте свой первый расходник, чтобы начать работу

+ {supply.isEditing ? ( + <> + + + + ) : ( + + )} + @@ -449,7 +605,7 @@ export function SuppliesTab() { Отмена handleDelete(supply.id)} + onClick={() => removeRow(supply.id!, supply.isNew)} className="bg-gradient-to-r from-red-600 to-red-700 hover:from-red-700 hover:to-red-800 text-white border-0 shadow-lg shadow-red-500/25 hover:shadow-red-500/40 transition-all duration-300" > Удалить @@ -467,6 +623,8 @@ export function SuppliesTab() { )}
+ +
) -} \ No newline at end of file +} \ No newline at end of file diff --git a/src/components/supplies/create-supply-form.tsx b/src/components/supplies/create-supply-form.tsx new file mode 100644 index 0000000..36fec09 --- /dev/null +++ b/src/components/supplies/create-supply-form.tsx @@ -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(null) + + const renderStars = (rating: number) => { + return Array.from({ length: 5 }, (_, i) => ( + + )) + } + + if (selectedVariant === 'wholesaler') { + if (selectedWholesaler) { + return ( +
+
+
+ +
+

Товары оптовика

+

{selectedWholesaler.name}

+
+
+ +
+
+

Компонент товаров оптовика в разработке...

+
+
+ ) + } + + return ( +
+
+
+ +
+

Выбор оптовика

+

Выберите оптовика для создания поставки

+
+
+ +
+ +
+ {mockWholesalers.map((wholesaler) => ( + setSelectedWholesaler(wholesaler)} + > +
+ {/* Заголовок карточки */} +
+
+ +
+
+

+ {wholesaler.name} +

+

+ {wholesaler.fullName} +

+
+ {renderStars(wholesaler.rating)} + {wholesaler.rating} +
+
+
+ + {/* Информация */} +
+
+ + {wholesaler.address} +
+ + {wholesaler.phone && ( +
+ + {wholesaler.phone} +
+ )} + + {wholesaler.email && ( +
+ + {wholesaler.email} +
+ )} + +
+ + {wholesaler.productCount} товаров +
+
+ + {/* Специализация */} +
+

Специализация:

+
+ {wholesaler.specialization.map((spec, index) => ( + + {spec} + + ))} +
+
+ + {/* ИНН */} +
+

ИНН: {wholesaler.inn}

+
+
+
+ ))} +
+
+ ) + } + + return ( +
+
+
+

Создание поставки

+

Выберите способ создания поставки

+
+ +
+ +
+ {/* Вариант 1: Карточки */} + setSelectedVariant('cards')} + > +
+
+ +
+
+

Карточки

+

+ Создание поставки через выбор товаров по карточкам +

+
+ + В разработке + +
+
+ + {/* Вариант 2: Оптовик */} + setSelectedVariant('wholesaler')} + > +
+
+ +
+
+

Оптовик

+

+ Создание поставки через выбор товаров у оптовиков +

+
+ + Доступно + +
+
+
+ + +
+ ) +} \ No newline at end of file diff --git a/src/components/supplies/create-supply-page.tsx b/src/components/supplies/create-supply-page.tsx new file mode 100644 index 0000000..631bbe4 --- /dev/null +++ b/src/components/supplies/create-supply-page.tsx @@ -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(null) + const [selectedProducts, setSelectedProducts] = useState([]) + 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) => ( + + )) + } + + 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 ( +
+ +
+
+
+
+ +
+

Товары оптовика

+

{selectedWholesaler.name} • {mockProducts.length} товаров

+
+
+
+ +
+
+ + {showSummary && selectedProducts.length > 0 && ( + +
+

+ + Резюме заказа +

+ + {selectedProducts.length} товаров + +
+
+ {selectedProducts.map((product) => { + const discountedPrice = product.discount + ? product.price * (1 - product.discount / 100) + : product.price + return ( +
+
+ {product.name} +
+ {product.name} +
+ × {product.selectedQuantity} + {product.discount && ( + + -{product.discount}% + + )} +
+
+
+
+ + {formatCurrency(discountedPrice * product.selectedQuantity)} + + {product.discount && ( +
+ {formatCurrency(product.price * product.selectedQuantity)} +
+ )} +
+
+ ) + })} +
+ + Итого: {getTotalItems()} товаров + + + {formatCurrency(getTotalAmount())} + +
+ +
+
+ )} + +
+ {mockProducts.map((product) => { + const selectedQuantity = getSelectedQuantity(product.id) + const discountedPrice = product.discount + ? product.price * (1 - product.discount / 100) + : product.price + + return ( + +
+ {product.name} + + {/* Badges в верхних углах */} +
+ {product.isNew && ( + + + NEW + + )} + {product.isBestseller && ( + + + ХИТ + + )} + {product.discount && ( + + -{product.discount}% + + )} +
+ + {/* Количество в наличии */} +
+ 50 ? 'bg-green-500/80' : product.quantity > 10 ? 'bg-yellow-500/80' : 'bg-red-500/80'} text-white border-0 backdrop-blur`}> + {product.quantity} шт + +
+ + {/* Overlay с кнопками */} +
+
+ + +
+
+
+ +
+ {/* Заголовок и бренд */} +
+
+ {product.brand && ( + + {product.brand} + + )} + + {product.category} + +
+

+ {product.name} +

+

+ Артикул: {product.article} +

+
+ + {/* Описание */} +

+ {product.description} +

+ + {/* Характеристики */} +
+ {product.color && ( +
+
+ Цвет: {product.color} +
+ )} + {product.size && ( +
+ Размер: {product.size} +
+ )} + {product.weight && ( +
+ Вес: {product.weight} г +
+ )} +
+ + {/* Цена */} +
+
+
+
+ {formatCurrency(discountedPrice)} +
+ {product.discount && ( +
+ {formatCurrency(product.price)} +
+ )} +
+
за штуку
+
+
+ + {/* Управление количеством */} +
+
+ + { + 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} + /> + +
+ + {selectedQuantity > 0 && ( + + )} +
+ + {/* Сумма для выбранного товара */} + {selectedQuantity > 0 && ( +
+
+ Сумма: {formatCurrency(discountedPrice * selectedQuantity)} + {product.discount && ( + + (экономия {formatCurrency((product.price - discountedPrice) * selectedQuantity)}) + + )} +
+
+ )} +
+
+ ) + })} +
+ + {/* Floating корзина */} + {selectedProducts.length > 0 && ( +
+ +
+ )} +
+
+
+ ) + } + + // Рендер выбора оптовиков + if (selectedVariant === 'wholesaler') { + return ( +
+ +
+
+
+
+ +
+

Выбор оптовика

+

Выберите оптовика для создания поставки

+
+
+
+ +
+ {mockWholesalers.map((wholesaler) => ( + setSelectedWholesaler(wholesaler)} + > +
+
+
+ +
+
+

+ {wholesaler.name} +

+

+ {wholesaler.fullName} +

+
+ {renderStars(wholesaler.rating)} + {wholesaler.rating} +
+
+
+ +
+
+ + {wholesaler.address} +
+ + {wholesaler.phone && ( +
+ + {wholesaler.phone} +
+ )} + + {wholesaler.email && ( +
+ + {wholesaler.email} +
+ )} + +
+ + {wholesaler.productCount} товаров +
+
+ +
+

Специализация:

+
+ {wholesaler.specialization.map((spec, index) => ( + + {spec} + + ))} +
+
+ +
+

ИНН: {wholesaler.inn}

+
+
+
+ ))} +
+
+
+
+ ) + } + + // Главная страница выбора варианта + return ( +
+ +
+
+
+
+ +
+

Создание поставки

+

Выберите способ создания поставки

+
+
+
+ +
+ setSelectedVariant('cards')} + > +
+
+ +
+
+

Карточки

+

+ Создание поставки через выбор товаров по карточкам +

+
+ + В разработке + +
+
+ + setSelectedVariant('wholesaler')} + > +
+
+ +
+
+

Оптовик

+

+ Создание поставки через выбор товаров у оптовиков +

+
+ + Доступно + +
+
+
+
+
+
+ ) +} \ No newline at end of file diff --git a/src/components/supplies/supplies-dashboard.tsx b/src/components/supplies/supplies-dashboard.tsx new file mode 100644 index 0000000..c501e7c --- /dev/null +++ b/src/components/supplies/supplies-dashboard.tsx @@ -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>(new Set()) + const [expandedRoutes, setExpandedRoutes] = useState>(new Set()) + const [expandedWholesalers, setExpandedWholesalers] = useState>(new Set()) + const [expandedProducts, setExpandedProducts] = useState>(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 {label} + } + + 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 Отлично + } else if (efficiency >= 90) { + return Хорошо + } else { + return Проблемы + } + } + + return ( +
+ +
+
+ {/* Заголовок */} +
+
+

Поставки

+

Управление поставками товаров

+
+ +
+ + {/* Статистика */} +
+ +
+
+ +
+
+

Всего поставок

+

{mockSupplies.length}

+
+
+
+ + +
+
+ +
+
+

Общая сумма

+

+ {formatCurrency(mockSupplies.reduce((sum, supply) => sum + supply.grandTotal, 0))} +

+
+
+
+ + +
+
+ +
+
+

В пути

+

+ {mockSupplies.filter(supply => supply.status === 'in-transit').length} +

+
+
+
+ + +
+
+ +
+
+

С браком

+

+ {mockSupplies.filter(supply => supply.defectTotal > 0).length} +

+
+
+
+
+ + {/* Многоуровневая таблица поставок */} + +
+ + + + + + + + + + + + + + + + + + {mockSupplies.map((supply) => { + const isSupplyExpanded = expandedSupplies.has(supply.id) + + return ( + + {/* Уровень 1: Основная строка поставки */} + + + + + + + + + + + + + + + {/* Уровень 2: Маршруты */} + {isSupplyExpanded && supply.routes.map((route) => { + const isRouteExpanded = expandedRoutes.has(route.id) + return ( + + + + + + + + + + + + + + + {/* Уровень 3: Оптовики */} + {isRouteExpanded && route.wholesalers.map((wholesaler) => { + const isWholesalerExpanded = expandedWholesalers.has(wholesaler.id) + return ( + + + + + + + + + + + + + + {/* Уровень 4: Товары */} + {isWholesalerExpanded && wholesaler.products.map((product) => { + const isProductExpanded = expandedProducts.has(product.id) + return ( + + + + + + + + + + + + + + {/* Уровень 5: Параметры товара */} + {isProductExpanded && ( + + + + )} + + ) + })} + + ) + })} + + ) + })} + + ) + })} + +
Дата поставкиДата созданияПланФактБракЦена товаровУслуги ФФЛогистика до ФФИтого суммаСтатус
+
+ + #{supply.number} +
+
+
+ + {formatDate(supply.deliveryDate)} +
+
+ {formatDate(supply.createdDate)} + + {supply.plannedTotal} + + {supply.actualTotal} + + 0 ? 'text-red-400' : 'text-white'}`}> + {supply.defectTotal} + + + {formatCurrency(supply.totalProductPrice)} + + {formatCurrency(supply.totalFulfillmentPrice)} + + {formatCurrency(supply.totalLogisticsPrice)} + +
+ + {formatCurrency(supply.grandTotal)} +
+
+ {getStatusBadge(supply.status)} +
+
+ + + Маршрут +
+
+
+
+ {route.from} + + {route.to} +
+
{route.fromAddress} → {route.toAddress}
+
+
+ + {route.wholesalers.reduce((sum, w) => + sum + w.products.reduce((pSum, p) => pSum + p.plannedQty, 0), 0 + )} + + + + {route.wholesalers.reduce((sum, w) => + sum + w.products.reduce((pSum, p) => pSum + p.actualQty, 0), 0 + )} + + + + {route.wholesalers.reduce((sum, w) => + sum + w.products.reduce((pSum, p) => pSum + p.defectQty, 0), 0 + )} + + + {formatCurrency(route.totalProductPrice)} + + {formatCurrency(route.fulfillmentServicePrice)} + + {formatCurrency(route.logisticsPrice)} + + {formatCurrency(route.totalAmount)} +
+
+ + + Оптовик +
+
+
+
{wholesaler.name}
+
ИНН: {wholesaler.inn}
+
{wholesaler.address}
+
{wholesaler.contact}
+
+
+ + {wholesaler.products.reduce((sum, p) => sum + p.plannedQty, 0)} + + + + {wholesaler.products.reduce((sum, p) => sum + p.actualQty, 0)} + + + + {wholesaler.products.reduce((sum, p) => sum + p.defectQty, 0)} + + + + {formatCurrency(wholesaler.products.reduce((sum, p) => sum + calculateProductTotal(p), 0))} + + + {formatCurrency(wholesaler.totalAmount)} +
+
+ + + Товар +
+
+
+
{product.name}
+
Артикул: {product.sku}
+ + {product.category} + +
+
+ {product.plannedQty} + + {product.actualQty} + + 0 ? 'text-red-400' : 'text-white'}`}> + {product.defectQty} + + +
+
{formatCurrency(calculateProductTotal(product))}
+
{formatCurrency(product.productPrice)} за шт.
+
+
+ {getEfficiencyBadge(product.plannedQty, product.actualQty, product.defectQty)} + + {formatCurrency(calculateProductTotal(product))} +
+
+
+

+ 📋 Параметры товара: +

+
+ {product.parameters.map((param) => ( +
+
{param.name}
+
+ {param.value} {param.unit || ''} +
+
+ ))} +
+
+
+
+
+
+
+
+
+ ) +} \ No newline at end of file diff --git a/src/components/supplies/wholesaler-products.tsx b/src/components/supplies/wholesaler-products.tsx new file mode 100644 index 0000000..e5ba98a --- /dev/null +++ b/src/components/supplies/wholesaler-products.tsx @@ -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([]) + 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 ( +
+
+
+ +
+

Товары оптовика

+

{wholesaler.name} • {mockProducts.length} товаров

+
+
+
+ + +
+
+ + {showSummary && selectedProducts.length > 0 && ( + +

Резюме заказа

+
+ {selectedProducts.map((product) => ( +
+
+ {product.name} + × {product.selectedQuantity} +
+ + {formatCurrency(product.price * product.selectedQuantity)} + +
+ ))} +
+ + Итого: {getTotalItems()} товаров + + + {formatCurrency(getTotalAmount())} + +
+ +
+
+ )} + +
+ {mockProducts.map((product) => { + const selectedQuantity = getSelectedQuantity(product.id) + + return ( + +
+ {product.name} +
+ + В наличии: {product.quantity} + +
+
+ +
+
+

+ {product.name} +

+

+ Артикул: {product.article} +

+
+ + {product.category} + + {product.brand && ( + + {product.brand} + + )} +
+
+ +

+ {product.description} +

+ +
+ {product.color && ( +
+ Цвет: {product.color} +
+ )} + {product.size && ( +
+ Размер: {product.size} +
+ )} + {product.weight && ( +
+ Вес: {product.weight} г +
+ )} +
+ +
+
+
+ {formatCurrency(product.price)} +
+
за штуку
+
+
+ +
+ + { + 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} + /> + +
+ + {selectedQuantity > 0 && ( +
+
+ Сумма: {formatCurrency(product.price * selectedQuantity)} +
+
+ )} +
+
+ ) + })} +
+ + {selectedProducts.length > 0 && ( +
+ +
+ )} +
+ ) +} \ No newline at end of file diff --git a/src/components/supplies/wholesaler-selection.tsx b/src/components/supplies/wholesaler-selection.tsx new file mode 100644 index 0000000..6392197 --- /dev/null +++ b/src/components/supplies/wholesaler-selection.tsx @@ -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(null) + + if (selectedWholesaler) { + return ( +
+
+
+ +
+

Товары оптовика

+

{selectedWholesaler.name}

+
+
+
+
+

Компонент товаров в разработке...

+
+
+ ) + } + + const renderStars = (rating: number) => { + return Array.from({ length: 5 }, (_, i) => ( + + )) + } + + return ( +
+
+
+ +
+

Выбор оптовика

+

Выберите оптовика для создания поставки

+
+
+ +
+ +
+ {mockWholesalers.map((wholesaler) => ( + setSelectedWholesaler(wholesaler)} + > +
+ {/* Заголовок карточки */} +
+
+ +
+
+

+ {wholesaler.name} +

+

+ {wholesaler.fullName} +

+
+ {renderStars(wholesaler.rating)} + {wholesaler.rating} +
+
+
+ + {/* Информация */} +
+
+ + {wholesaler.address} +
+ + {wholesaler.phone && ( +
+ + {wholesaler.phone} +
+ )} + + {wholesaler.email && ( +
+ + {wholesaler.email} +
+ )} + +
+ + {wholesaler.productCount} товаров +
+
+ + {/* Специализация */} +
+

Специализация:

+
+ {wholesaler.specialization.map((spec, index) => ( + + {spec} + + ))} +
+
+ + {/* ИНН */} +
+

ИНН: {wholesaler.inn}

+
+
+
+ ))} +
+
+ ) +} \ No newline at end of file diff --git a/src/graphql/mutations.ts b/src/graphql/mutations.ts index 709b38e..bded43f 100644 --- a/src/graphql/mutations.ts +++ b/src/graphql/mutations.ts @@ -602,7 +602,6 @@ export const CREATE_SUPPLY = gql` name description price - quantity imageUrl createdAt updatedAt @@ -621,7 +620,6 @@ export const UPDATE_SUPPLY = gql` name description price - quantity imageUrl createdAt updatedAt @@ -636,6 +634,51 @@ export const DELETE_SUPPLY = gql` } ` +// Мутации для логистики +export const CREATE_LOGISTICS = gql` + mutation CreateLogistics($input: LogisticsInput!) { + createLogistics(input: $input) { + success + message + logistics { + id + fromLocation + toLocation + priceUnder1m3 + priceOver1m3 + description + createdAt + updatedAt + } + } + } +` + +export const UPDATE_LOGISTICS = gql` + mutation UpdateLogistics($id: ID!, $input: LogisticsInput!) { + updateLogistics(id: $id, input: $input) { + success + message + logistics { + id + fromLocation + toLocation + priceUnder1m3 + priceOver1m3 + description + createdAt + updatedAt + } + } + } +` + +export const DELETE_LOGISTICS = gql` + mutation DeleteLogistics($id: ID!) { + deleteLogistics(id: $id) + } +` + // Мутации для товаров оптовика export const CREATE_PRODUCT = gql` mutation CreateProduct($input: ProductInput!) { diff --git a/src/graphql/queries.ts b/src/graphql/queries.ts index 9458fb0..061f6bc 100644 --- a/src/graphql/queries.ts +++ b/src/graphql/queries.ts @@ -69,7 +69,6 @@ export const GET_MY_SUPPLIES = gql` name description price - quantity imageUrl createdAt updatedAt @@ -77,6 +76,21 @@ export const GET_MY_SUPPLIES = gql` } ` +export const GET_MY_LOGISTICS = gql` + query GetMyLogistics { + myLogistics { + id + fromLocation + toLocation + priceUnder1m3 + priceOver1m3 + description + createdAt + updatedAt + } + } +` + export const GET_MY_PRODUCTS = gql` query GetMyProducts { myProducts { diff --git a/src/graphql/resolvers.ts b/src/graphql/resolvers.ts index eb73697..f95eb27 100644 --- a/src/graphql/resolvers.ts +++ b/src/graphql/resolvers.ts @@ -530,12 +530,31 @@ export const resolvers = { throw new GraphQLError('У пользователя нет организации') } - // Проверяем, что это фулфилмент центр - if (currentUser.organization.type !== 'FULFILLMENT') { - throw new GraphQLError('Расходники доступны только для фулфилмент центров') + return await prisma.supply.findMany({ + where: { organizationId: currentUser.organization.id }, + include: { organization: true }, + orderBy: { createdAt: 'desc' } + }) + }, + + // Логистика организации + myLogistics: async (_: unknown, __: unknown, context: Context) => { + if (!context.user) { + throw new GraphQLError('Требуется авторизация', { + extensions: { code: 'UNAUTHENTICATED' } + }) } - return await prisma.supply.findMany({ + const currentUser = await prisma.user.findUnique({ + where: { id: context.user.id }, + include: { organization: true } + }) + + if (!currentUser?.organization) { + throw new GraphQLError('У пользователя нет организации') + } + + return await prisma.logistics.findMany({ where: { organizationId: currentUser.organization.id }, include: { organization: true }, orderBy: { createdAt: 'desc' } @@ -2322,7 +2341,7 @@ export const resolvers = { }, // Создать расходник - createSupply: async (_: unknown, args: { input: { name: string; description?: string; price: number; quantity: number; imageUrl?: string } }, context: Context) => { + createSupply: async (_: unknown, args: { input: { name: string; description?: string; price: number; imageUrl?: string } }, context: Context) => { if (!context.user) { throw new GraphQLError('Требуется авторизация', { extensions: { code: 'UNAUTHENTICATED' } @@ -2349,7 +2368,7 @@ export const resolvers = { name: args.input.name, description: args.input.description, price: args.input.price, - quantity: args.input.quantity, + quantity: 0, // Временно устанавливаем 0, так как поле убрано из интерфейса imageUrl: args.input.imageUrl, organizationId: currentUser.organization.id }, @@ -2371,7 +2390,7 @@ export const resolvers = { }, // Обновить расходник - updateSupply: async (_: unknown, args: { id: string; input: { name: string; description?: string; price: number; quantity: number; imageUrl?: string } }, context: Context) => { + updateSupply: async (_: unknown, args: { id: string; input: { name: string; description?: string; price: number; imageUrl?: string } }, context: Context) => { if (!context.user) { throw new GraphQLError('Требуется авторизация', { extensions: { code: 'UNAUTHENTICATED' } @@ -2406,7 +2425,7 @@ export const resolvers = { name: args.input.name, description: args.input.description, price: args.input.price, - quantity: args.input.quantity, + quantity: 0, // Временно устанавливаем 0, так как поле убрано из интерфейса imageUrl: args.input.imageUrl }, include: { organization: true } @@ -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 } \ No newline at end of file diff --git a/src/graphql/typedefs.ts b/src/graphql/typedefs.ts index 1f4acbe..71be7d2 100644 --- a/src/graphql/typedefs.ts +++ b/src/graphql/typedefs.ts @@ -29,6 +29,9 @@ export const typeDefs = gql` # Расходники организации mySupplies: [Supply!]! + # Логистика организации + myLogistics: [Logistics!]! + # Товары оптовика myProducts: [Product!]! @@ -100,6 +103,11 @@ export const typeDefs = gql` updateSupply(id: ID!, input: SupplyInput!): SupplyResponse! deleteSupply(id: ID!): Boolean! + # Работа с логистикой + createLogistics(input: LogisticsInput!): LogisticsResponse! + updateLogistics(id: ID!, input: LogisticsInput!): LogisticsResponse! + deleteLogistics(id: ID!): Boolean! + # Работа с товарами (для оптовиков) createProduct(input: ProductInput!): ProductResponse! updateProduct(id: ID!, input: ProductInput!): ProductResponse! @@ -372,7 +380,6 @@ export const typeDefs = gql` name: String! description: String price: Float! - quantity: Int! imageUrl: String createdAt: String! updatedAt: String! @@ -383,7 +390,6 @@ export const typeDefs = gql` name: String! description: String price: Float! - quantity: Int! imageUrl: String } @@ -393,6 +399,33 @@ export const typeDefs = gql` supply: Supply } + # Типы для логистики + type Logistics { + id: ID! + fromLocation: String! + toLocation: String! + priceUnder1m3: Float! + priceOver1m3: Float! + description: String + createdAt: String! + updatedAt: String! + organization: Organization! + } + + input LogisticsInput { + fromLocation: String! + toLocation: String! + priceUnder1m3: Float! + priceOver1m3: Float! + description: String + } + + type LogisticsResponse { + success: Boolean! + message: String! + logistics: Logistics + } + # Типы для категорий товаров type Category { id: ID! diff --git a/src/lib/input-masks.ts b/src/lib/input-masks.ts new file mode 100644 index 0000000..86c6e29 --- /dev/null +++ b/src/lib/input-masks.ts @@ -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 } +} \ No newline at end of file diff --git a/src/services/wildberries-service.ts b/src/services/wildberries-service.ts new file mode 100644 index 0000000..d490a3a --- /dev/null +++ b/src/services/wildberries-service.ts @@ -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 { + 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 { + 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 { + 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 { + 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('Ошибка получения списка поставок') + } + } +} \ No newline at end of file