Merge pull request 'Refactor: Replace wholesaler with supplier terminology and add fulfillment consumables logic' (#1) from testing into main

Reviewed-on: #1
This commit is contained in:
2025-07-30 17:41:50 +03:00
31 changed files with 3343 additions and 1538 deletions

View File

@ -359,7 +359,7 @@ export function FulfillmentWarehouse2Demo() {
description="К обработке"
/>
<StatCard
title="Расходники ФФ"
title="Расходники фулфилмента"
icon={Wrench}
current={warehouseStats.fulfillmentSupplies.current}
change={warehouseStats.fulfillmentSupplies.change}

View File

@ -1,29 +1,32 @@
"use client"
"use client";
import { useAuth } from '@/hooks/useAuth'
import { useMutation } from '@apollo/client'
import { UPDATE_USER_PROFILE, UPDATE_ORGANIZATION_BY_INN } from '@/graphql/mutations'
import { GET_ME } from '@/graphql/queries'
import { apolloClient } from '@/lib/apollo-client'
import { formatPhone } from '@/lib/utils'
import S3Service from '@/services/s3-service'
import { Card } from '@/components/ui/card'
import { Avatar, AvatarFallback } from '@/components/ui/avatar'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Badge } from '@/components/ui/badge'
import { Alert, AlertDescription } from '@/components/ui/alert'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { Sidebar } from './sidebar'
import { useSidebar } from '@/hooks/useSidebar'
import {
User,
Building2,
Phone,
Mail,
MapPin,
CreditCard,
import { useAuth } from "@/hooks/useAuth";
import { useMutation } from "@apollo/client";
import {
UPDATE_USER_PROFILE,
UPDATE_ORGANIZATION_BY_INN,
} from "@/graphql/mutations";
import { GET_ME } from "@/graphql/queries";
import { apolloClient } from "@/lib/apollo-client";
import { formatPhone } from "@/lib/utils";
import S3Service from "@/services/s3-service";
import { Card } from "@/components/ui/card";
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Badge } from "@/components/ui/badge";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Sidebar } from "./sidebar";
import { useSidebar } from "@/hooks/useSidebar";
import {
User,
Building2,
Phone,
Mail,
MapPin,
CreditCard,
Key,
Edit3,
ExternalLink,
@ -35,649 +38,769 @@ import {
RefreshCw,
Calendar,
Settings,
Camera
} from 'lucide-react'
import { useState, useEffect, useRef } from 'react'
import Image from 'next/image'
Camera,
} from "lucide-react";
import { useState, useEffect, useRef } from "react";
import Image from "next/image";
export function UserSettings() {
const { getSidebarMargin } = useSidebar()
const { user, updateUser } = useAuth()
const [updateUserProfile, { loading: isSaving }] = useMutation(UPDATE_USER_PROFILE)
const [updateOrganizationByInn, { loading: isUpdatingOrganization }] = useMutation(UPDATE_ORGANIZATION_BY_INN)
const [isEditing, setIsEditing] = useState(false)
const [saveMessage, setSaveMessage] = useState<{ type: 'success' | 'error', text: string } | null>(null)
const [partnerLink, setPartnerLink] = useState('')
const [isGenerating, setIsGenerating] = useState(false)
const [isUploadingAvatar, setIsUploadingAvatar] = useState(false)
const [localAvatarUrl, setLocalAvatarUrl] = useState<string | null>(null)
const phoneInputRef = useRef<HTMLInputElement>(null)
const whatsappInputRef = useRef<HTMLInputElement>(null)
const { getSidebarMargin } = useSidebar();
const { user, updateUser } = useAuth();
const [updateUserProfile, { loading: isSaving }] =
useMutation(UPDATE_USER_PROFILE);
const [updateOrganizationByInn, { loading: isUpdatingOrganization }] =
useMutation(UPDATE_ORGANIZATION_BY_INN);
const [isEditing, setIsEditing] = useState(false);
const [saveMessage, setSaveMessage] = useState<{
type: "success" | "error";
text: string;
} | null>(null);
const [partnerLink, setPartnerLink] = useState("");
const [isGenerating, setIsGenerating] = useState(false);
const [isUploadingAvatar, setIsUploadingAvatar] = useState(false);
const [localAvatarUrl, setLocalAvatarUrl] = useState<string | null>(null);
const phoneInputRef = useRef<HTMLInputElement>(null);
const whatsappInputRef = useRef<HTMLInputElement>(null);
// Инициализируем данные из пользователя и организации
const [formData, setFormData] = useState({
// Контактные данные организации
orgPhone: '', // телефон организации, не пользователя
managerName: '',
telegram: '',
whatsapp: '',
email: '',
orgPhone: "", // телефон организации, не пользователя
managerName: "",
telegram: "",
whatsapp: "",
email: "",
// Организация - данные могут быть заполнены из DaData
orgName: '',
address: '',
orgName: "",
address: "",
// Юридические данные - могут быть заполнены из DaData
fullName: '',
inn: '',
ogrn: '',
registrationPlace: '',
fullName: "",
inn: "",
ogrn: "",
registrationPlace: "",
// Финансовые данные - требуют ручного заполнения
bankName: '',
bik: '',
accountNumber: '',
corrAccount: '',
bankName: "",
bik: "",
accountNumber: "",
corrAccount: "",
// API ключи маркетплейсов
wildberriesApiKey: '',
ozonApiKey: ''
})
wildberriesApiKey: "",
ozonApiKey: "",
});
// Загружаем данные организации при монтировании компонента
useEffect(() => {
if (user?.organization) {
const org = user.organization
const org = user.organization;
// Извлекаем первый телефон из phones JSON
let orgPhone = ''
let orgPhone = "";
if (org.phones && Array.isArray(org.phones) && org.phones.length > 0) {
orgPhone = org.phones[0].value || org.phones[0] || ''
} else if (org.phones && typeof org.phones === 'object') {
const phoneValues = Object.values(org.phones)
orgPhone = org.phones[0].value || org.phones[0] || "";
} else if (org.phones && typeof org.phones === "object") {
const phoneValues = Object.values(org.phones);
if (phoneValues.length > 0) {
orgPhone = String(phoneValues[0])
orgPhone = String(phoneValues[0]);
}
}
// Извлекаем email из emails JSON
let email = ''
let email = "";
if (org.emails && Array.isArray(org.emails) && org.emails.length > 0) {
email = org.emails[0].value || org.emails[0] || ''
} else if (org.emails && typeof org.emails === 'object') {
const emailValues = Object.values(org.emails)
email = org.emails[0].value || org.emails[0] || "";
} else if (org.emails && typeof org.emails === "object") {
const emailValues = Object.values(org.emails);
if (emailValues.length > 0) {
email = String(emailValues[0])
email = String(emailValues[0]);
}
}
// Извлекаем дополнительные данные из managementPost (JSON)
let customContacts: {
managerName?: string
telegram?: string
whatsapp?: string
managerName?: string;
telegram?: string;
whatsapp?: string;
bankDetails?: {
bankName?: string
bik?: string
accountNumber?: string
corrAccount?: string
}
} = {}
bankName?: string;
bik?: string;
accountNumber?: string;
corrAccount?: string;
};
} = {};
try {
if (org.managementPost && typeof org.managementPost === 'string') {
customContacts = JSON.parse(org.managementPost)
if (org.managementPost && typeof org.managementPost === "string") {
customContacts = JSON.parse(org.managementPost);
}
} catch (e) {
console.warn('Ошибка парсинга managementPost:', e)
console.warn("Ошибка парсинга managementPost:", e);
}
setFormData({
orgPhone: orgPhone || '+7',
managerName: user?.managerName || '',
telegram: customContacts?.telegram || '',
whatsapp: customContacts?.whatsapp || '',
orgPhone: orgPhone || "+7",
managerName: user?.managerName || "",
telegram: customContacts?.telegram || "",
whatsapp: customContacts?.whatsapp || "",
email: email,
orgName: org.name || '',
address: org.address || '',
fullName: org.fullName || '',
inn: org.inn || '',
ogrn: org.ogrn || '',
registrationPlace: org.address || '',
bankName: customContacts?.bankDetails?.bankName || '',
bik: customContacts?.bankDetails?.bik || '',
accountNumber: customContacts?.bankDetails?.accountNumber || '',
corrAccount: customContacts?.bankDetails?.corrAccount || '',
wildberriesApiKey: '',
ozonApiKey: ''
})
orgName: org.name || "",
address: org.address || "",
fullName: org.fullName || "",
inn: org.inn || "",
ogrn: org.ogrn || "",
registrationPlace: org.address || "",
bankName: customContacts?.bankDetails?.bankName || "",
bik: customContacts?.bankDetails?.bik || "",
accountNumber: customContacts?.bankDetails?.accountNumber || "",
corrAccount: customContacts?.bankDetails?.corrAccount || "",
wildberriesApiKey: "",
ozonApiKey: "",
});
}
}, [user])
}, [user]);
const getInitials = () => {
const orgName = user?.organization?.name || user?.organization?.fullName
const orgName = user?.organization?.name || user?.organization?.fullName;
if (orgName) {
return orgName.charAt(0).toUpperCase()
return orgName.charAt(0).toUpperCase();
}
return user?.phone ? user.phone.slice(-2).toUpperCase() : 'О'
}
return user?.phone ? user.phone.slice(-2).toUpperCase() : "О";
};
const getCabinetTypeName = () => {
if (!user?.organization?.type) return 'Не указан'
if (!user?.organization?.type) return "Не указан";
switch (user.organization.type) {
case 'FULFILLMENT':
return 'Фулфилмент'
case 'SELLER':
return 'Селлер'
case 'LOGIST':
return 'Логистика'
case 'WHOLESALE':
return 'Поставщик'
case "FULFILLMENT":
return "Фулфилмент";
case "SELLER":
return "Селлер";
case "LOGIST":
return "Логистика";
case "WHOLESALE":
return "Поставщик";
default:
return 'Не указан'
return "Не указан";
}
}
};
// Обновленная функция для проверки заполненности профиля
const checkProfileCompleteness = () => {
// Базовые поля (обязательные для всех)
const baseFields = [
{ field: 'orgPhone', label: 'Телефон организации', value: formData.orgPhone },
{ field: 'managerName', label: 'Имя управляющего', value: formData.managerName },
{ field: 'email', label: 'Email', value: formData.email }
]
{
field: "orgPhone",
label: "Телефон организации",
value: formData.orgPhone,
},
{
field: "managerName",
label: "Имя управляющего",
value: formData.managerName,
},
{ field: "email", label: "Email", value: formData.email },
];
// Дополнительные поля в зависимости от типа кабинета
const additionalFields = []
if (user?.organization?.type === 'FULFILLMENT' || user?.organization?.type === 'LOGIST' || user?.organization?.type === 'WHOLESALE' || user?.organization?.type === 'SELLER') {
const additionalFields = [];
if (
user?.organization?.type === "FULFILLMENT" ||
user?.organization?.type === "LOGIST" ||
user?.organization?.type === "WHOLESALE" ||
user?.organization?.type === "SELLER"
) {
// Финансовые данные - всегда обязательны для всех типов кабинетов
additionalFields.push(
{ field: 'bankName', label: 'Название банка', value: formData.bankName },
{ field: 'bik', label: 'БИК', value: formData.bik },
{ field: 'accountNumber', label: 'Расчетный счет', value: formData.accountNumber },
{ field: 'corrAccount', label: 'Корр. счет', value: formData.corrAccount }
)
{
field: "bankName",
label: "Название банка",
value: formData.bankName,
},
{ field: "bik", label: "БИК", value: formData.bik },
{
field: "accountNumber",
label: "Расчетный счет",
value: formData.accountNumber,
},
{
field: "corrAccount",
label: "Корр. счет",
value: formData.corrAccount,
}
);
}
const allRequiredFields = [...baseFields, ...additionalFields]
const filledRequiredFields = allRequiredFields.filter(field => field.value && field.value.trim() !== '').length
const allRequiredFields = [...baseFields, ...additionalFields];
const filledRequiredFields = allRequiredFields.filter(
(field) => field.value && field.value.trim() !== ""
).length;
// Подсчитываем бонусные баллы за автоматически заполненные поля
let autoFilledFields = 0
let totalAutoFields = 0
let autoFilledFields = 0;
let totalAutoFields = 0;
// Номер телефона пользователя для авторизации (не считаем в процентах заполненности)
// Телефон организации учитывается отдельно как обычное поле
// Данные организации из DaData (если есть ИНН)
if (formData.inn || user?.organization?.inn) {
totalAutoFields += 5 // ИНН + название + адрес + полное название + ОГРН
if (formData.inn || user?.organization?.inn) autoFilledFields += 1 // ИНН
if (formData.orgName || user?.organization?.name) autoFilledFields += 1 // Название
if (formData.address || user?.organization?.address) autoFilledFields += 1 // Адрес
if (formData.fullName || user?.organization?.fullName) autoFilledFields += 1 // Полное название
if (formData.ogrn || user?.organization?.ogrn) autoFilledFields += 1 // ОГРН
totalAutoFields += 5; // ИНН + название + адрес + полное название + ОГРН
if (formData.inn || user?.organization?.inn) autoFilledFields += 1; // ИНН
if (formData.orgName || user?.organization?.name) autoFilledFields += 1; // Название
if (formData.address || user?.organization?.address)
autoFilledFields += 1; // Адрес
if (formData.fullName || user?.organization?.fullName)
autoFilledFields += 1; // Полное название
if (formData.ogrn || user?.organization?.ogrn) autoFilledFields += 1; // ОГРН
}
// Место регистрации
if (formData.registrationPlace || user?.organization?.registrationDate) {
autoFilledFields += 1
totalAutoFields += 1
autoFilledFields += 1;
totalAutoFields += 1;
}
const totalPossibleFields = allRequiredFields.length + totalAutoFields
const totalFilledFields = filledRequiredFields + autoFilledFields
const percentage = totalPossibleFields > 0 ? Math.round((totalFilledFields / totalPossibleFields) * 100) : 0
const missingFields = allRequiredFields.filter(field => !field.value || field.value.trim() === '').map(field => field.label)
const totalPossibleFields = allRequiredFields.length + totalAutoFields;
const totalFilledFields = filledRequiredFields + autoFilledFields;
return { percentage, missingFields }
}
const percentage =
totalPossibleFields > 0
? Math.round((totalFilledFields / totalPossibleFields) * 100)
: 0;
const missingFields = allRequiredFields
.filter((field) => !field.value || field.value.trim() === "")
.map((field) => field.label);
const profileStatus = checkProfileCompleteness()
const isIncomplete = profileStatus.percentage < 100
return { percentage, missingFields };
};
const profileStatus = checkProfileCompleteness();
const isIncomplete = profileStatus.percentage < 100;
const generatePartnerLink = async () => {
if (!user?.id) return
setIsGenerating(true)
setSaveMessage(null)
if (!user?.id) return;
setIsGenerating(true);
setSaveMessage(null);
try {
// Генерируем уникальный код партнера
const partnerCode = btoa(user.id + Date.now()).replace(/[^a-zA-Z0-9]/g, '').substring(0, 12)
const link = `${window.location.origin}/register?partner=${partnerCode}`
setPartnerLink(link)
setSaveMessage({ type: 'success', text: 'Партнерская ссылка сгенерирована!' })
const partnerCode = btoa(user.id + Date.now())
.replace(/[^a-zA-Z0-9]/g, "")
.substring(0, 12);
const link = `${window.location.origin}/register?partner=${partnerCode}`;
setPartnerLink(link);
setSaveMessage({
type: "success",
text: "Партнерская ссылка сгенерирована!",
});
// TODO: Сохранить партнерский код в базе данных
console.log('Partner code generated:', partnerCode)
console.log("Partner code generated:", partnerCode);
} catch (error) {
console.error('Error generating partner link:', error)
setSaveMessage({ type: 'error', text: 'Ошибка при генерации ссылки' })
console.error("Error generating partner link:", error);
setSaveMessage({ type: "error", text: "Ошибка при генерации ссылки" });
} finally {
setIsGenerating(false)
setIsGenerating(false);
}
}
};
const handleCopyLink = async () => {
if (!partnerLink) {
await generatePartnerLink()
return
await generatePartnerLink();
return;
}
try {
await navigator.clipboard.writeText(partnerLink)
setSaveMessage({ type: 'success', text: 'Ссылка скопирована!' })
await navigator.clipboard.writeText(partnerLink);
setSaveMessage({ type: "success", text: "Ссылка скопирована!" });
} catch (error) {
console.error('Error copying to clipboard:', error)
setSaveMessage({ type: 'error', text: 'Ошибка при копировании' })
console.error("Error copying to clipboard:", error);
setSaveMessage({ type: "error", text: "Ошибка при копировании" });
}
}
};
const handleOpenLink = async () => {
if (!partnerLink) {
await generatePartnerLink()
return
await generatePartnerLink();
return;
}
window.open(partnerLink, '_blank')
}
window.open(partnerLink, "_blank");
};
const handleAvatarUpload = async (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0]
if (!file || !user?.id) return
const handleAvatarUpload = async (
event: React.ChangeEvent<HTMLInputElement>
) => {
const file = event.target.files?.[0];
if (!file || !user?.id) return;
setIsUploadingAvatar(true)
setSaveMessage(null)
setIsUploadingAvatar(true);
setSaveMessage(null);
try {
const avatarUrl = await S3Service.uploadAvatar(file, user.id)
const avatarUrl = await S3Service.uploadAvatar(file, user.id);
// Сразу обновляем локальное состояние для мгновенного отображения
setLocalAvatarUrl(avatarUrl)
setLocalAvatarUrl(avatarUrl);
// Обновляем аватар пользователя через GraphQL
const result = await updateUserProfile({
variables: {
input: {
avatar: avatarUrl
}
avatar: avatarUrl,
},
},
update: (cache, { data }) => {
if (data?.updateUserProfile?.success) {
// Обновляем кеш Apollo Client
try {
const existingData: any = cache.readQuery({ query: GET_ME })
const existingData: any = cache.readQuery({ query: GET_ME });
if (existingData?.me) {
cache.writeQuery({
query: GET_ME,
data: {
me: {
...existingData.me,
avatar: avatarUrl
}
}
})
avatar: avatarUrl,
},
},
});
}
} catch (error) {
console.log('Cache update error:', error)
console.log("Cache update error:", error);
}
}
}
})
},
});
if (result.data?.updateUserProfile?.success) {
setSaveMessage({ type: 'success', text: 'Аватар успешно обновлен!' })
setSaveMessage({ type: "success", text: "Аватар успешно обновлен!" });
// Обновляем локальное состояние в useAuth для мгновенного отображения в сайдбаре
updateUser({ avatar: avatarUrl })
updateUser({ avatar: avatarUrl });
// Принудительно обновляем Apollo Client кеш
await apolloClient.refetchQueries({
include: [GET_ME]
})
include: [GET_ME],
});
// Очищаем input файла
if (event.target) {
event.target.value = ''
event.target.value = "";
}
// Очищаем сообщение через 3 секунды
setTimeout(() => {
setSaveMessage(null)
}, 3000)
setSaveMessage(null);
}, 3000);
} else {
throw new Error(result.data?.updateUserProfile?.message || 'Failed to update avatar')
throw new Error(
result.data?.updateUserProfile?.message || "Failed to update avatar"
);
}
} catch (error) {
console.error('Error uploading avatar:', error)
console.error("Error uploading avatar:", error);
// Сбрасываем локальное состояние при ошибке
setLocalAvatarUrl(null)
const errorMessage = error instanceof Error ? error.message : 'Ошибка при загрузке аватара'
setSaveMessage({ type: 'error', text: errorMessage })
setLocalAvatarUrl(null);
const errorMessage =
error instanceof Error ? error.message : "Ошибка при загрузке аватара";
setSaveMessage({ type: "error", text: errorMessage });
// Очищаем сообщение об ошибке через 5 секунд
setTimeout(() => {
setSaveMessage(null)
}, 5000)
setSaveMessage(null);
}, 5000);
} finally {
setIsUploadingAvatar(false)
setIsUploadingAvatar(false);
}
}
};
// Функции для валидации и масок
const validateEmail = (email: string) => {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
return emailRegex.test(email)
}
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
return emailRegex.test(email);
};
const formatPhoneInput = (value: string, isOptional: boolean = false) => {
// Убираем все нецифровые символы
const digitsOnly = value.replace(/\D/g, '')
const digitsOnly = value.replace(/\D/g, "");
// Если строка пустая
if (!digitsOnly) {
// Для необязательных полей возвращаем пустую строку
if (isOptional) return ''
if (isOptional) return "";
// Для обязательных полей возвращаем +7
return '+7'
return "+7";
}
// Если пользователь ввел первую цифру не 7, добавляем 7 перед ней
let cleaned = digitsOnly
if (!cleaned.startsWith('7')) {
cleaned = '7' + cleaned
}
// Ограничиваем до 11 цифр (7 + 10 цифр номера)
cleaned = cleaned.slice(0, 11)
// Форматируем в зависимости от длины
if (cleaned.length <= 1) return isOptional && cleaned === '7' ? '' : '+7'
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)}`
if (cleaned.length <= 11) return `+7 (${cleaned.slice(1, 4)}) ${cleaned.slice(4, 7)}-${cleaned.slice(7, 9)}-${cleaned.slice(9)}`
return `+7 (${cleaned.slice(1, 4)}) ${cleaned.slice(4, 7)}-${cleaned.slice(7, 9)}-${cleaned.slice(9, 11)}`
}
const handlePhoneInputChange = (field: string, value: string, inputRef?: React.RefObject<HTMLInputElement>, isOptional: boolean = false) => {
const currentInput = inputRef?.current
const currentCursorPosition = currentInput?.selectionStart || 0
const currentValue = formData[field as keyof typeof formData] as string || ''
// Если пользователь ввел первую цифру не 7, добавляем 7 перед ней
let cleaned = digitsOnly;
if (!cleaned.startsWith("7")) {
cleaned = "7" + cleaned;
}
// Ограничиваем до 11 цифр (7 + 10 цифр номера)
cleaned = cleaned.slice(0, 11);
// Форматируем в зависимости от длины
if (cleaned.length <= 1) return isOptional && cleaned === "7" ? "" : "+7";
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)}`;
if (cleaned.length <= 11)
return `+7 (${cleaned.slice(1, 4)}) ${cleaned.slice(
4,
7
)}-${cleaned.slice(7, 9)}-${cleaned.slice(9)}`;
return `+7 (${cleaned.slice(1, 4)}) ${cleaned.slice(4, 7)}-${cleaned.slice(
7,
9
)}-${cleaned.slice(9, 11)}`;
};
const handlePhoneInputChange = (
field: string,
value: string,
inputRef?: React.RefObject<HTMLInputElement>,
isOptional: boolean = false
) => {
const currentInput = inputRef?.current;
const currentCursorPosition = currentInput?.selectionStart || 0;
const currentValue =
(formData[field as keyof typeof formData] as string) || "";
// Для необязательных полей разрешаем пустое значение
if (isOptional && value.length < 2) {
const formatted = formatPhoneInput(value, true)
setFormData(prev => ({ ...prev, [field]: formatted }))
return
const formatted = formatPhoneInput(value, true);
setFormData((prev) => ({ ...prev, [field]: formatted }));
return;
}
// Для обязательных полей если пользователь пытается удалить +7, предотвращаем это
if (!isOptional && value.length < 2) {
value = '+7'
value = "+7";
}
const formatted = formatPhoneInput(value, isOptional)
setFormData(prev => ({ ...prev, [field]: formatted }))
const formatted = formatPhoneInput(value, isOptional);
setFormData((prev) => ({ ...prev, [field]: formatted }));
// Вычисляем новую позицию курсора
if (currentInput) {
setTimeout(() => {
let newCursorPosition = currentCursorPosition
let newCursorPosition = currentCursorPosition;
// Если длина увеличилась (добавили цифру), передвигаем курсор
if (formatted.length > currentValue.length) {
newCursorPosition = currentCursorPosition + (formatted.length - currentValue.length)
newCursorPosition =
currentCursorPosition + (formatted.length - currentValue.length);
}
// Если длина уменьшилась (удалили цифру), оставляем курсор на месте или сдвигаем немного
else if (formatted.length < currentValue.length) {
newCursorPosition = Math.min(currentCursorPosition, formatted.length)
newCursorPosition = Math.min(currentCursorPosition, formatted.length);
}
// Не позволяем курсору находиться перед +7
newCursorPosition = Math.max(newCursorPosition, 2)
// Не позволяем курсору находиться перед +7
newCursorPosition = Math.max(newCursorPosition, 2);
// Ограничиваем курсор длиной строки
newCursorPosition = Math.min(newCursorPosition, formatted.length)
currentInput.setSelectionRange(newCursorPosition, newCursorPosition)
}, 0)
newCursorPosition = Math.min(newCursorPosition, formatted.length);
currentInput.setSelectionRange(newCursorPosition, newCursorPosition);
}, 0);
}
}
};
const formatTelegram = (value: string) => {
// Убираем все символы кроме букв, цифр, _ и @
let cleaned = value.replace(/[^a-zA-Z0-9_@]/g, '')
let cleaned = value.replace(/[^a-zA-Z0-9_@]/g, "");
// Убираем лишние символы @
cleaned = cleaned.replace(/@+/g, '@')
cleaned = cleaned.replace(/@+/g, "@");
// Если есть символы после удаления @ и строка не начинается с @, добавляем @
if (cleaned && !cleaned.startsWith('@')) {
cleaned = '@' + cleaned
if (cleaned && !cleaned.startsWith("@")) {
cleaned = "@" + cleaned;
}
// Ограничиваем длину (максимум 32 символа для Telegram)
if (cleaned.length > 33) {
cleaned = cleaned.substring(0, 33)
cleaned = cleaned.substring(0, 33);
}
return cleaned
}
return cleaned;
};
const validateName = (name: string) => {
return /^[а-яёА-ЯЁa-zA-Z\s-]+$/.test(name) && name.trim().length >= 2
}
return /^[а-яёА-ЯЁa-zA-Z\s-]+$/.test(name) && name.trim().length >= 2;
};
const handleInputChange = (field: string, value: string) => {
let processedValue = value
let processedValue = value;
// Применяем маски и валидации
switch (field) {
case 'orgPhone':
case 'whatsapp':
processedValue = formatPhoneInput(value)
break
case 'telegram':
processedValue = formatTelegram(value)
break
case 'email':
case "orgPhone":
case "whatsapp":
processedValue = formatPhoneInput(value);
break;
case "telegram":
processedValue = formatTelegram(value);
break;
case "email":
// Для email не применяем маску, только валидацию при потере фокуса
break
case 'managerName':
break;
case "managerName":
// Разрешаем только буквы, пробелы и дефисы
processedValue = value.replace(/[^а-яёА-ЯЁa-zA-Z\s-]/g, '')
break
processedValue = value.replace(/[^а-яёА-ЯЁa-zA-Z\s-]/g, "");
break;
}
setFormData(prev => ({ ...prev, [field]: processedValue }))
}
setFormData((prev) => ({ ...prev, [field]: processedValue }));
};
// Функции для проверки ошибок
const getFieldError = (field: string, value: string) => {
if (!isEditing || !value.trim()) return null
if (!isEditing || !value.trim()) return null;
switch (field) {
case 'email':
return !validateEmail(value) ? 'Неверный формат email' : null
case 'managerName':
return !validateName(value) ? 'Только буквы, пробелы и дефисы' : null
case 'orgPhone':
case 'whatsapp':
const cleaned = value.replace(/\D/g, '')
return cleaned.length !== 11 ? 'Неверный формат телефона' : null
case 'telegram':
case "email":
return !validateEmail(value) ? "Неверный формат email" : null;
case "managerName":
return !validateName(value) ? "Только буквы, пробелы и дефисы" : null;
case "orgPhone":
case "whatsapp":
const cleaned = value.replace(/\D/g, "");
return cleaned.length !== 11 ? "Неверный формат телефона" : null;
case "telegram":
// Проверяем что после @ есть минимум 5 символов
const usernameLength = value.startsWith('@') ? value.length - 1 : value.length
return usernameLength < 5 ? 'Минимум 5 символов после @' : null
case 'inn':
const usernameLength = value.startsWith("@")
? value.length - 1
: value.length;
return usernameLength < 5 ? "Минимум 5 символов после @" : null;
case "inn":
// Игнорируем автоматически сгенерированные ИНН селлеров
if (value.startsWith('SELLER_')) {
return null
if (value.startsWith("SELLER_")) {
return null;
}
const innCleaned = value.replace(/\D/g, '')
const innCleaned = value.replace(/\D/g, "");
if (innCleaned.length !== 10 && innCleaned.length !== 12) {
return 'ИНН должен содержать 10 или 12 цифр'
return "ИНН должен содержать 10 или 12 цифр";
}
return null
case 'bankName':
return value.trim().length < 3 ? 'Минимум 3 символа' : null
case 'bik':
const bikCleaned = value.replace(/\D/g, '')
return bikCleaned.length !== 9 ? 'БИК должен содержать 9 цифр' : null
case 'accountNumber':
const accountCleaned = value.replace(/\D/g, '')
return accountCleaned.length !== 20 ? 'Расчетный счет должен содержать 20 цифр' : null
case 'corrAccount':
const corrCleaned = value.replace(/\D/g, '')
return corrCleaned.length !== 20 ? 'Корр. счет должен содержать 20 цифр' : null
return null;
case "bankName":
return value.trim().length < 3 ? "Минимум 3 символа" : null;
case "bik":
const bikCleaned = value.replace(/\D/g, "");
return bikCleaned.length !== 9 ? "БИК должен содержать 9 цифр" : null;
case "accountNumber":
const accountCleaned = value.replace(/\D/g, "");
return accountCleaned.length !== 20
? "Расчетный счет должен содержать 20 цифр"
: null;
case "corrAccount":
const corrCleaned = value.replace(/\D/g, "");
return corrCleaned.length !== 20
? "Корр. счет должен содержать 20 цифр"
: null;
default:
return null
return null;
}
}
};
// Проверка наличия ошибок валидации
const hasValidationErrors = () => {
const fields = ['orgPhone', 'managerName', 'telegram', 'whatsapp', 'email', 'inn', 'bankName', 'bik', 'accountNumber', 'corrAccount']
const fields = [
"orgPhone",
"managerName",
"telegram",
"whatsapp",
"email",
"inn",
"bankName",
"bik",
"accountNumber",
"corrAccount",
];
// Проверяем ошибки валидации только в заполненных полях
const hasErrors = fields.some(field => {
const value = formData[field as keyof typeof formData]
const hasErrors = fields.some((field) => {
const value = formData[field as keyof typeof formData];
// Проверяем ошибки только для заполненных полей
if (!value || !value.trim()) return false
const error = getFieldError(field, value)
return error !== null
})
if (!value || !value.trim()) return false;
const error = getFieldError(field, value);
return error !== null;
});
// Убираем проверку обязательных полей - пользователь может заполнять постепенно
return hasErrors
}
return hasErrors;
};
const handleSave = async () => {
// Сброс предыдущих сообщений
setSaveMessage(null)
setSaveMessage(null);
try {
// Проверяем, изменился ли ИНН и нужно ли обновить данные организации
const currentInn = formData.inn || user?.organization?.inn || ''
const originalInn = user?.organization?.inn || ''
const innCleaned = currentInn.replace(/\D/g, '')
const originalInnCleaned = originalInn.replace(/\D/g, '')
const currentInn = formData.inn || user?.organization?.inn || "";
const originalInn = user?.organization?.inn || "";
const innCleaned = currentInn.replace(/\D/g, "");
const originalInnCleaned = originalInn.replace(/\D/g, "");
// Если ИНН изменился и валиден, сначала обновляем данные организации
if (innCleaned !== originalInnCleaned && (innCleaned.length === 10 || innCleaned.length === 12)) {
setSaveMessage({ type: 'success', text: 'Обновляем данные организации...' })
if (
innCleaned !== originalInnCleaned &&
(innCleaned.length === 10 || innCleaned.length === 12)
) {
setSaveMessage({
type: "success",
text: "Обновляем данные организации...",
});
const orgResult = await updateOrganizationByInn({
variables: { inn: innCleaned }
})
variables: { inn: innCleaned },
});
if (!orgResult.data?.updateOrganizationByInn?.success) {
setSaveMessage({
type: 'error',
text: orgResult.data?.updateOrganizationByInn?.message || 'Ошибка при обновлении данных организации'
})
return
setSaveMessage({
type: "error",
text:
orgResult.data?.updateOrganizationByInn?.message ||
"Ошибка при обновлении данных организации",
});
return;
}
setSaveMessage({ type: 'success', text: 'Данные организации обновлены. Сохраняем профиль...' })
setSaveMessage({
type: "success",
text: "Данные организации обновлены. Сохраняем профиль...",
});
}
// Подготавливаем только заполненные поля для отправки
const inputData: {
orgPhone?: string
managerName?: string
telegram?: string
whatsapp?: string
email?: string
bankName?: string
bik?: string
accountNumber?: string
corrAccount?: string
} = {}
orgPhone?: string;
managerName?: string;
telegram?: string;
whatsapp?: string;
email?: string;
bankName?: string;
bik?: string;
accountNumber?: string;
corrAccount?: string;
} = {};
// orgName больше не редактируется - устанавливается только при регистрации
if (formData.orgPhone?.trim()) inputData.orgPhone = formData.orgPhone.trim()
if (formData.managerName?.trim()) inputData.managerName = formData.managerName.trim()
if (formData.telegram?.trim()) inputData.telegram = formData.telegram.trim()
if (formData.whatsapp?.trim()) inputData.whatsapp = formData.whatsapp.trim()
if (formData.email?.trim()) inputData.email = formData.email.trim()
if (formData.bankName?.trim()) inputData.bankName = formData.bankName.trim()
if (formData.bik?.trim()) inputData.bik = formData.bik.trim()
if (formData.accountNumber?.trim()) inputData.accountNumber = formData.accountNumber.trim()
if (formData.corrAccount?.trim()) inputData.corrAccount = formData.corrAccount.trim()
if (formData.orgPhone?.trim())
inputData.orgPhone = formData.orgPhone.trim();
if (formData.managerName?.trim())
inputData.managerName = formData.managerName.trim();
if (formData.telegram?.trim())
inputData.telegram = formData.telegram.trim();
if (formData.whatsapp?.trim())
inputData.whatsapp = formData.whatsapp.trim();
if (formData.email?.trim()) inputData.email = formData.email.trim();
if (formData.bankName?.trim())
inputData.bankName = formData.bankName.trim();
if (formData.bik?.trim()) inputData.bik = formData.bik.trim();
if (formData.accountNumber?.trim())
inputData.accountNumber = formData.accountNumber.trim();
if (formData.corrAccount?.trim())
inputData.corrAccount = formData.corrAccount.trim();
const result = await updateUserProfile({
variables: {
input: inputData
}
})
input: inputData,
},
});
if (result.data?.updateUserProfile?.success) {
setSaveMessage({ type: 'success', text: 'Профиль успешно сохранен! Обновляем страницу...' })
setSaveMessage({
type: "success",
text: "Профиль успешно сохранен! Обновляем страницу...",
});
// Простое обновление страницы после успешного сохранения
setTimeout(() => {
window.location.reload()
}, 1000)
window.location.reload();
}, 1000);
} else {
setSaveMessage({
type: 'error',
text: result.data?.updateUserProfile?.message || 'Ошибка при сохранении профиля'
})
setSaveMessage({
type: "error",
text:
result.data?.updateUserProfile?.message ||
"Ошибка при сохранении профиля",
});
}
} catch (error) {
console.error('Error saving profile:', error)
setSaveMessage({ type: 'error', text: 'Ошибка при сохранении профиля' })
console.error("Error saving profile:", error);
setSaveMessage({ type: "error", text: "Ошибка при сохранении профиля" });
}
}
};
const formatDate = (dateString?: string) => {
if (!dateString) return ''
if (!dateString) return "";
try {
let date: Date
let date: Date;
// Проверяем, является ли строка числом (Unix timestamp)
if (/^\d+$/.test(dateString)) {
// Если это Unix timestamp в миллисекундах
const timestamp = parseInt(dateString, 10)
date = new Date(timestamp)
const timestamp = parseInt(dateString, 10);
date = new Date(timestamp);
} else {
// Обычная строка даты
date = new Date(dateString)
date = new Date(dateString);
}
if (isNaN(date.getTime())) {
console.warn('Invalid date string:', dateString)
return 'Неверная дата'
console.warn("Invalid date string:", dateString);
return "Неверная дата";
}
return date.toLocaleDateString('ru-RU', {
year: 'numeric',
month: 'long',
day: 'numeric'
})
return date.toLocaleDateString("ru-RU", {
year: "numeric",
month: "long",
day: "numeric",
});
} catch (error) {
console.error('Error formatting date:', error, dateString)
return 'Ошибка даты'
console.error("Error formatting date:", error, dateString);
return "Ошибка даты";
}
}
};
return (
<div className="h-screen flex overflow-hidden">
<Sidebar />
<main className={`flex-1 ${getSidebarMargin()} px-6 py-4 overflow-hidden transition-all duration-300`}>
<main
className={`flex-1 ${getSidebarMargin()} px-6 py-4 overflow-hidden transition-all duration-300`}
>
<div className="h-full w-full flex flex-col">
{/* Сообщения о сохранении */}
{saveMessage && (
<Alert className={`mb-4 ${saveMessage.type === 'success' ? 'border-green-500 bg-green-500/10' : 'border-red-500 bg-red-500/10'}`}>
<AlertDescription className={saveMessage.type === 'success' ? 'text-green-400' : 'text-red-400'}>
<Alert
className={`mb-4 ${
saveMessage.type === "success"
? "border-green-500 bg-green-500/10"
: "border-red-500 bg-red-500/10"
}`}
>
<AlertDescription
className={
saveMessage.type === "success"
? "text-green-400"
: "text-red-400"
}
>
{saveMessage.text}
</AlertDescription>
</Alert>
@ -686,33 +809,57 @@ export function UserSettings() {
{/* Основной контент с вкладками - заполняет оставшееся пространство */}
<div className="flex-1 overflow-hidden">
<Tabs defaultValue="profile" className="h-full flex flex-col">
<TabsList className={`grid w-full glass-card mb-4 flex-shrink-0 ${
user?.organization?.type === 'SELLER' ? 'grid-cols-4' :
(user?.organization?.type === 'FULFILLMENT' || user?.organization?.type === 'LOGIST' || user?.organization?.type === 'WHOLESALE') ? 'grid-cols-4' :
'grid-cols-3'
}`}>
<TabsTrigger value="profile" className="text-white data-[state=active]:bg-white/20 cursor-pointer">
<TabsList
className={`grid w-full glass-card mb-4 flex-shrink-0 ${
user?.organization?.type === "SELLER"
? "grid-cols-4"
: user?.organization?.type === "FULFILLMENT" ||
user?.organization?.type === "LOGIST" ||
user?.organization?.type === "WHOLESALE"
? "grid-cols-4"
: "grid-cols-3"
}`}
>
<TabsTrigger
value="profile"
className="text-white data-[state=active]:bg-white/20 cursor-pointer"
>
<User className="h-4 w-4 mr-2" />
Профиль
</TabsTrigger>
<TabsTrigger value="organization" className="text-white data-[state=active]:bg-white/20 cursor-pointer">
<TabsTrigger
value="organization"
className="text-white data-[state=active]:bg-white/20 cursor-pointer"
>
<Building2 className="h-4 w-4 mr-2" />
Организация
</TabsTrigger>
{(user?.organization?.type === 'FULFILLMENT' || user?.organization?.type === 'LOGIST' || user?.organization?.type === 'WHOLESALE' || user?.organization?.type === 'SELLER') && (
<TabsTrigger value="financial" className="text-white data-[state=active]:bg-white/20 cursor-pointer">
{(user?.organization?.type === "FULFILLMENT" ||
user?.organization?.type === "LOGIST" ||
user?.organization?.type === "WHOLESALE" ||
user?.organization?.type === "SELLER") && (
<TabsTrigger
value="financial"
className="text-white data-[state=active]:bg-white/20 cursor-pointer"
>
<CreditCard className="h-4 w-4 mr-2" />
Финансы
</TabsTrigger>
)}
{user?.organization?.type === 'SELLER' && (
<TabsTrigger value="api" className="text-white data-[state=active]:bg-white/20 cursor-pointer">
{user?.organization?.type === "SELLER" && (
<TabsTrigger
value="api"
className="text-white data-[state=active]:bg-white/20 cursor-pointer"
>
<Key className="h-4 w-4 mr-2" />
API
</TabsTrigger>
)}
{user?.organization?.type !== 'SELLER' && (
<TabsTrigger value="tools" className="text-white data-[state=active]:bg-white/20 cursor-pointer">
{user?.organization?.type !== "SELLER" && (
<TabsTrigger
value="tools"
className="text-white data-[state=active]:bg-white/20 cursor-pointer"
>
<Settings className="h-4 w-4 mr-2" />
Инструменты
</TabsTrigger>
@ -727,15 +874,21 @@ export function UserSettings() {
<div className="flex items-center gap-4">
<User className="h-6 w-6 text-purple-400" />
<div>
<h2 className="text-lg font-semibold text-white">Профиль пользователя</h2>
<p className="text-white/70 text-sm">Личная информация и контактные данные</p>
<h2 className="text-lg font-semibold text-white">
Профиль пользователя
</h2>
<p className="text-white/70 text-sm">
Личная информация и контактные данные
</p>
</div>
</div>
<div className="flex items-center gap-2">
{/* Компактный индикатор прогресса */}
<div className="flex items-center gap-2 mr-2">
<div className="w-8 h-8 rounded-full bg-white/10 flex items-center justify-center">
<span className="text-xs text-white font-medium">{profileStatus.percentage}%</span>
<span className="text-xs text-white font-medium">
{profileStatus.percentage}%
</span>
</div>
<div className="hidden sm:block text-xs text-white/70">
{isIncomplete ? (
@ -745,31 +898,33 @@ export function UserSettings() {
)}
</div>
</div>
{isEditing ? (
<>
<Button
variant="outline"
<Button
variant="outline"
size="sm"
onClick={() => setIsEditing(false)}
className="glass-secondary text-white hover:text-white cursor-pointer"
>
Отмена
</Button>
<Button
<Button
size="sm"
onClick={handleSave}
disabled={hasValidationErrors() || isSaving}
className={`glass-button text-white cursor-pointer ${
hasValidationErrors() || isSaving ? 'opacity-50 cursor-not-allowed' : ''
hasValidationErrors() || isSaving
? "opacity-50 cursor-not-allowed"
: ""
}`}
>
<Save className="h-4 w-4 mr-2" />
{isSaving ? 'Сохранение...' : 'Сохранить'}
{isSaving ? "Сохранение..." : "Сохранить"}
</Button>
</>
) : (
<Button
<Button
size="sm"
onClick={() => setIsEditing(true)}
className="glass-button text-white cursor-pointer"
@ -783,10 +938,10 @@ export function UserSettings() {
<div className="flex items-center gap-4 mb-6">
<div className="relative">
<Avatar className="h-16 w-16">
{(localAvatarUrl || user?.avatar) ? (
<Image
src={localAvatarUrl || user.avatar}
alt="Аватар"
{localAvatarUrl || user?.avatar ? (
<Image
src={localAvatarUrl || user.avatar}
alt="Аватар"
width={64}
height={64}
className="w-full h-full object-cover rounded-full"
@ -798,7 +953,10 @@ export function UserSettings() {
)}
</Avatar>
<div className="absolute -bottom-1 -right-1">
<label htmlFor="avatar-upload" className="cursor-pointer">
<label
htmlFor="avatar-upload"
className="cursor-pointer"
>
<div className="w-6 h-6 bg-purple-600 rounded-full flex items-center justify-center hover:bg-purple-700 transition-colors">
{isUploadingAvatar ? (
<RefreshCw className="h-3 w-3 text-white animate-spin" />
@ -819,13 +977,18 @@ export function UserSettings() {
</div>
<div className="flex-1">
<p className="text-white font-medium text-lg">
{user?.organization?.name || user?.organization?.fullName || 'Пользователь'}
{user?.organization?.name ||
user?.organization?.fullName ||
"Пользователь"}
</p>
<Badge variant="outline" className="bg-white/10 text-white border-white/20 mt-1">
<Badge
variant="outline"
className="bg-white/10 text-white border-white/20 mt-1"
>
{getCabinetTypeName()}
</Badge>
<p className="text-white/60 text-sm mt-2">
Авторизован по номеру: {formatPhone(user?.phone || '')}
Авторизован по номеру: {formatPhone(user?.phone || "")}
</p>
{user?.createdAt && (
<p className="text-white/50 text-xs mt-1 flex items-center gap-1">
@ -840,47 +1003,65 @@ export function UserSettings() {
<div className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div>
<Label className="text-white/80 text-sm mb-2 block">Номер телефона организации</Label>
<Input
<Label className="text-white/80 text-sm mb-2 block">
Номер телефона организации
</Label>
<Input
ref={phoneInputRef}
value={formData.orgPhone || ''}
onChange={(e) => handlePhoneInputChange('orgPhone', e.target.value, phoneInputRef)}
value={formData.orgPhone || ""}
onChange={(e) =>
handlePhoneInputChange(
"orgPhone",
e.target.value,
phoneInputRef
)
}
onKeyDown={(e) => {
// Предотвращаем удаление +7
if ((e.key === 'Backspace' || e.key === 'Delete') &&
phoneInputRef.current?.selectionStart <= 2) {
e.preventDefault()
if (
(e.key === "Backspace" || e.key === "Delete") &&
phoneInputRef.current?.selectionStart <= 2
) {
e.preventDefault();
}
}}
placeholder="+7 (999) 999-99-99"
readOnly={!isEditing}
className={`glass-input text-white placeholder:text-white/40 h-10 read-only:opacity-70 ${
getFieldError('orgPhone', formData.orgPhone) ? 'border-red-400' : ''
getFieldError("orgPhone", formData.orgPhone)
? "border-red-400"
: ""
}`}
/>
{getFieldError('orgPhone', formData.orgPhone) && (
{getFieldError("orgPhone", formData.orgPhone) && (
<p className="text-red-400 text-xs mt-1 flex items-center gap-1">
<AlertTriangle className="h-3 w-3" />
{getFieldError('orgPhone', formData.orgPhone)}
{getFieldError("orgPhone", formData.orgPhone)}
</p>
)}
</div>
<div>
<Label className="text-white/80 text-sm mb-2 block">Имя управляющего</Label>
<Input
value={formData.managerName || ''}
onChange={(e) => handleInputChange('managerName', e.target.value)}
<Label className="text-white/80 text-sm mb-2 block">
Имя управляющего
</Label>
<Input
value={formData.managerName || ""}
onChange={(e) =>
handleInputChange("managerName", e.target.value)
}
placeholder="Иван Иванов"
readOnly={!isEditing}
className={`glass-input text-white placeholder:text-white/40 h-10 read-only:opacity-70 ${
getFieldError('managerName', formData.managerName) ? 'border-red-400' : ''
getFieldError("managerName", formData.managerName)
? "border-red-400"
: ""
}`}
/>
{getFieldError('managerName', formData.managerName) && (
{getFieldError("managerName", formData.managerName) && (
<p className="text-red-400 text-xs mt-1 flex items-center gap-1">
<AlertTriangle className="h-3 w-3" />
{getFieldError('managerName', formData.managerName)}
{getFieldError("managerName", formData.managerName)}
</p>
)}
</div>
@ -892,19 +1073,23 @@ export function UserSettings() {
<MessageCircle className="h-4 w-4 text-blue-400" />
Telegram
</Label>
<Input
value={formData.telegram || ''}
onChange={(e) => handleInputChange('telegram', e.target.value)}
<Input
value={formData.telegram || ""}
onChange={(e) =>
handleInputChange("telegram", e.target.value)
}
placeholder="@username"
readOnly={!isEditing}
className={`glass-input text-white placeholder:text-white/40 h-10 read-only:opacity-70 ${
getFieldError('telegram', formData.telegram) ? 'border-red-400' : ''
getFieldError("telegram", formData.telegram)
? "border-red-400"
: ""
}`}
/>
{getFieldError('telegram', formData.telegram) && (
{getFieldError("telegram", formData.telegram) && (
<p className="text-red-400 text-xs mt-1 flex items-center gap-1">
<AlertTriangle className="h-3 w-3" />
{getFieldError('telegram', formData.telegram)}
{getFieldError("telegram", formData.telegram)}
</p>
)}
</div>
@ -914,10 +1099,17 @@ export function UserSettings() {
<Phone className="h-4 w-4 text-green-400" />
WhatsApp
</Label>
<Input
<Input
ref={whatsappInputRef}
value={formData.whatsapp || ''}
onChange={(e) => handlePhoneInputChange('whatsapp', e.target.value, whatsappInputRef, true)}
value={formData.whatsapp || ""}
onChange={(e) =>
handlePhoneInputChange(
"whatsapp",
e.target.value,
whatsappInputRef,
true
)
}
onKeyDown={(e) => {
// Для WhatsApp разрешаем полное удаление (поле необязательное)
// Никаких ограничений на удаление
@ -925,13 +1117,15 @@ export function UserSettings() {
placeholder="Необязательно"
readOnly={!isEditing}
className={`glass-input text-white placeholder:text-white/40 h-10 read-only:opacity-70 ${
getFieldError('whatsapp', formData.whatsapp) ? 'border-red-400' : ''
getFieldError("whatsapp", formData.whatsapp)
? "border-red-400"
: ""
}`}
/>
{getFieldError('whatsapp', formData.whatsapp) && (
{getFieldError("whatsapp", formData.whatsapp) && (
<p className="text-red-400 text-xs mt-1 flex items-center gap-1">
<AlertTriangle className="h-3 w-3" />
{getFieldError('whatsapp', formData.whatsapp)}
{getFieldError("whatsapp", formData.whatsapp)}
</p>
)}
</div>
@ -941,20 +1135,24 @@ export function UserSettings() {
<Mail className="h-4 w-4 text-red-400" />
Email
</Label>
<Input
<Input
type="email"
value={formData.email || ''}
onChange={(e) => handleInputChange('email', e.target.value)}
value={formData.email || ""}
onChange={(e) =>
handleInputChange("email", e.target.value)
}
placeholder="example@company.com"
readOnly={!isEditing}
className={`glass-input text-white placeholder:text-white/40 h-10 read-only:opacity-70 ${
getFieldError('email', formData.email) ? 'border-red-400' : ''
getFieldError("email", formData.email)
? "border-red-400"
: ""
}`}
/>
{getFieldError('email', formData.email) && (
{getFieldError("email", formData.email) && (
<p className="text-red-400 text-xs mt-1 flex items-center gap-1">
<AlertTriangle className="h-3 w-3" />
{getFieldError('email', formData.email)}
{getFieldError("email", formData.email)}
</p>
)}
</div>
@ -964,49 +1162,60 @@ export function UserSettings() {
</TabsContent>
{/* Организация и юридические данные */}
<TabsContent value="organization" className="flex-1 overflow-hidden">
<TabsContent
value="organization"
className="flex-1 overflow-hidden"
>
<Card className="glass-card p-6 h-full overflow-hidden">
{/* Заголовок вкладки с кнопками */}
<div className="flex items-center justify-between mb-6 pb-4 border-b border-white/10">
<div className="flex items-center gap-4">
<Building2 className="h-6 w-6 text-blue-400" />
<div>
<h2 className="text-lg font-semibold text-white">Данные организации</h2>
<p className="text-white/70 text-sm">Юридическая информация и реквизиты</p>
<h2 className="text-lg font-semibold text-white">
Данные организации
</h2>
<p className="text-white/70 text-sm">
Юридическая информация и реквизиты
</p>
</div>
</div>
<div className="flex items-center gap-2">
{(formData.inn || user?.organization?.inn) && (
<div className="flex items-center gap-2 mr-2">
<CheckCircle className="h-5 w-5 text-green-400" />
<span className="text-green-400 text-sm">Проверено</span>
<span className="text-green-400 text-sm">
Проверено
</span>
</div>
)}
{isEditing ? (
<>
<Button
variant="outline"
<Button
variant="outline"
size="sm"
onClick={() => setIsEditing(false)}
className="glass-secondary text-white hover:text-white cursor-pointer"
>
Отмена
</Button>
<Button
<Button
size="sm"
onClick={handleSave}
disabled={hasValidationErrors() || isSaving}
className={`glass-button text-white cursor-pointer ${
hasValidationErrors() || isSaving ? 'opacity-50 cursor-not-allowed' : ''
hasValidationErrors() || isSaving
? "opacity-50 cursor-not-allowed"
: ""
}`}
>
<Save className="h-4 w-4 mr-2" />
{isSaving ? 'Сохранение...' : 'Сохранить'}
{isSaving ? "Сохранение..." : "Сохранить"}
</Button>
</>
) : (
<Button
<Button
size="sm"
onClick={() => setIsEditing(true)}
className="glass-button text-white cursor-pointer"
@ -1017,12 +1226,13 @@ export function UserSettings() {
)}
</div>
</div>
{/* Общая подпись про реестр */}
<div className="mb-6 p-3 bg-blue-500/10 rounded-lg border border-blue-500/20">
<p className="text-blue-300 text-sm flex items-center gap-2">
<RefreshCw className="h-4 w-4" />
При сохранении с измененным ИНН мы автоматически обновляем все остальные данные из федерального реестра
При сохранении с измененным ИНН мы автоматически обновляем
все остальные данные из федерального реестра
</p>
</div>
@ -1031,30 +1241,48 @@ export function UserSettings() {
<div className="grid grid-cols-2 gap-4">
<div>
<Label className="text-white/80 text-sm mb-2 block">
{user?.organization?.type === 'SELLER' ? 'Название магазина' : 'Название организации'}
{user?.organization?.type === "SELLER"
? "Название магазина"
: "Название организации"}
</Label>
<Input
value={formData.orgName || user?.organization?.name || ''}
onChange={(e) => handleInputChange('orgName', e.target.value)}
placeholder={user?.organization?.type === 'SELLER' ? 'Название магазина' : 'Название организации'}
<Input
value={
formData.orgName || user?.organization?.name || ""
}
onChange={(e) =>
handleInputChange("orgName", e.target.value)
}
placeholder={
user?.organization?.type === "SELLER"
? "Название магазина"
: "Название организации"
}
readOnly={true}
className="glass-input text-white placeholder:text-white/40 h-10 read-only:opacity-70"
/>
{user?.organization?.type === 'SELLER' ? (
{user?.organization?.type === "SELLER" ? (
<p className="text-white/50 text-xs mt-1">
Название устанавливается при регистрации кабинета и не может быть изменено.
Название устанавливается при регистрации кабинета и
не может быть изменено.
</p>
) : (
<p className="text-white/50 text-xs mt-1">
Автоматически заполняется из федерального реестра при указании ИНН.
Автоматически заполняется из федерального реестра
при указании ИНН.
</p>
)}
</div>
<div>
<Label className="text-white/80 text-sm mb-2 block">Полное название</Label>
<Input
value={formData.fullName || user?.organization?.fullName || ''}
<Label className="text-white/80 text-sm mb-2 block">
Полное название
</Label>
<Input
value={
formData.fullName ||
user?.organization?.fullName ||
""
}
readOnly
className="glass-input text-white h-10 read-only:opacity-70"
/>
@ -1068,19 +1296,30 @@ export function UserSettings() {
<MapPin className="h-4 w-4" />
Адрес
</Label>
<Input
value={formData.address || user?.organization?.address || ''}
onChange={(e) => handleInputChange('address', e.target.value)}
<Input
value={
formData.address ||
user?.organization?.address ||
""
}
onChange={(e) =>
handleInputChange("address", e.target.value)
}
placeholder="г. Москва, ул. Примерная, д. 1"
readOnly={!isEditing || !!(formData.address || user?.organization?.address)}
readOnly={
!isEditing ||
!!(formData.address || user?.organization?.address)
}
className="glass-input text-white placeholder:text-white/40 h-10 read-only:opacity-70"
/>
</div>
<div>
<Label className="text-white/80 text-sm mb-2 block">Полный юридический адрес</Label>
<Input
value={user?.organization?.addressFull || ''}
<Label className="text-white/80 text-sm mb-2 block">
Полный юридический адрес
</Label>
<Input
value={user?.organization?.addressFull || ""}
readOnly
className="glass-input text-white h-10 read-only:opacity-70"
/>
@ -1096,40 +1335,48 @@ export function UserSettings() {
<RefreshCw className="h-3 w-3 animate-spin text-blue-400" />
)}
</Label>
<Input
value={formData.inn || user?.organization?.inn || ''}
<Input
value={formData.inn || user?.organization?.inn || ""}
onChange={(e) => {
handleInputChange('inn', e.target.value)
handleInputChange("inn", e.target.value);
}}
placeholder="Введите ИНН организации"
readOnly={!isEditing}
disabled={isUpdatingOrganization}
className={`glass-input text-white placeholder:text-white/40 h-10 ${
!isEditing ? 'read-only:opacity-70' : ''
} ${getFieldError('inn', formData.inn) ? 'border-red-400' : ''} ${
isUpdatingOrganization ? 'opacity-50' : ''
}`}
!isEditing ? "read-only:opacity-70" : ""
} ${
getFieldError("inn", formData.inn)
? "border-red-400"
: ""
} ${isUpdatingOrganization ? "opacity-50" : ""}`}
/>
{getFieldError('inn', formData.inn) && (
{getFieldError("inn", formData.inn) && (
<p className="text-red-400 text-xs mt-1">
{getFieldError('inn', formData.inn)}
{getFieldError("inn", formData.inn)}
</p>
)}
</div>
<div>
<Label className="text-white/80 text-sm mb-2 block">ОГРН</Label>
<Input
value={formData.ogrn || user?.organization?.ogrn || ''}
<Label className="text-white/80 text-sm mb-2 block">
ОГРН
</Label>
<Input
value={
formData.ogrn || user?.organization?.ogrn || ""
}
readOnly
className="glass-input text-white h-10 read-only:opacity-70"
/>
</div>
<div>
<Label className="text-white/80 text-sm mb-2 block">КПП</Label>
<Input
value={user?.organization?.kpp || ''}
<Label className="text-white/80 text-sm mb-2 block">
КПП
</Label>
<Input
value={user?.organization?.kpp || ""}
readOnly
className="glass-input text-white h-10 read-only:opacity-70"
/>
@ -1139,24 +1386,35 @@ export function UserSettings() {
{/* Руководитель и статус */}
<div className="grid grid-cols-2 gap-4">
<div>
<Label className="text-white/80 text-sm mb-2 block">Руководитель организации</Label>
<Input
value={user?.organization?.managementName || 'Данные не указаны в реестре'}
<Label className="text-white/80 text-sm mb-2 block">
Руководитель организации
</Label>
<Input
value={
user?.organization?.managementName ||
"Данные не указаны в реестре"
}
readOnly
className="glass-input text-white h-10 read-only:opacity-70"
placeholder="Данные отсутствуют в федеральном реестре"
/>
<p className="text-white/50 text-xs mt-1">
{user?.organization?.managementName
? 'Данные из федерального реестра'
: 'Автоматически заполняется из федерального реестра при указании ИНН'}
{user?.organization?.managementName
? "Данные из федерального реестра"
: "Автоматически заполняется из федерального реестра при указании ИНН"}
</p>
</div>
<div>
<Label className="text-white/80 text-sm mb-2 block">Статус организации</Label>
<Input
value={user?.organization?.status === 'ACTIVE' ? 'Действующая' : user?.organization?.status || 'Статус не указан'}
<Label className="text-white/80 text-sm mb-2 block">
Статус организации
</Label>
<Input
value={
user?.organization?.status === "ACTIVE"
? "Действующая"
: user?.organization?.status || "Статус не указан"
}
readOnly
className="glass-input text-white h-10 read-only:opacity-70"
/>
@ -1171,8 +1429,10 @@ export function UserSettings() {
<Calendar className="h-4 w-4" />
Дата регистрации
</Label>
<Input
value={formatDate(user.organization.registrationDate)}
<Input
value={formatDate(
user.organization.registrationDate
)}
readOnly
className="glass-input text-white h-10 read-only:opacity-70"
/>
@ -1183,53 +1443,68 @@ export function UserSettings() {
</Card>
</TabsContent>
{/* Финансовые данные */}
{(user?.organization?.type === 'FULFILLMENT' || user?.organization?.type === 'LOGIST' || user?.organization?.type === 'WHOLESALE' || user?.organization?.type === 'SELLER') && (
<TabsContent value="financial" className="flex-1 overflow-hidden">
{(user?.organization?.type === "FULFILLMENT" ||
user?.organization?.type === "LOGIST" ||
user?.organization?.type === "WHOLESALE" ||
user?.organization?.type === "SELLER") && (
<TabsContent
value="financial"
className="flex-1 overflow-hidden"
>
<Card className="glass-card p-6 h-full overflow-auto">
{/* Заголовок вкладки с кнопками */}
<div className="flex items-center justify-between mb-6 pb-4 border-b border-white/10">
<div className="flex items-center gap-4">
<CreditCard className="h-6 w-6 text-red-400" />
<div>
<h2 className="text-lg font-semibold text-white">Финансовые данные</h2>
<p className="text-white/70 text-sm">Банковские реквизиты для расчетов</p>
<h2 className="text-lg font-semibold text-white">
Финансовые данные
</h2>
<p className="text-white/70 text-sm">
Банковские реквизиты для расчетов
</p>
</div>
</div>
<div className="flex items-center gap-2">
{formData.bankName && formData.bik && formData.accountNumber && formData.corrAccount && (
<div className="flex items-center gap-2 mr-2">
<CheckCircle className="h-5 w-5 text-green-400" />
<span className="text-green-400 text-sm">Заполнено</span>
</div>
)}
{formData.bankName &&
formData.bik &&
formData.accountNumber &&
formData.corrAccount && (
<div className="flex items-center gap-2 mr-2">
<CheckCircle className="h-5 w-5 text-green-400" />
<span className="text-green-400 text-sm">
Заполнено
</span>
</div>
)}
{isEditing ? (
<>
<Button
variant="outline"
<Button
variant="outline"
size="sm"
onClick={() => setIsEditing(false)}
className="glass-secondary text-white hover:text-white cursor-pointer"
>
Отмена
</Button>
<Button
<Button
size="sm"
onClick={handleSave}
disabled={hasValidationErrors() || isSaving}
className={`glass-button text-white cursor-pointer ${
hasValidationErrors() || isSaving ? 'opacity-50 cursor-not-allowed' : ''
hasValidationErrors() || isSaving
? "opacity-50 cursor-not-allowed"
: ""
}`}
>
<Save className="h-4 w-4 mr-2" />
{isSaving ? 'Сохранение...' : 'Сохранить'}
{isSaving ? "Сохранение..." : "Сохранить"}
</Button>
</>
) : (
<Button
<Button
size="sm"
onClick={() => setIsEditing(true)}
className="glass-button text-white cursor-pointer"
@ -1243,10 +1518,14 @@ export function UserSettings() {
<div className="space-y-4">
<div>
<Label className="text-white/80 text-sm mb-2 block">Название банка</Label>
<Input
value={formData.bankName || ''}
onChange={(e) => handleInputChange('bankName', e.target.value)}
<Label className="text-white/80 text-sm mb-2 block">
Название банка
</Label>
<Input
value={formData.bankName || ""}
onChange={(e) =>
handleInputChange("bankName", e.target.value)
}
placeholder="ПАО Сбербанк"
readOnly={!isEditing}
className="glass-input text-white placeholder:text-white/40 h-10 read-only:opacity-70"
@ -1255,10 +1534,14 @@ export function UserSettings() {
<div className="grid grid-cols-2 gap-4">
<div>
<Label className="text-white/80 text-sm mb-2 block">БИК</Label>
<Input
value={formData.bik || ''}
onChange={(e) => handleInputChange('bik', e.target.value)}
<Label className="text-white/80 text-sm mb-2 block">
БИК
</Label>
<Input
value={formData.bik || ""}
onChange={(e) =>
handleInputChange("bik", e.target.value)
}
placeholder="044525225"
readOnly={!isEditing}
className="glass-input text-white placeholder:text-white/40 h-10 read-only:opacity-70"
@ -1266,10 +1549,14 @@ export function UserSettings() {
</div>
<div>
<Label className="text-white/80 text-sm mb-2 block">Корр. счет</Label>
<Input
value={formData.corrAccount || ''}
onChange={(e) => handleInputChange('corrAccount', e.target.value)}
<Label className="text-white/80 text-sm mb-2 block">
Корр. счет
</Label>
<Input
value={formData.corrAccount || ""}
onChange={(e) =>
handleInputChange("corrAccount", e.target.value)
}
placeholder="30101810400000000225"
readOnly={!isEditing}
className="glass-input text-white placeholder:text-white/40 h-10 read-only:opacity-70"
@ -1278,10 +1565,14 @@ export function UserSettings() {
</div>
<div>
<Label className="text-white/80 text-sm mb-2 block">Расчетный счет</Label>
<Input
value={formData.accountNumber || ''}
onChange={(e) => handleInputChange('accountNumber', e.target.value)}
<Label className="text-white/80 text-sm mb-2 block">
Расчетный счет
</Label>
<Input
value={formData.accountNumber || ""}
onChange={(e) =>
handleInputChange("accountNumber", e.target.value)
}
placeholder="40702810123456789012"
readOnly={!isEditing}
className="glass-input text-white placeholder:text-white/40 h-10 read-only:opacity-70"
@ -1293,7 +1584,7 @@ export function UserSettings() {
)}
{/* API ключи для селлера */}
{user?.organization?.type === 'SELLER' && (
{user?.organization?.type === "SELLER" && (
<TabsContent value="api" className="flex-1 overflow-hidden">
<Card className="glass-card p-6 h-full overflow-auto">
{/* Заголовок вкладки с кнопками */}
@ -1301,42 +1592,50 @@ export function UserSettings() {
<div className="flex items-center gap-4">
<Key className="h-6 w-6 text-green-400" />
<div>
<h2 className="text-lg font-semibold text-white">API ключи маркетплейсов</h2>
<p className="text-white/70 text-sm">Интеграция с торговыми площадками</p>
<h2 className="text-lg font-semibold text-white">
API ключи маркетплейсов
</h2>
<p className="text-white/70 text-sm">
Интеграция с торговыми площадками
</p>
</div>
</div>
<div className="flex items-center gap-2">
{user?.organization?.apiKeys?.length > 0 && (
<div className="flex items-center gap-2 mr-2">
<CheckCircle className="h-5 w-5 text-green-400" />
<span className="text-green-400 text-sm">Настроено</span>
<span className="text-green-400 text-sm">
Настроено
</span>
</div>
)}
{isEditing ? (
<>
<Button
variant="outline"
<Button
variant="outline"
size="sm"
onClick={() => setIsEditing(false)}
className="glass-secondary text-white hover:text-white cursor-pointer"
>
Отмена
</Button>
<Button
<Button
size="sm"
onClick={handleSave}
disabled={hasValidationErrors() || isSaving}
className={`glass-button text-white cursor-pointer ${
hasValidationErrors() || isSaving ? 'opacity-50 cursor-not-allowed' : ''
hasValidationErrors() || isSaving
? "opacity-50 cursor-not-allowed"
: ""
}`}
>
<Save className="h-4 w-4 mr-2" />
{isSaving ? 'Сохранение...' : 'Сохранить'}
{isSaving ? "Сохранение..." : "Сохранить"}
</Button>
</>
) : (
<Button
<Button
size="sm"
onClick={() => setIsEditing(true)}
className="glass-button text-white cursor-pointer"
@ -1350,35 +1649,72 @@ export function UserSettings() {
<div className="space-y-4">
<div>
<Label className="text-white/80 text-sm mb-2 block">Wildberries API</Label>
<Input
value={isEditing ? (formData.wildberriesApiKey || '') : (user?.organization?.apiKeys?.find(key => key.marketplace === 'WILDBERRIES') ? '••••••••••••••••••••' : '')}
onChange={(e) => handleInputChange('wildberriesApiKey', e.target.value)}
<Label className="text-white/80 text-sm mb-2 block">
Wildberries API
</Label>
<Input
value={
isEditing
? formData.wildberriesApiKey || ""
: user?.organization?.apiKeys?.find(
(key) => key.marketplace === "WILDBERRIES"
)
? "••••••••••••••••••••"
: ""
}
onChange={(e) =>
handleInputChange(
"wildberriesApiKey",
e.target.value
)
}
placeholder="Введите API ключ Wildberries"
readOnly={!isEditing}
readOnly={!isEditing}
className="glass-input text-white placeholder:text-white/40 h-10 read-only:opacity-70"
/>
{(user?.organization?.apiKeys?.find(key => key.marketplace === 'WILDBERRIES') || (formData.wildberriesApiKey && isEditing)) && (
{(user?.organization?.apiKeys?.find(
(key) => key.marketplace === "WILDBERRIES"
) ||
(formData.wildberriesApiKey && isEditing)) && (
<p className="text-green-400 text-sm mt-2 flex items-center gap-2">
<CheckCircle className="h-4 w-4" />
{!isEditing ? 'API ключ настроен' : 'Будет сохранен'}
{!isEditing
? "API ключ настроен"
: "Будет сохранен"}
</p>
)}
</div>
<div>
<Label className="text-white/80 text-sm mb-2 block">Ozon API</Label>
<Input
value={isEditing ? (formData.ozonApiKey || '') : (user?.organization?.apiKeys?.find(key => key.marketplace === 'OZON') ? '••••••••••••••••••••' : '')}
onChange={(e) => handleInputChange('ozonApiKey', e.target.value)}
<Label className="text-white/80 text-sm mb-2 block">
Ozon API
</Label>
<Input
value={
isEditing
? formData.ozonApiKey || ""
: user?.organization?.apiKeys?.find(
(key) => key.marketplace === "OZON"
)
? "••••••••••••••••••••"
: ""
}
onChange={(e) =>
handleInputChange("ozonApiKey", e.target.value)
}
placeholder="Введите API ключ Ozon"
readOnly={!isEditing}
readOnly={!isEditing}
className="glass-input text-white placeholder:text-white/40 h-10 read-only:opacity-70"
/>
{(user?.organization?.apiKeys?.find(key => key.marketplace === 'OZON') || (formData.ozonApiKey && isEditing)) && (
{(user?.organization?.apiKeys?.find(
(key) => key.marketplace === "OZON"
) ||
(formData.ozonApiKey && isEditing)) && (
<p className="text-green-400 text-sm mt-2 flex items-center gap-2">
<CheckCircle className="h-4 w-4" />
{!isEditing ? 'API ключ настроен' : 'Будет сохранен'}
{!isEditing
? "API ключ настроен"
: "Будет сохранен"}
</p>
)}
</div>
@ -1395,49 +1731,65 @@ export function UserSettings() {
<div className="flex items-center gap-4">
<Settings className="h-6 w-6 text-green-400" />
<div>
<h2 className="text-lg font-semibold text-white">Инструменты</h2>
<p className="text-white/70 text-sm">Дополнительные возможности для бизнеса</p>
<h2 className="text-lg font-semibold text-white">
Инструменты
</h2>
<p className="text-white/70 text-sm">
Дополнительные возможности для бизнеса
</p>
</div>
</div>
</div>
{(user?.organization?.type === 'FULFILLMENT' || user?.organization?.type === 'LOGIST' || user?.organization?.type === 'WHOLESALE') && (
{(user?.organization?.type === "FULFILLMENT" ||
user?.organization?.type === "LOGIST" ||
user?.organization?.type === "WHOLESALE") && (
<div className="space-y-6">
<div>
<h4 className="text-white font-medium mb-2">Партнерская программа</h4>
<h4 className="text-white font-medium mb-2">
Партнерская программа
</h4>
<p className="text-white/70 text-sm mb-4">
Приглашайте новых контрагентов по уникальной ссылке. При регистрации они автоматически становятся вашими партнерами.
Приглашайте новых контрагентов по уникальной ссылке.
При регистрации они автоматически становятся вашими
партнерами.
</p>
<div className="space-y-3">
<div className="flex gap-2">
<Button
size="sm"
variant="outline"
<Button
size="sm"
variant="outline"
className="glass-secondary text-white hover:text-white cursor-pointer text-xs px-3 py-2"
onClick={generatePartnerLink}
disabled={isGenerating}
>
<RefreshCw className={`h-3 w-3 mr-1 ${isGenerating ? 'animate-spin' : ''}`} />
{isGenerating ? 'Генерируем...' : 'Сгенерировать ссылку'}
<RefreshCw
className={`h-3 w-3 mr-1 ${
isGenerating ? "animate-spin" : ""
}`}
/>
{isGenerating
? "Генерируем..."
: "Сгенерировать ссылку"}
</Button>
</div>
{partnerLink && (
<div className="space-y-2">
<div className="flex gap-2">
<Button
size="sm"
variant="outline"
<Button
size="sm"
variant="outline"
className="glass-secondary text-white hover:text-white cursor-pointer text-xs px-3 py-2"
onClick={handleOpenLink}
>
<ExternalLink className="h-3 w-3 mr-1" />
Открыть ссылку
</Button>
<Button
size="sm"
variant="outline"
<Button
size="sm"
variant="outline"
className="glass-secondary text-white hover:text-white cursor-pointer px-2"
onClick={handleCopyLink}
>
@ -1445,7 +1797,8 @@ export function UserSettings() {
</Button>
</div>
<p className="text-white/60 text-xs">
Ваша партнерская ссылка сгенерирована и готова к использованию
Ваша партнерская ссылка сгенерирована и готова к
использованию
</p>
</div>
)}
@ -1460,5 +1813,5 @@ export function UserSettings() {
</div>
</main>
</div>
)
}
);
}

View File

@ -248,7 +248,7 @@ export function CreateFulfillmentConsumablesSupplyPage() {
setProductSearchQuery("");
setSearchQuery("");
// Перенаправляем на страницу поставок фулфилмента с активной вкладкой "Наши расходники"
// Перенаправляем на страницу поставок фулфилмента с активной вкладкой "Расходники фулфилмента"
router.push("/fulfillment-supplies?tab=detailed-supplies");
} else {
toast.error(

View File

@ -96,7 +96,7 @@ export function FulfillmentConsumablesOrdersTab() {
},
refetchQueries: [
{ query: GET_SUPPLY_ORDERS }, // Обновляем заказы поставок
{ query: GET_MY_SUPPLIES }, // Обновляем склад фулфилмента (расходники фф)
{ query: GET_MY_SUPPLIES }, // Обновляем склад фулфилмента (расходники фулфилмента)
{ query: GET_WAREHOUSE_PRODUCTS }, // Обновляем товары склада
],
onError: (error) => {

View File

@ -126,7 +126,7 @@ const mockFulfillmentGoodsSupplies: FulfillmentSupply[] = [
value: "12",
unit: "мес",
},
{ id: "ffparam4", name: "Расходники ФФ", value: "Усиленная" },
{ id: "ffparam4", name: "Расходники фулфилмента", value: "Усиленная" },
],
},
],

View File

@ -126,7 +126,7 @@ export function FulfillmentDetailedSuppliesTab() {
const [updateSupplyOrderStatus] = useMutation(UPDATE_SUPPLY_ORDER_STATUS, {
refetchQueries: [
{ query: GET_SUPPLY_ORDERS }, // Обновляем заказы поставок
{ query: GET_MY_SUPPLIES }, // Обновляем склад фулфилмента (расходники фф)
{ query: GET_MY_SUPPLIES }, // Обновляем склад фулфилмента (расходники фулфилмента)
{ query: GET_WAREHOUSE_PRODUCTS }, // Обновляем товары склада
],
onError: (error) => {
@ -144,7 +144,7 @@ export function FulfillmentDetailedSuppliesTab() {
// Получаем ID текущей организации (фулфилмент-центра)
const currentOrganizationId = user?.organization?.id;
// "Наши расходники" = расходники, которые МЫ (фулфилмент-центр) заказали для себя
// "Расходники фулфилмента" = расходники, которые МЫ (фулфилмент-центр) заказали для себя
// Критерии: создатель = мы И получатель = мы (ОБА условия)
const ourSupplyOrders: SupplyOrder[] = (data?.supplyOrders || []).filter(
(order: SupplyOrder) => {
@ -226,7 +226,7 @@ export function FulfillmentDetailedSuppliesTab() {
{/* Заголовок с кнопкой создания поставки */}
<div className="flex items-center justify-between">
<div>
<h2 className="text-xl font-bold text-white mb-1">Наши расходники</h2>
<h2 className="text-xl font-bold text-white mb-1">Расходники фулфилмента</h2>
<p className="text-white/60 text-sm">
Поставки расходников, поступающие на склад фулфилмент-центра
</p>

View File

@ -86,9 +86,9 @@ export function FulfillmentSuppliesTab() {
className="data-[state=active]:bg-white/20 data-[state=active]:text-white text-white/70 flex items-center gap-1 text-[10px] xl:text-xs relative"
>
<Building2 className="h-2.5 w-2.5 xl:h-3 xl:w-3" />
<span className="hidden md:inline">Наши расходники</span>
<span className="md:hidden hidden sm:inline">Наши</span>
<span className="sm:hidden">Н</span>
<span className="hidden md:inline">Расходники фулфилмента</span>
<span className="md:hidden hidden sm:inline">Фулфилмент</span>
<span className="sm:hidden">Ф</span>
<NotificationBadge count={ourSupplyOrdersCount} />
</TabsTrigger>
<TabsTrigger

View File

@ -4,7 +4,7 @@ import React, { useState, useMemo, useCallback } from "react";
import { Sidebar } from "@/components/dashboard/sidebar";
import { useSidebar } from "@/hooks/useSidebar";
import { useQuery } from "@apollo/client";
import { GET_MY_SUPPLIES } from "@/graphql/queries";
import { GET_MY_FULFILLMENT_SUPPLIES } from "@/graphql/queries";
import {
Package,
Wrench,
@ -87,14 +87,14 @@ export function FulfillmentSuppliesPage() {
loading,
error,
refetch,
} = useQuery(GET_MY_SUPPLIES, {
} = useQuery(GET_MY_FULFILLMENT_SUPPLIES, {
fetchPolicy: "cache-and-network",
onError: (error) => {
toast.error("Ошибка загрузки расходников: " + error.message);
toast.error("Ошибка загрузки расходников фулфилмента: " + error.message);
},
});
const supplies: Supply[] = suppliesData?.mySupplies || [];
const supplies: Supply[] = suppliesData?.myFulfillmentSupplies || [];
// Логирование для отладки
console.log("🔥🔥🔥 FULFILLMENT SUPPLIES PAGE DATA 🔥🔥🔥", {
@ -142,17 +142,17 @@ export function FulfillmentSuppliesPage() {
// Суммируем поставленное количество (заказано = поставлено)
acc[key].quantity += supply.quantity;
// Суммируем отправленное количество
acc[key].shippedQuantity += supply.shippedQuantity || 0;
// Остаток = Поставлено - Отправлено
// Если ничего не отправлено, то остаток = поставлено
acc[key].currentStock = acc[key].quantity - acc[key].shippedQuantity;
// Рассчитываем общую стоимость (количество × цена)
acc[key].totalCost += supply.quantity * supply.price;
// Средневзвешенная цена за единицу
if (acc[key].quantity > 0) {
acc[key].price = acc[key].totalCost / acc[key].quantity;
@ -265,7 +265,7 @@ export function FulfillmentSuppliesPage() {
const blob = new Blob([csv], { type: "text/csv;charset=utf-8;" });
const link = document.createElement("a");
link.href = URL.createObjectURL(blob);
link.download = `расходники_фф_${
link.download = `расходники_фулфилмента_${
new Date().toISOString().split("T")[0]
}.csv`;
link.click();

View File

@ -15,7 +15,8 @@ import {
GET_MY_COUNTERPARTIES,
GET_SUPPLY_ORDERS,
GET_WAREHOUSE_PRODUCTS,
GET_MY_SUPPLIES, // Добавляем импорт для загрузки расходников
GET_MY_SUPPLIES, // Расходники селлеров
GET_MY_FULFILLMENT_SUPPLIES, // Расходники фулфилмента
} from "@/graphql/queries";
import { toast } from "sonner";
import {
@ -198,7 +199,7 @@ export function FulfillmentWarehouseDashboard() {
fetchPolicy: "cache-and-network",
});
// Загружаем расходники фулфилмента
// Загружаем расходники селлеров
const {
data: suppliesData,
loading: suppliesLoading,
@ -208,6 +209,16 @@ export function FulfillmentWarehouseDashboard() {
fetchPolicy: "cache-and-network",
});
// Загружаем расходники фулфилмента
const {
data: fulfillmentSuppliesData,
loading: fulfillmentSuppliesLoading,
error: fulfillmentSuppliesError,
refetch: refetchFulfillmentSupplies,
} = useQuery(GET_MY_FULFILLMENT_SUPPLIES, {
fetchPolicy: "cache-and-network",
});
// Получаем данные магазинов, заказов и товаров
const allCounterparties = counterpartiesData?.myCounterparties || [];
const sellerPartners = allCounterparties.filter(
@ -215,7 +226,9 @@ export function FulfillmentWarehouseDashboard() {
);
const supplyOrders: SupplyOrder[] = ordersData?.supplyOrders || [];
const allProducts = productsData?.warehouseProducts || [];
const mySupplies = suppliesData?.mySupplies || []; // Добавляем расходники
const mySupplies = suppliesData?.mySupplies || []; // Расходники селлеров
const myFulfillmentSupplies =
fulfillmentSuppliesData?.myFulfillmentSupplies || []; // Расходники фулфилмента
// Логирование для отладки
console.log("🏪 Данные склада фулфилмента:", {
@ -391,37 +404,47 @@ export function FulfillmentWarehouseDashboard() {
0
);
// Подсчитываем расходники ФФ (расходники, которые получил фулфилмент-центр)
const fulfillmentConsumablesOrders = supplyOrders.filter((order) => {
// Заказы где текущий фулфилмент-центр является получателем
const isRecipient =
order.fulfillmentCenter?.id === user?.organization?.id;
// НО создатель заказа НЕ мы (т.е. селлер создал заказ для нас)
const isCreatedByOther =
order.organization?.id !== user?.organization?.id;
// И статус DELIVERED (получено)
const isDelivered = order.status === "DELIVERED";
return isRecipient && isCreatedByOther && isDelivered;
});
// Подсчитываем общее количество расходников ФФ из доставленных заказов
const totalFulfillmentSupplies = fulfillmentConsumablesOrders.reduce(
(sum, order) => sum + (order.totalItems || 0),
// Подсчитываем расходники фулфилмента из нового резолвера
// Основное значение = текущий остаток на складе
const totalFulfillmentSupplies = myFulfillmentSupplies.reduce(
(sum: number, supply: any) => sum + (supply.currentStock || 0),
0
);
// Подсчитываем изменения за сегодня (расходники ФФ, полученные сегодня)
// Дополнительные значения - динамика за сегодня
const today = new Date();
today.setHours(0, 0, 0, 0);
const fulfillmentSuppliesReceivedToday = fulfillmentConsumablesOrders
.filter((order) => {
const orderDate = new Date(order.updatedAt || order.createdAt);
orderDate.setHours(0, 0, 0, 0);
return orderDate.getTime() === today.getTime();
// Поставлено сегодня (дополнительное значение +)
const fulfillmentSuppliesReceivedToday = myFulfillmentSupplies
.filter((supply: any) => {
const supplyDate = new Date(supply.updatedAt || supply.createdAt);
supplyDate.setHours(0, 0, 0, 0);
return (
supplyDate.getTime() === today.getTime() &&
supply.status === "available"
);
})
.reduce((sum, order) => sum + (order.totalItems || 0), 0);
.reduce(
(sum: number, supply: any) => sum + (supply.quantity || 0), // Поставленное количество
0
);
// Использовано сегодня (дополнительное значение -)
const fulfillmentSuppliesUsedToday = myFulfillmentSupplies
.filter((supply: any) => {
const supplyDate = new Date(supply.updatedAt || supply.createdAt);
supplyDate.setHours(0, 0, 0, 0);
return supplyDate.getTime() === today.getTime();
})
.reduce(
(sum: number, supply: any) => sum + (supply.usedStock || 0), // Использованное количество
0
);
// Итоговое изменение = поставлено - использовано
const fulfillmentSuppliesChange =
fulfillmentSuppliesReceivedToday - fulfillmentSuppliesUsedToday;
return {
products: {
@ -441,8 +464,8 @@ export function FulfillmentWarehouseDashboard() {
change: 0, // Нет реальных данных об изменениях возвратов
},
fulfillmentSupplies: {
current: totalFulfillmentSupplies, // Реальное количество расходников ФФ
change: fulfillmentSuppliesReceivedToday, // Расходники ФФ, полученные сегодня
current: totalFulfillmentSupplies, // Основное значение: текущий остаток на складе
change: fulfillmentSuppliesChange, // Дополнительное значение: поставлено - использовано за сегодня
},
sellerSupplies: {
current: totalSellerSupplies, // Реальное количество расходников селлера из базы
@ -1245,7 +1268,7 @@ export function FulfillmentWarehouseDashboard() {
description="К обработке"
/>
<StatCard
title="Расходники ФФ"
title="Расходники фулфилмента"
icon={Wrench}
current={warehouseStats.fulfillmentSupplies.current}
change={warehouseStats.fulfillmentSupplies.change}

View File

@ -1,68 +1,75 @@
"use client"
"use client";
import { useState } from 'react'
import { useQuery, useMutation } from '@apollo/client'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Search, Boxes } from 'lucide-react'
import { OrganizationCard } from './organization-card'
import { SEARCH_ORGANIZATIONS, GET_INCOMING_REQUESTS, GET_OUTGOING_REQUESTS } from '@/graphql/queries'
import { SEND_COUNTERPARTY_REQUEST } from '@/graphql/mutations'
import { useState } from "react";
import { useQuery, useMutation } from "@apollo/client";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Search, Boxes } from "lucide-react";
import { OrganizationCard } from "./organization-card";
import {
SEARCH_ORGANIZATIONS,
GET_INCOMING_REQUESTS,
GET_OUTGOING_REQUESTS,
} from "@/graphql/queries";
import { SEND_COUNTERPARTY_REQUEST } from "@/graphql/mutations";
interface Organization {
id: string
inn: string
name?: string
fullName?: string
type: 'FULFILLMENT' | 'SELLER' | 'LOGIST' | 'WHOLESALE'
address?: string
phones?: Array<{ value: string }>
emails?: Array<{ value: string }>
createdAt: string
users?: Array<{ id: string, avatar?: string }>
isCounterparty?: boolean
isCurrentUser?: boolean
hasOutgoingRequest?: boolean
hasIncomingRequest?: boolean
id: string;
inn: string;
name?: string;
fullName?: string;
type: "FULFILLMENT" | "SELLER" | "LOGIST" | "WHOLESALE";
address?: string;
phones?: Array<{ value: string }>;
emails?: Array<{ value: string }>;
createdAt: string;
users?: Array<{ id: string; avatar?: string }>;
isCounterparty?: boolean;
isCurrentUser?: boolean;
hasOutgoingRequest?: boolean;
hasIncomingRequest?: boolean;
}
export function MarketWholesale() {
const [searchTerm, setSearchTerm] = useState('')
export function MarketSuppliers() {
const [searchTerm, setSearchTerm] = useState("");
const { data, loading, refetch } = useQuery(SEARCH_ORGANIZATIONS, {
variables: { type: 'WHOLESALE', search: searchTerm || null }
})
variables: { type: "WHOLESALE", search: searchTerm || null },
});
const [sendRequest, { loading: sendingRequest }] = useMutation(SEND_COUNTERPARTY_REQUEST, {
refetchQueries: [
{ query: SEARCH_ORGANIZATIONS, variables: { type: 'SELLER' } },
{ query: SEARCH_ORGANIZATIONS, variables: { type: 'FULFILLMENT' } },
{ query: SEARCH_ORGANIZATIONS, variables: { type: 'LOGIST' } },
{ query: SEARCH_ORGANIZATIONS, variables: { type: 'WHOLESALE' } },
{ query: GET_OUTGOING_REQUESTS },
{ query: GET_INCOMING_REQUESTS }
],
awaitRefetchQueries: true
})
const [sendRequest, { loading: sendingRequest }] = useMutation(
SEND_COUNTERPARTY_REQUEST,
{
refetchQueries: [
{ query: SEARCH_ORGANIZATIONS, variables: { type: "SELLER" } },
{ query: SEARCH_ORGANIZATIONS, variables: { type: "FULFILLMENT" } },
{ query: SEARCH_ORGANIZATIONS, variables: { type: "LOGIST" } },
{ query: SEARCH_ORGANIZATIONS, variables: { type: "WHOLESALE" } },
{ query: GET_OUTGOING_REQUESTS },
{ query: GET_INCOMING_REQUESTS },
],
awaitRefetchQueries: true,
}
);
const handleSearch = () => {
refetch({ type: 'WHOLESALE', search: searchTerm || null })
}
refetch({ type: "WHOLESALE", search: searchTerm || null });
};
const handleSendRequest = async (organizationId: string, message: string) => {
try {
await sendRequest({
variables: {
organizationId: organizationId,
message: message || 'Заявка на добавление в контрагенты'
}
})
message: message || "Заявка на добавление в контрагенты",
},
});
} catch (error) {
console.error('Ошибка отправки заявки:', error)
console.error("Ошибка отправки заявки:", error);
}
}
};
const organizations = data?.searchOrganizations || []
const organizations = data?.searchOrganizations || [];
return (
<div className="h-full flex flex-col space-y-4 overflow-hidden">
@ -71,14 +78,14 @@ export function MarketWholesale() {
<div className="relative flex-1">
<Search className="absolute left-3 top-3 h-4 w-4 text-white/40" />
<Input
placeholder="Поиск оптовых компаний по названию или ИНН..."
placeholder="Поиск поставщиков по названию или ИНН..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && handleSearch()}
onKeyDown={(e) => e.key === "Enter" && handleSearch()}
className="pl-10 glass-input text-white placeholder:text-white/40 h-10"
/>
</div>
<Button
<Button
onClick={handleSearch}
className="bg-purple-500/20 hover:bg-purple-500/30 text-purple-300 border-purple-500/30 cursor-pointer"
>
@ -91,8 +98,10 @@ export function MarketWholesale() {
<div className="flex items-center space-x-3 flex-shrink-0 mb-4">
<Boxes className="h-6 w-6 text-purple-400" />
<div>
<h3 className="text-lg font-semibold text-white">Поставщики</h3>
<p className="text-white/60 text-sm">Найдите и добавьте оптовые компании в контрагенты</p>
<h3 className="text-lg font-semibold text-white">Поставщики</h3>
<p className="text-white/60 text-sm">
Найдите и добавьте поставщиков в контрагенты
</p>
</div>
</div>
@ -107,7 +116,9 @@ export function MarketWholesale() {
<div className="text-center">
<Boxes className="h-12 w-12 text-white/20 mx-auto mb-4" />
<p className="text-white/60">
{searchTerm ? 'Поставщики не найдены' : 'Введите запрос для поиска поставщиков'}
{searchTerm
? "Поставщики не найдены"
: "Введите запрос для поиска поставщиков"}
</p>
<p className="text-white/40 text-sm mt-2">
Попробуйте изменить условия поиска
@ -130,5 +141,5 @@ export function MarketWholesale() {
)}
</div>
</div>
)
}
);
}

View File

@ -1,101 +1,127 @@
"use client"
"use client";
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { Card } from '@/components/ui/card'
import { Sidebar } from '@/components/dashboard/sidebar'
import { useSidebar } from '@/hooks/useSidebar'
import { useQuery } from '@apollo/client'
import { GET_INCOMING_REQUESTS } from '@/graphql/queries'
import { MarketCounterparties } from '../market/market-counterparties'
import { MarketFulfillment } from '../market/market-fulfillment'
import { MarketSellers } from '../market/market-sellers'
import { MarketLogistics } from '../market/market-logistics'
import { MarketWholesale } from '../market/market-wholesale'
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Card } from "@/components/ui/card";
import { Sidebar } from "@/components/dashboard/sidebar";
import { useSidebar } from "@/hooks/useSidebar";
import { useQuery } from "@apollo/client";
import { GET_INCOMING_REQUESTS } from "@/graphql/queries";
import { MarketCounterparties } from "../market/market-counterparties";
import { MarketFulfillment } from "../market/market-fulfillment";
import { MarketSellers } from "../market/market-sellers";
import { MarketLogistics } from "../market/market-logistics";
import { MarketSuppliers } from "../market/market-suppliers";
export function PartnersDashboard() {
const { getSidebarMargin } = useSidebar()
const { getSidebarMargin } = useSidebar();
// Загружаем входящие заявки для подсветки
const { data: incomingRequestsData } = useQuery(GET_INCOMING_REQUESTS, {
pollInterval: 30000, // Обновляем каждые 30 секунд
fetchPolicy: 'cache-first',
errorPolicy: 'ignore',
})
fetchPolicy: "cache-first",
errorPolicy: "ignore",
});
const incomingRequests = incomingRequestsData?.incomingRequests || []
const hasIncomingRequests = incomingRequests.length > 0
const incomingRequests = incomingRequestsData?.incomingRequests || [];
const hasIncomingRequests = incomingRequests.length > 0;
return (
<div className="h-screen flex overflow-hidden">
<Sidebar />
<main className={`flex-1 ${getSidebarMargin()} px-6 py-4 overflow-hidden transition-all duration-300`}>
<main
className={`flex-1 ${getSidebarMargin()} px-6 py-4 overflow-hidden transition-all duration-300`}
>
<div className="h-full w-full flex flex-col">
{/* Основной контент с табами */}
<div className="flex-1 overflow-hidden">
<Tabs defaultValue="counterparties" className="h-full flex flex-col">
<TabsList className={`grid w-full grid-cols-5 bg-white/5 backdrop-blur border-white/10 flex-shrink-0 ${hasIncomingRequests ? 'ring-2 ring-blue-400/50' : ''}`}>
<TabsTrigger
value="counterparties"
className={`data-[state=active]:bg-white/20 data-[state=active]:text-white text-white/70 relative ${hasIncomingRequests ? 'animate-pulse' : ''}`}
<Tabs
defaultValue="counterparties"
className="h-full flex flex-col"
>
<TabsList
className={`grid w-full grid-cols-5 bg-white/5 backdrop-blur border-white/10 flex-shrink-0 ${
hasIncomingRequests ? "ring-2 ring-blue-400/50" : ""
}`}
>
<TabsTrigger
value="counterparties"
className={`data-[state=active]:bg-white/20 data-[state=active]:text-white text-white/70 relative ${
hasIncomingRequests ? "animate-pulse" : ""
}`}
>
Мои контрагенты
{hasIncomingRequests && (
<div className="absolute -top-1 -right-1 w-3 h-3 bg-blue-500 rounded-full"></div>
)}
</TabsTrigger>
<TabsTrigger
value="fulfillment"
<TabsTrigger
value="fulfillment"
className="data-[state=active]:bg-white/20 data-[state=active]:text-white text-white/70"
>
Фулфилмент
</TabsTrigger>
<TabsTrigger
value="sellers"
<TabsTrigger
value="sellers"
className="data-[state=active]:bg-white/20 data-[state=active]:text-white text-white/70"
>
Селлеры
</TabsTrigger>
<TabsTrigger
value="logistics"
<TabsTrigger
value="logistics"
className="data-[state=active]:bg-white/20 data-[state=active]:text-white text-white/70"
>
Логистика
</TabsTrigger>
<TabsTrigger
value="wholesale"
<TabsTrigger
value="suppliers"
className="data-[state=active]:bg-white/20 data-[state=active]:text-white text-white/70"
>
Поставщик
</TabsTrigger>
</TabsList>
<TabsContent value="counterparties" className="flex-1 overflow-hidden mt-6">
<TabsContent
value="counterparties"
className="flex-1 overflow-hidden mt-6"
>
<Card className="glass-card h-full overflow-hidden p-6">
<MarketCounterparties />
</Card>
</TabsContent>
<TabsContent value="fulfillment" className="flex-1 overflow-hidden mt-6">
<TabsContent
value="fulfillment"
className="flex-1 overflow-hidden mt-6"
>
<Card className="glass-card h-full overflow-hidden p-6">
<MarketFulfillment />
</Card>
</TabsContent>
<TabsContent value="sellers" className="flex-1 overflow-hidden mt-6">
<TabsContent
value="sellers"
className="flex-1 overflow-hidden mt-6"
>
<Card className="glass-card h-full overflow-hidden p-6">
<MarketSellers />
</Card>
</TabsContent>
<TabsContent value="logistics" className="flex-1 overflow-hidden mt-6">
<TabsContent
value="logistics"
className="flex-1 overflow-hidden mt-6"
>
<Card className="glass-card h-full overflow-hidden p-6">
<MarketLogistics />
</Card>
</TabsContent>
<TabsContent value="wholesale" className="flex-1 overflow-hidden mt-6">
<TabsContent
value="suppliers"
className="flex-1 overflow-hidden mt-6"
>
<Card className="glass-card h-full overflow-hidden p-6">
<MarketWholesale />
<MarketSuppliers />
</Card>
</TabsContent>
</Tabs>
@ -103,5 +129,5 @@ export function PartnersDashboard() {
</div>
</main>
</div>
)
}
);
}

View File

@ -1,26 +1,24 @@
"use client"
"use client";
import React from 'react'
import { Card } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import {
ShoppingCart,
Building2,
Plus,
Minus,
Eye
} from 'lucide-react'
import { SelectedProduct } from './types'
import React from "react";
import { Card } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { ShoppingCart, Building2, Plus, Minus, Eye } from "lucide-react";
import { SelectedProduct } from "./types";
interface CartSummaryProps {
selectedProducts: SelectedProduct[]
onQuantityChange: (productId: string, wholesalerId: string, quantity: number) => void
onRemoveProduct: (productId: string, wholesalerId: string) => void
onCreateSupply: () => void
onToggleVisibility: () => void
formatCurrency: (amount: number) => string
visible: boolean
selectedProducts: SelectedProduct[];
onQuantityChange: (
productId: string,
supplierId: string,
quantity: number
) => void;
onRemoveProduct: (productId: string, supplierId: string) => void;
onCreateSupply: () => void;
onToggleVisibility: () => void;
formatCurrency: (amount: number) => string;
visible: boolean;
}
export function CartSummary({
@ -30,36 +28,39 @@ export function CartSummary({
onCreateSupply,
onToggleVisibility,
formatCurrency,
visible
visible,
}: CartSummaryProps) {
if (!visible || selectedProducts.length === 0) {
return null
return null;
}
// Группируем товары по поставщикам
const groupedProducts = selectedProducts.reduce((acc, product) => {
if (!acc[product.wholesalerId]) {
acc[product.wholesalerId] = {
wholesaler: product.wholesalerName,
products: []
}
if (!acc[product.supplierId]) {
acc[product.supplierId] = {
supplier: product.supplierName,
products: [],
};
}
acc[product.wholesalerId].products.push(product)
return acc
}, {} as Record<string, { wholesaler: string; products: SelectedProduct[] }>)
acc[product.supplierId].products.push(product);
return acc;
}, {} as Record<string, { supplier: string; products: SelectedProduct[] }>);
const getTotalAmount = () => {
return selectedProducts.reduce((sum, product) => {
const discountedPrice = product.discount
const discountedPrice = product.discount
? product.price * (1 - product.discount / 100)
: product.price
return sum + (discountedPrice * product.selectedQuantity)
}, 0)
}
: product.price;
return sum + discountedPrice * product.selectedQuantity;
}, 0);
};
const getTotalItems = () => {
return selectedProducts.reduce((sum, product) => sum + product.selectedQuantity, 0)
}
return selectedProducts.reduce(
(sum, product) => sum + product.selectedQuantity,
0
);
};
return (
<Card className="bg-gradient-to-br from-purple-500/10 to-pink-500/10 backdrop-blur-xl border border-purple-500/20 mb-6 shadow-2xl">
@ -72,7 +73,8 @@ export function CartSummary({
<div>
<h3 className="text-white font-bold text-lg">Корзина</h3>
<p className="text-purple-200 text-xs">
{selectedProducts.length} товаров от {Object.keys(groupedProducts).length} поставщиков
{selectedProducts.length} товаров от{" "}
{Object.keys(groupedProducts).length} поставщиков
</p>
</div>
</div>
@ -85,74 +87,99 @@ export function CartSummary({
<Eye className="h-4 w-4" />
</Button>
</div>
{/* Группировка по поставщикам */}
{Object.entries(groupedProducts).map(([wholesalerId, group]) => (
<div key={wholesalerId} className="mb-4 last:mb-0">
{Object.entries(groupedProducts).map(([supplierId, group]) => (
<div key={supplierId} className="mb-4 last:mb-0">
<div className="flex items-center mb-2 pb-1 border-b border-white/10">
<Building2 className="h-4 w-4 text-blue-400 mr-2" />
<span className="text-white font-medium">{group.wholesaler}</span>
<span className="text-white font-medium">{group.supplier}</span>
<Badge className="ml-2 bg-blue-500/20 text-blue-300 border-blue-500/30 text-xs">
{group.products.length} товар(ов)
</Badge>
</div>
<div className="space-y-2">
{group.products.map((product) => {
const discountedPrice = product.discount
const discountedPrice = product.discount
? product.price * (1 - product.discount / 100)
: product.price
const totalPrice = discountedPrice * product.selectedQuantity
: product.price;
const totalPrice = discountedPrice * product.selectedQuantity;
return (
<div key={`${product.wholesalerId}-${product.id}`} className="flex items-center space-x-3 bg-white/5 rounded-lg p-3">
<div
key={`${product.supplierId}-${product.id}`}
className="flex items-center space-x-3 bg-white/5 rounded-lg p-3"
>
<img
src={product.mainImage || '/api/placeholder/50/50'}
src={product.mainImage || "/api/placeholder/50/50"}
alt={product.name}
className="w-12 h-12 rounded-lg object-cover"
/>
<div className="flex-1 min-w-0">
<h4 className="text-white font-medium text-xs mb-1 truncate">{product.name}</h4>
<p className="text-white/60 text-xs mb-1">{product.article}</p>
<h4 className="text-white font-medium text-xs mb-1 truncate">
{product.name}
</h4>
<p className="text-white/60 text-xs mb-1">
{product.article}
</p>
<div className="flex items-center space-x-2">
<div className="flex items-center space-x-1">
<Button
variant="ghost"
size="sm"
onClick={() => {
const newQuantity = Math.max(0, product.selectedQuantity - 1)
const newQuantity = Math.max(
0,
product.selectedQuantity - 1
);
if (newQuantity === 0) {
onRemoveProduct(product.id, product.wholesalerId)
onRemoveProduct(product.id, product.supplierId);
} else {
onQuantityChange(product.id, product.wholesalerId, newQuantity)
onQuantityChange(
product.id,
product.supplierId,
newQuantity
);
}
}}
className="h-6 w-6 p-0 text-white/60 hover:text-white hover:bg-white/10"
>
<Minus className="h-3 w-3" />
</Button>
<span className="text-white text-xs w-6 text-center">{product.selectedQuantity}</span>
<span className="text-white text-xs w-6 text-center">
{product.selectedQuantity}
</span>
<Button
variant="ghost"
size="sm"
onClick={() => {
onQuantityChange(
product.id,
product.wholesalerId,
Math.min(product.quantity, product.selectedQuantity + 1)
)
product.id,
product.wholesalerId,
Math.min(
product.quantity,
product.selectedQuantity + 1
)
);
}}
disabled={product.selectedQuantity >= product.quantity}
disabled={
product.selectedQuantity >= product.quantity
}
className="h-6 w-6 p-0 text-white/60 hover:text-white hover:bg-white/10"
>
<Plus className="h-3 w-3" />
</Button>
</div>
<div className="text-right">
<div className="text-white font-semibold text-xs">{formatCurrency(totalPrice)}</div>
<div className="text-white font-semibold text-xs">
{formatCurrency(totalPrice)}
</div>
{product.discount && (
<div className="text-white/40 text-xs line-through">
{formatCurrency(product.price * product.selectedQuantity)}
{formatCurrency(
product.price * product.selectedQuantity
)}
</div>
)}
</div>
@ -161,18 +188,20 @@ export function CartSummary({
<Button
variant="ghost"
size="sm"
onClick={() => onRemoveProduct(product.id, product.wholesalerId)}
onClick={() =>
onRemoveProduct(product.id, product.supplierId)
}
className="text-red-400 hover:text-red-300 hover:bg-red-500/10"
>
</Button>
</div>
)
);
})}
</div>
</div>
))}
{/* Итого */}
<div className="border-t border-white/20 pt-3 mt-4">
<div className="flex justify-between items-center">
@ -184,7 +213,7 @@ export function CartSummary({
</span>
</div>
<div className="flex space-x-2 mt-3">
<Button
<Button
variant="outline"
className="flex-1 border-purple-300/30 text-white hover:bg-white/10"
onClick={onToggleVisibility}
@ -192,7 +221,7 @@ export function CartSummary({
<Plus className="h-4 w-4 mr-2" />
Добавить еще
</Button>
<Button
<Button
className="flex-1 bg-gradient-to-r from-green-500 to-emerald-500 hover:from-green-600 hover:to-emerald-600 text-white"
onClick={onCreateSupply}
>
@ -203,5 +232,5 @@ export function CartSummary({
</div>
</div>
</Card>
)
}
);
}

View File

@ -1343,13 +1343,13 @@ export function DirectSupplyCreation({
Цена
</div>
<div className="text-white/80 text-[9px] font-medium text-center">
Услуги фф
Услуги фулфилмента
</div>
<div className="text-white/80 text-[9px] font-medium text-center">
Поставщик
</div>
<div className="text-white/80 text-[9px] font-medium text-center">
Расходники фф
Расходники фулфилмента
</div>
<div className="text-white/80 text-[9px] font-medium text-center">
Расходники
@ -1654,7 +1654,7 @@ export function DirectSupplyCreation({
</div>
</div>
{/* Блок 7: Расходники фф */}
{/* Блок 7: Расходники фулфилмента */}
<div className="bg-white/10 rounded-lg p-2 flex flex-col justify-center h-20">
<div className="space-y-1 max-h-16 overflow-y-auto">
{/* DEBUG для расходников */}

View File

@ -21,7 +21,7 @@ import {
Tags,
} from "lucide-react";
// Типы данных для расходников ФФ
// Типы данных для расходников фулфилмента
interface ConsumableParameter {
id: string;
name: string;
@ -79,7 +79,7 @@ interface FulfillmentConsumableSupply {
status: "planned" | "in-transit" | "delivered" | "completed";
}
// Моковые данные для расходников ФФ
// Моковые данные для расходников фулфилмента
const mockFulfillmentConsumables: FulfillmentConsumableSupply[] = [
{
id: "ffc1",
@ -116,7 +116,7 @@ const mockFulfillmentConsumables: FulfillmentConsumableSupply[] = [
id: "ffcons1",
name: "Коробки для ФФ 40x30x15",
sku: "BOX-FF-403015",
category: "Расходники ФФ",
category: "Расходники фулфилмента",
type: "packaging",
plannedQty: 2000,
actualQty: 1980,
@ -269,10 +269,10 @@ export function FulfillmentSuppliesTab() {
return (
<div className="space-y-6">
{/* Статистика расходников ФФ */}
{/* Статистика расходников фулфилмента */}
<StatsGrid>
<StatsCard
title="Расходники ФФ"
title="Расходники фулфилмента"
value={mockFulfillmentConsumables.length}
icon={Package2}
iconColor="text-orange-400"
@ -282,7 +282,7 @@ export function FulfillmentSuppliesTab() {
/>
<StatsCard
title="Сумма расходников ФФ"
title="Сумма расходников фулфилмента"
value={formatCurrency(
mockFulfillmentConsumables.reduce(
(sum, supply) => sum + supply.grandTotal,
@ -324,7 +324,7 @@ export function FulfillmentSuppliesTab() {
/>
</StatsGrid>
{/* Таблица поставок расходников ФФ */}
{/* Таблица поставок расходников фулфилмента */}
<Card className="bg-white/10 backdrop-blur border-white/20 overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full">
@ -359,7 +359,7 @@ export function FulfillmentSuppliesTab() {
return (
<React.Fragment key={supply.id}>
{/* Основная строка поставки расходников ФФ */}
{/* Основная строка поставки расходников фулфилмента */}
<tr
className="border-b border-white/10 hover:bg-white/5 transition-colors bg-orange-500/10 cursor-pointer"
onClick={() => toggleSupplyExpansion(supply.id)}

View File

@ -171,6 +171,7 @@ export function RealSupplyOrdersTab() {
const { data, loading, error, refetch } = useQuery(GET_SUPPLY_ORDERS, {
fetchPolicy: "cache-and-network",
notifyOnNetworkStatusChange: true,
pollInterval: 30000, // 🔔 Опрашиваем каждые 30 секунд для получения новых заказов
});
// Мутация для обновления статуса заказа
@ -189,15 +190,22 @@ export function RealSupplyOrdersTab() {
toast.error("Ошибка при обновлении статуса заказа");
},
update: (cache, { data }) => {
if (data?.updateSupplyOrderStatus?.success && data?.updateSupplyOrderStatus?.order) {
console.log(`✅ Обновляем кэш для заказа ${data.updateSupplyOrderStatus.order.id} на статус ${data.updateSupplyOrderStatus.order.status}`);
if (
data?.updateSupplyOrderStatus?.success &&
data?.updateSupplyOrderStatus?.order
) {
console.log(
`✅ Обновляем кэш для заказа ${data.updateSupplyOrderStatus.order.id} на статус ${data.updateSupplyOrderStatus.order.status}`
);
// Точечно обновляем кэш для конкретного заказа
cache.modify({
id: cache.identify(data.updateSupplyOrderStatus.order),
fields: {
status() {
console.log(`📝 Обновляем поле status для заказа ${data.updateSupplyOrderStatus.order.id}`);
console.log(
`📝 Обновляем поле status для заказа ${data.updateSupplyOrderStatus.order.id}`
);
return data.updateSupplyOrderStatus.order.status;
},
},
@ -205,9 +213,13 @@ export function RealSupplyOrdersTab() {
// Также обновляем данные в запросе GET_SUPPLY_ORDERS если нужно
try {
const existingData = cache.readQuery({ query: GET_SUPPLY_ORDERS }) as any;
const existingData = cache.readQuery({
query: GET_SUPPLY_ORDERS,
}) as any;
if (existingData?.supplyOrders) {
console.log(`📋 Обновляем список заказов в кэше, всего заказов: ${existingData.supplyOrders.length}`);
console.log(
`📋 Обновляем список заказов в кэше, всего заказов: ${existingData.supplyOrders.length}`
);
cache.writeQuery({
query: GET_SUPPLY_ORDERS,
data: {
@ -215,7 +227,10 @@ export function RealSupplyOrdersTab() {
supplyOrders: existingData.supplyOrders.map((order: any) => {
if (order.id === data.updateSupplyOrderStatus.order.id) {
console.log(`🎯 Найден и обновлен заказ ${order.id}`);
return { ...order, status: data.updateSupplyOrderStatus.order.status };
return {
...order,
status: data.updateSupplyOrderStatus.order.status,
};
}
return order;
}),
@ -243,11 +258,16 @@ export function RealSupplyOrdersTab() {
// Отладочное логирование для проверки дублирующихся ID
React.useEffect(() => {
if (incomingSupplyOrders.length > 0) {
const ids = incomingSupplyOrders.map(order => order.id);
const ids = incomingSupplyOrders.map((order) => order.id);
const uniqueIds = new Set(ids);
if (ids.length !== uniqueIds.size) {
console.warn(`⚠️ Обнаружены дублирующиеся ID заказов! Всего: ${ids.length}, уникальных: ${uniqueIds.size}`);
console.warn('Дублирующиеся ID:', ids.filter((id, index) => ids.indexOf(id) !== index));
console.warn(
`⚠️ Обнаружены дублирующиеся ID заказов! Всего: ${ids.length}, уникальных: ${uniqueIds.size}`
);
console.warn(
"Дублирующиеся ID:",
ids.filter((id, index) => ids.indexOf(id) !== index)
);
} else {
console.log(`Все ID заказов уникальны: ${ids.length} заказов`);
}
@ -276,7 +296,7 @@ export function RealSupplyOrdersTab() {
const handleStatusUpdate = async (orderId: string, status: string) => {
console.log(`🔄 Обновляем статус заказа ${orderId} на ${status}`);
try {
await updateSupplyOrderStatus({
variables: {

View File

@ -36,7 +36,7 @@ interface Product {
parameters: ProductParameter[];
}
interface Wholesaler {
interface Supplier {
id: string;
name: string;
inn: string;

View File

@ -1,24 +1,19 @@
"use client"
"use client";
import React from 'react'
import { Card } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import {
Building2,
MapPin,
Phone,
Mail
} from 'lucide-react'
import { WholesalerForCreation } from './types'
import React from "react";
import { Card } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Building2, MapPin, Phone, Mail } from "lucide-react";
import { SupplierForCreation } from "./types";
interface WholesalerCardProps {
wholesaler: WholesalerForCreation
onClick: () => void
interface SupplierCardProps {
supplier: SupplierForCreation;
onClick: () => void;
}
export function WholesalerCard({ wholesaler, onClick }: WholesalerCardProps) {
export function SupplierCard({ supplier, onClick }: SupplierCardProps) {
return (
<Card
<Card
className="bg-white/10 backdrop-blur border-white/20 p-4 cursor-pointer transition-all hover:bg-white/15 hover:border-white/30 hover:scale-[1.02]"
onClick={onClick}
>
@ -29,41 +24,46 @@ export function WholesalerCard({ wholesaler, onClick }: WholesalerCardProps) {
</div>
<div className="flex-1 min-w-0">
<h3 className="text-white font-semibold text-sm mb-1 truncate">
{wholesaler.name}
{supplier.name}
</h3>
<p className="text-white/60 text-xs mb-1 truncate">
{wholesaler.fullName}
</p>
<p className="text-white/40 text-xs">
ИНН: {wholesaler.inn}
{supplier.fullName}
</p>
<p className="text-white/40 text-xs">ИНН: {supplier.inn}</p>
</div>
</div>
<div className="space-y-1">
<div className="flex items-center space-x-1">
<MapPin className="h-3 w-3 text-gray-400" />
<span className="text-white/80 text-xs truncate">{wholesaler.address}</span>
<span className="text-white/80 text-xs truncate">
{supplier.address}
</span>
</div>
{wholesaler.phone && (
{supplier.phone && (
<div className="flex items-center space-x-1">
<Phone className="h-3 w-3 text-gray-400" />
<span className="text-white/80 text-xs">{wholesaler.phone}</span>
<span className="text-white/80 text-xs">{supplier.phone}</span>
</div>
)}
{wholesaler.email && (
{supplier.email && (
<div className="flex items-center space-x-1">
<Mail className="h-3 w-3 text-gray-400" />
<span className="text-white/80 text-xs truncate">{wholesaler.email}</span>
<span className="text-white/80 text-xs truncate">
{supplier.email}
</span>
</div>
)}
</div>
<div className="flex flex-wrap gap-1">
{wholesaler.specialization.map((spec, index) => (
<Badge key={index} className="bg-purple-500/20 text-purple-300 border-purple-500/30 text-xs">
{supplier.specialization.map((spec, index) => (
<Badge
key={index}
className="bg-purple-500/20 text-purple-300 border-purple-500/30 text-xs"
>
{spec}
</Badge>
))}
@ -71,8 +71,12 @@ export function WholesalerCard({ wholesaler, onClick }: WholesalerCardProps) {
<div className="pt-2 border-t border-white/10 flex items-center justify-between">
<div>
<p className="text-white/60 text-xs">Товаров: {wholesaler.productCount}</p>
<p className="text-white/60 text-xs">Рейтинг: {wholesaler.rating}/5</p>
<p className="text-white/60 text-xs">
Товаров: {supplier.productCount}
</p>
<p className="text-white/60 text-xs">
Рейтинг: {supplier.rating}/5
</p>
</div>
<Badge className="bg-green-500/20 text-green-300 border-green-500/30 text-xs">
Контрагент
@ -80,5 +84,5 @@ export function WholesalerCard({ wholesaler, onClick }: WholesalerCardProps) {
</div>
</div>
</Card>
)
}
);
}

View File

@ -0,0 +1,118 @@
"use client";
import React from "react";
import { SupplierCard } from "./supplier-card";
import { Input } from "@/components/ui/input";
import { Users, Search } from "lucide-react";
import { SupplierForCreation, CounterpartySupplier } from "./types";
interface SupplierGridProps {
suppliers: CounterpartySupplier[];
onSupplierSelect: (supplier: SupplierForCreation) => void;
searchQuery: string;
onSearchChange: (query: string) => void;
loading?: boolean;
}
export function SupplierGrid({
suppliers,
onSupplierSelect,
searchQuery,
onSearchChange,
loading = false,
}: SupplierGridProps) {
// Фильтруем поставщиков по поисковому запросу
const filteredSuppliers = suppliers.filter(
(supplier) =>
supplier.name?.toLowerCase().includes(searchQuery.toLowerCase()) ||
supplier.fullName?.toLowerCase().includes(searchQuery.toLowerCase()) ||
supplier.inn?.toLowerCase().includes(searchQuery.toLowerCase())
);
const handleSupplierClick = (supplier: CounterpartySupplier) => {
// Адаптируем данные под существующий интерфейс
const adaptedSupplier: SupplierForCreation = {
id: supplier.id,
inn: supplier.inn || "",
name: supplier.name || "Неизвестная организация",
fullName: supplier.fullName || supplier.name || "Неизвестная организация",
address: supplier.address || "Адрес не указан",
phone: supplier.phones?.[0]?.value,
email: supplier.emails?.[0]?.value,
rating: 4.5, // Временное значение
productCount: 0, // Временное значение
specialization: ["Оптовая торговля"], // Временное значение
};
onSupplierSelect(adaptedSupplier);
};
if (loading) {
return (
<div className="flex items-center justify-center p-8">
<div className="text-center">
<div className="animate-spin rounded-full h-12 w-12 border-4 border-white border-t-transparent mx-auto mb-4"></div>
<p className="text-white/60">Загружаем поставщиков...</p>
</div>
</div>
);
}
return (
<div>
{/* Поиск */}
<div className="mb-4">
<div className="relative max-w-md">
<Search className="absolute left-3 top-3 h-4 w-4 text-white/40" />
<Input
placeholder="Поиск поставщиков..."
value={searchQuery}
onChange={(e) => onSearchChange(e.target.value)}
className="pl-10 glass-input text-white placeholder:text-white/40 h-10"
/>
</div>
</div>
{filteredSuppliers.length === 0 ? (
<div className="text-center p-8">
<Users className="h-12 w-12 text-white/20 mx-auto mb-4" />
<p className="text-white/60">
{searchQuery
? "Поставщики не найдены"
: "У вас нет контрагентов-поставщиков"}
</p>
<p className="text-white/40 text-sm mt-2">
{searchQuery
? "Попробуйте изменить условия поиска"
: 'Добавьте поставщиков в разделе "Партнеры"'}
</p>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
{filteredSuppliers.map((supplier) => {
const adaptedSupplier: SupplierForCreation = {
id: supplier.id,
inn: supplier.inn || "",
name: supplier.name || "Неизвестная организация",
fullName:
supplier.fullName || supplier.name || "Неизвестная организация",
address: supplier.address || "Адрес не указан",
phone: supplier.phones?.[0]?.value,
email: supplier.emails?.[0]?.value,
rating: 4.5,
productCount: 0,
specialization: ["Оптовая торговля"],
};
return (
<SupplierCard
key={supplier.id}
supplier={adaptedSupplier}
onClick={() => handleSupplierClick(supplier)}
/>
);
})}
</div>
)}
</div>
);
}

View File

@ -1,30 +1,30 @@
"use client"
"use client";
import React from 'react'
import { Button } from '@/components/ui/button'
import { ProductGrid } from './product-grid'
import { CartSummary } from './cart-summary'
import { FloatingCart } from './floating-cart'
import { Sidebar } from '@/components/dashboard/sidebar'
import { useSidebar } from '@/hooks/useSidebar'
import { ArrowLeft, Info } from 'lucide-react'
import { WholesalerForCreation, WholesalerProduct, SelectedProduct } from './types'
import React from "react";
import { Button } from "@/components/ui/button";
import { ProductGrid } from "./product-grid";
import { CartSummary } from "./cart-summary";
import { FloatingCart } from "./floating-cart";
import { Sidebar } from "@/components/dashboard/sidebar";
import { useSidebar } from "@/hooks/useSidebar";
import { ArrowLeft, Info } from "lucide-react";
import { SupplierForCreation, SupplierProduct, SelectedProduct } from "./types";
interface WholesalerProductsPageProps {
selectedWholesaler: WholesalerForCreation
products: WholesalerProduct[]
selectedProducts: SelectedProduct[]
onQuantityChange: (productId: string, quantity: number) => void
onBack: () => void
onCreateSupply: () => void
formatCurrency: (amount: number) => string
showSummary: boolean
setShowSummary: (show: boolean) => void
loading: boolean
interface SupplierProductsPageProps {
selectedSupplier: SupplierForCreation;
products: SupplierProduct[];
selectedProducts: SelectedProduct[];
onQuantityChange: (productId: string, quantity: number) => void;
onBack: () => void;
onCreateSupply: () => void;
formatCurrency: (amount: number) => string;
showSummary: boolean;
setShowSummary: (show: boolean) => void;
loading: boolean;
}
export function WholesalerProductsPage({
selectedWholesaler,
export function SupplierProductsPage({
selectedSupplier,
products,
selectedProducts,
onQuantityChange,
@ -33,50 +33,61 @@ export function WholesalerProductsPage({
formatCurrency,
showSummary,
setShowSummary,
loading
}: WholesalerProductsPageProps) {
const { getSidebarMargin } = useSidebar()
loading,
}: SupplierProductsPageProps) {
const { getSidebarMargin } = useSidebar();
const getSelectedQuantity = (productId: string): number => {
const selected = selectedProducts.find(p => p.id === productId && p.wholesalerId === selectedWholesaler.id)
return selected ? selected.selectedQuantity : 0
}
const selected = selectedProducts.find(
(p) => p.id === productId && p.supplierId === selectedSupplier.id
);
return selected ? selected.selectedQuantity : 0;
};
const selectedProductsMap = products.reduce((acc, product) => {
acc[product.id] = getSelectedQuantity(product.id)
return acc
}, {} as Record<string, number>)
acc[product.id] = getSelectedQuantity(product.id);
return acc;
}, {} as Record<string, number>);
const getTotalAmount = () => {
return selectedProducts.reduce((sum, product) => {
const discountedPrice = product.discount
const discountedPrice = product.discount
? product.price * (1 - product.discount / 100)
: product.price
return sum + (discountedPrice * product.selectedQuantity)
}, 0)
}
: product.price;
return sum + discountedPrice * product.selectedQuantity;
}, 0);
};
const getTotalItems = () => {
return selectedProducts.reduce((sum, product) => sum + product.selectedQuantity, 0)
}
return selectedProducts.reduce(
(sum, product) => sum + product.selectedQuantity,
0
);
};
const handleRemoveProduct = (productId: string, wholesalerId: string) => {
onQuantityChange(productId, 0)
}
const handleRemoveProduct = (productId: string, supplierId: string) => {
onQuantityChange(productId, 0);
};
const handleCartQuantityChange = (productId: string, wholesalerId: string, quantity: number) => {
onQuantityChange(productId, quantity)
}
const handleCartQuantityChange = (
productId: string,
supplierId: string,
quantity: number
) => {
onQuantityChange(productId, quantity);
};
return (
<div className="h-screen flex overflow-hidden">
<Sidebar />
<main className={`flex-1 ${getSidebarMargin()} px-6 py-4 overflow-hidden transition-all duration-300`}>
<main
className={`flex-1 ${getSidebarMargin()} px-6 py-4 overflow-hidden transition-all duration-300`}
>
<div className="p-8">
<div className="flex items-center justify-between mb-8">
<div className="flex items-center space-x-4">
<Button
variant="ghost"
<Button
variant="ghost"
size="sm"
onClick={onBack}
className="text-white/60 hover:text-white hover:bg-white/10"
@ -85,13 +96,17 @@ export function WholesalerProductsPage({
Назад
</Button>
<div>
<h1 className="text-3xl font-bold text-white mb-2">Товары поставщика</h1>
<p className="text-white/60">{selectedWholesaler.name} {products.length} товаров</p>
<h1 className="text-3xl font-bold text-white mb-2">
Товары поставщика
</h1>
<p className="text-white/60">
{selectedSupplier.name} {products.length} товаров
</p>
</div>
</div>
<div className="flex items-center space-x-3">
<Button
variant="ghost"
<Button
variant="ghost"
size="sm"
onClick={() => setShowSummary(!showSummary)}
className="text-white/60 hover:text-white hover:bg-white/10"
@ -130,5 +145,5 @@ export function WholesalerProductsPage({
</div>
</main>
</div>
)
}
);
}

View File

@ -1,230 +1,241 @@
"use client"
"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 {
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'
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 Supplier {
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
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
selectedQuantity: number;
}
interface WholesalerProductsProps {
wholesaler: Wholesaler
onBack: () => void
onClose: () => void
onSupplyCreated: () => void
interface SupplierProductsProps {
supplier: Supplier;
onBack: () => void;
onClose: () => void;
onSupplyCreated: () => void;
}
// Моковые данные товаров
const mockProducts: Product[] = [
{
id: '1',
name: 'Смартфон Samsung Galaxy A54',
article: 'SGX-A54-128',
id: "1",
name: "Смартфон Samsung Galaxy A54",
article: "SGX-A54-128",
description: 'Смартфон с экраном 6.4", камерой 50 МП, 128 ГБ памяти',
price: 28900,
quantity: 150,
category: 'Смартфоны',
brand: 'Samsung',
color: 'Черный',
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'
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: 'Беспроводные наушники с шумоподавлением',
id: "2",
name: "Наушники Sony WH-1000XM4",
article: "SNY-WH1000XM4",
description: "Беспроводные наушники с шумоподавлением",
price: 24900,
quantity: 85,
category: 'Наушники',
brand: 'Sony',
color: 'Черный',
category: "Наушники",
brand: "Sony",
color: "Черный",
weight: 254,
material: 'Пластик, кожа',
images: ['/api/placeholder/300/300?text=Sony+WH1000XM4'],
mainImage: '/api/placeholder/300/300?text=Sony+WH1000XM4'
material: "Пластик, кожа",
images: ["/api/placeholder/300/300?text=Sony+WH1000XM4"],
mainImage: "/api/placeholder/300/300?text=Sony+WH1000XM4",
},
{
id: '3',
id: "3",
name: 'Планшет iPad Air 10.9"',
article: 'APL-IPADAIR-64',
description: 'Планшет Apple iPad Air с чипом M1, 64 ГБ',
article: "APL-IPADAIR-64",
description: "Планшет Apple iPad Air с чипом M1, 64 ГБ",
price: 54900,
quantity: 45,
category: 'Планшеты',
brand: 'Apple',
color: 'Серый космос',
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'
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',
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: 'Черный',
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'
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 мм',
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 мм',
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'
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: 'Беспроводная клавиатура для продуктивной работы',
id: "6",
name: "Клавиатура Logitech MX Keys",
article: "LGT-MXKEYS",
description: "Беспроводная клавиатура для продуктивной работы",
price: 8900,
quantity: 75,
category: 'Клавиатуры',
brand: 'Logitech',
color: 'Графит',
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'
}
]
dimensions: "430.2 x 20.5 x 131.6 мм",
material: "Пластик, металл",
images: ["/api/placeholder/300/300?text=MX+Keys"],
mainImage: "/api/placeholder/300/300?text=MX+Keys",
},
];
export function WholesalerProducts({ wholesaler, onBack, onClose, onSupplyCreated }: WholesalerProductsProps) {
const [selectedProducts, setSelectedProducts] = useState<SelectedProduct[]>([])
const [showSummary, setShowSummary] = useState(false)
export function SupplierProducts({
supplier,
onBack,
onClose,
onSupplyCreated,
}: SupplierProductsProps) {
const [selectedProducts, setSelectedProducts] = useState<SelectedProduct[]>(
[]
);
const [showSummary, setShowSummary] = useState(false);
const formatCurrency = (amount: number) => {
return new Intl.NumberFormat('ru-RU', {
style: 'currency',
currency: 'RUB',
minimumFractionDigits: 0
}).format(amount)
}
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
const product = mockProducts.find((p) => p.id === productId);
if (!product) return;
setSelectedProducts((prev) => {
const existing = prev.find((p) => p.id === productId);
setSelectedProducts(prev => {
const existing = prev.find(p => p.id === productId)
if (quantity === 0) {
// Удаляем продукт если количество 0
return prev.filter(p => p.id !== productId)
return prev.filter((p) => p.id !== productId);
}
if (existing) {
// Обновляем количество существующего продукта
return prev.map(p =>
return prev.map((p) =>
p.id === productId ? { ...p, selectedQuantity: quantity } : p
)
);
} else {
// Добавляем новый продукт
return [...prev, { ...product, selectedQuantity: quantity }]
return [...prev, { ...product, selectedQuantity: quantity }];
}
})
}
});
};
const getSelectedQuantity = (productId: string): number => {
const selected = selectedProducts.find(p => p.id === productId)
return selected ? selected.selectedQuantity : 0
}
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
)
}
return selectedProducts.reduce(
(sum, product) => sum + product.price * product.selectedQuantity,
0
);
};
const getTotalItems = () => {
return selectedProducts.reduce((sum, product) => sum + product.selectedQuantity, 0)
}
return selectedProducts.reduce(
(sum, product) => sum + product.selectedQuantity,
0
);
};
const handleCreateSupply = () => {
console.log('Создание поставки с товарами:', selectedProducts)
console.log("Создание поставки с товарами:", selectedProducts);
// TODO: Здесь будет реальное создание поставки
onSupplyCreated()
}
onSupplyCreated();
};
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div className="flex items-center space-x-3">
<Button
variant="ghost"
<Button
variant="ghost"
size="sm"
onClick={onBack}
className="text-white/60 hover:text-white hover:bg-white/10"
@ -233,13 +244,17 @@ export function WholesalerProducts({ wholesaler, onBack, onClose, onSupplyCreate
Назад
</Button>
<div>
<h2 className="text-2xl font-bold text-white mb-1">Товары поставщика</h2>
<p className="text-white/60">{wholesaler.name} {mockProducts.length} товаров</p>
<h2 className="text-2xl font-bold text-white mb-1">
Товары поставщика
</h2>
<p className="text-white/60">
{supplier.name} {mockProducts.length} товаров
</p>
</div>
</div>
<div className="flex items-center space-x-3">
<Button
variant="ghost"
<Button
variant="ghost"
size="sm"
onClick={() => setShowSummary(!showSummary)}
className="text-white/60 hover:text-white hover:bg-white/10"
@ -247,8 +262,8 @@ export function WholesalerProducts({ wholesaler, onBack, onClose, onSupplyCreate
<Info className="h-4 w-4 mr-2" />
Резюме ({selectedProducts.length})
</Button>
<Button
variant="ghost"
<Button
variant="ghost"
size="sm"
onClick={onClose}
className="text-white/60 hover:text-white hover:bg-white/10"
@ -260,13 +275,20 @@ export function WholesalerProducts({ wholesaler, onBack, onClose, onSupplyCreate
{showSummary && selectedProducts.length > 0 && (
<Card className="bg-purple-500/10 backdrop-blur border-purple-500/30 p-6">
<h3 className="text-white font-semibold text-lg mb-4">Резюме заказа</h3>
<h3 className="text-white font-semibold text-lg mb-4">
Резюме заказа
</h3>
<div className="space-y-3">
{selectedProducts.map((product) => (
<div key={product.id} className="flex justify-between items-center">
<div
key={product.id}
className="flex justify-between items-center"
>
<div>
<span className="text-white">{product.name}</span>
<span className="text-white/60 text-sm ml-2">× {product.selectedQuantity}</span>
<span className="text-white/60 text-sm ml-2">
× {product.selectedQuantity}
</span>
</div>
<span className="text-white font-medium">
{formatCurrency(product.price * product.selectedQuantity)}
@ -281,7 +303,7 @@ export function WholesalerProducts({ wholesaler, onBack, onClose, onSupplyCreate
{formatCurrency(getTotalAmount())}
</span>
</div>
<Button
<Button
className="w-full bg-gradient-to-r from-green-500 to-emerald-500 hover:from-green-600 hover:to-emerald-600 text-white"
onClick={handleCreateSupply}
disabled={selectedProducts.length === 0}
@ -295,13 +317,16 @@ export function WholesalerProducts({ wholesaler, onBack, onClose, onSupplyCreate
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{mockProducts.map((product) => {
const selectedQuantity = getSelectedQuantity(product.id)
const selectedQuantity = getSelectedQuantity(product.id);
return (
<Card key={product.id} className="bg-white/10 backdrop-blur border-white/20 overflow-hidden">
<Card
key={product.id}
className="bg-white/10 backdrop-blur border-white/20 overflow-hidden"
>
<div className="aspect-square relative bg-white/5">
<Image
src={product.mainImage || '/api/placeholder/300/300'}
src={product.mainImage || "/api/placeholder/300/300"}
alt={product.name}
fill
className="object-cover"
@ -312,7 +337,7 @@ export function WholesalerProducts({ wholesaler, onBack, onClose, onSupplyCreate
</Badge>
</div>
</div>
<div className="p-4 space-y-3">
<div>
<h3 className="text-white font-semibold mb-1 line-clamp-2">
@ -350,7 +375,8 @@ export function WholesalerProducts({ wholesaler, onBack, onClose, onSupplyCreate
)}
{product.weight && (
<div className="text-white/60 text-xs">
Вес: <span className="text-white">{product.weight} г</span>
Вес:{" "}
<span className="text-white">{product.weight} г</span>
</div>
)}
</div>
@ -368,7 +394,12 @@ export function WholesalerProducts({ wholesaler, onBack, onClose, onSupplyCreate
<Button
variant="ghost"
size="sm"
onClick={() => updateProductQuantity(product.id, Math.max(0, selectedQuantity - 1))}
onClick={() =>
updateProductQuantity(
product.id,
Math.max(0, selectedQuantity - 1)
)
}
disabled={selectedQuantity === 0}
className="h-8 w-8 p-0 text-white/60 hover:text-white hover:bg-white/10"
>
@ -378,8 +409,14 @@ export function WholesalerProducts({ wholesaler, onBack, onClose, onSupplyCreate
type="number"
value={selectedQuantity}
onChange={(e) => {
const value = Math.max(0, Math.min(product.quantity, parseInt(e.target.value) || 0))
updateProductQuantity(product.id, value)
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}
@ -388,7 +425,12 @@ export function WholesalerProducts({ wholesaler, onBack, onClose, onSupplyCreate
<Button
variant="ghost"
size="sm"
onClick={() => updateProductQuantity(product.id, Math.min(product.quantity, selectedQuantity + 1))}
onClick={() =>
updateProductQuantity(
product.id,
Math.min(product.quantity, selectedQuantity + 1)
)
}
disabled={selectedQuantity >= product.quantity}
className="h-8 w-8 p-0 text-white/60 hover:text-white hover:bg-white/10"
>
@ -405,21 +447,22 @@ export function WholesalerProducts({ wholesaler, onBack, onClose, onSupplyCreate
)}
</div>
</Card>
)
);
})}
</div>
{selectedProducts.length > 0 && (
<div className="fixed bottom-6 right-6">
<Button
<Button
className="bg-gradient-to-r from-purple-500 to-pink-500 hover:from-purple-600 hover:to-pink-600 text-white shadow-lg"
onClick={() => setShowSummary(!showSummary)}
>
<ShoppingCart className="h-4 w-4 mr-2" />
Корзина ({selectedProducts.length}) {formatCurrency(getTotalAmount())}
Корзина ({selectedProducts.length}) {" "}
{formatCurrency(getTotalAmount())}
</Button>
</div>
)}
</div>
)
}
);
}

View File

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

View File

@ -1,44 +1,39 @@
"use client"
"use client";
import React from 'react'
import { Button } from '@/components/ui/button'
import {
ArrowLeft,
ShoppingCart,
Users,
Check
} from 'lucide-react'
import React from "react";
import { Button } from "@/components/ui/button";
import { ArrowLeft, ShoppingCart, Users, Check } from "lucide-react";
interface TabsHeaderProps {
activeTab: 'cards' | 'wholesaler'
onTabChange: (tab: 'cards' | 'wholesaler') => void
onBack: () => void
activeTab: "cards" | "wholesaler";
onTabChange: (tab: "cards" | "wholesaler") => void;
onBack: () => void;
cartInfo?: {
itemCount: number
totalAmount: number
formatCurrency: (amount: number) => string
}
onCartClick?: () => void
onCreateSupply?: () => void
canCreateSupply?: boolean
isCreatingSupply?: boolean
itemCount: number;
totalAmount: number;
formatCurrency: (amount: number) => string;
};
onCartClick?: () => void;
onCreateSupply?: () => void;
canCreateSupply?: boolean;
isCreatingSupply?: boolean;
}
export function TabsHeader({
activeTab,
onTabChange,
onBack,
export function TabsHeader({
activeTab,
onTabChange,
onBack,
cartInfo,
onCartClick,
onCreateSupply,
canCreateSupply = false,
isCreatingSupply = false
isCreatingSupply = false,
}: TabsHeaderProps) {
return (
<div className="flex items-center justify-between mb-4">
<div className="flex items-center space-x-4">
<Button
variant="ghost"
<Button
variant="ghost"
size="sm"
onClick={onBack}
className="text-white/60 hover:text-white hover:bg-white/10"
@ -47,58 +42,59 @@ export function TabsHeader({
Назад
</Button>
<h1 className="text-2xl font-bold text-white">Создание поставки</h1>
{/* Кнопка корзины */}
{cartInfo && cartInfo.itemCount > 0 && onCartClick && (
<Button
<Button
onClick={onCartClick}
className="bg-gradient-to-r from-purple-500 to-pink-500 hover:from-purple-600 hover:to-pink-600 text-white"
>
<ShoppingCart className="h-4 w-4 mr-2" />
Корзина ({cartInfo.itemCount})
{activeTab === 'wholesaler' && `${cartInfo.formatCurrency(cartInfo.totalAmount)}`}
{activeTab === "supplier" &&
`${cartInfo.formatCurrency(cartInfo.totalAmount)}`}
</Button>
)}
{/* Кнопка создания поставки для таба карточек */}
{activeTab === 'cards' && onCreateSupply && (
<Button
{activeTab === "cards" && onCreateSupply && (
<Button
onClick={onCreateSupply}
disabled={!canCreateSupply || isCreatingSupply}
className="bg-white/20 hover:bg-white/30 text-white border-0"
>
<Check className="h-4 w-4 mr-2" />
{isCreatingSupply ? 'Создание...' : 'Создать поставку'}
{isCreatingSupply ? "Создание..." : "Создать поставку"}
</Button>
)}
</div>
<div>
<div className="grid grid-cols-2 bg-white/10 backdrop-blur border border-white/20 rounded-lg p-1">
<button
onClick={() => onTabChange('cards')}
onClick={() => onTabChange("cards")}
className={`px-4 py-2 text-sm rounded transition-all ${
activeTab === 'cards'
? 'bg-white/20 text-white'
: 'text-white/60 hover:text-white hover:bg-white/10'
activeTab === "cards"
? "bg-white/20 text-white"
: "text-white/60 hover:text-white hover:bg-white/10"
}`}
>
<ShoppingCart className="h-4 w-4 mr-1 inline" />
Карточки
</button>
<button
onClick={() => onTabChange('wholesaler')}
onClick={() => onTabChange("wholesaler")}
className={`px-4 py-2 text-sm rounded transition-all ${
activeTab === 'wholesaler'
? 'bg-white/20 text-white'
: 'text-white/60 hover:text-white hover:bg-white/10'
activeTab === "wholesaler"
? "bg-white/20 text-white"
: "text-white/60 hover:text-white hover:bg-white/10"
}`}
>
<Users className="h-4 w-4 mr-1 inline" />
Поставщики
Поставщики
</button>
</div>
</div>
</div>
)
}
);
}

View File

@ -1,50 +1,50 @@
export interface WholesalerForCreation {
id: string
inn: string
name: string
fullName: string
address: string
phone?: string
email?: string
rating: number
productCount: number
avatar?: string
specialization: string[]
export interface SupplierForCreation {
id: string;
inn: string;
name: string;
fullName: string;
address: string;
phone?: string;
email?: string;
rating: number;
productCount: number;
avatar?: string;
specialization: string[];
}
export 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
export interface SupplierProduct {
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;
}
export interface SelectedProduct extends WholesalerProduct {
selectedQuantity: number
wholesalerId: string
wholesalerName: string
export interface SelectedProduct extends SupplierProduct {
selectedQuantity: number;
supplierId: string;
supplierName: string;
}
export interface CounterpartyWholesaler {
id: string
inn?: string
name?: string
fullName?: string
address?: string
phones?: { value: string }[]
emails?: { value: string }[]
}
export interface CounterpartySupplier {
id: string;
inn?: string;
name?: string;
fullName?: string;
address?: string;
phones?: { value: string }[];
emails?: { value: string }[];
}

View File

@ -1,112 +0,0 @@
"use client"
import React from 'react'
import { WholesalerCard } from './wholesaler-card'
import { Input } from '@/components/ui/input'
import { Users, Search } from 'lucide-react'
import { WholesalerForCreation, CounterpartyWholesaler } from './types'
interface WholesalerGridProps {
wholesalers: CounterpartyWholesaler[]
onWholesalerSelect: (wholesaler: WholesalerForCreation) => void
searchQuery: string
onSearchChange: (query: string) => void
loading?: boolean
}
export function WholesalerGrid({
wholesalers,
onWholesalerSelect,
searchQuery,
onSearchChange,
loading = false
}: WholesalerGridProps) {
// Фильтруем поставщиков по поисковому запросу
const filteredWholesalers = wholesalers.filter((wholesaler) =>
wholesaler.name?.toLowerCase().includes(searchQuery.toLowerCase()) ||
wholesaler.fullName?.toLowerCase().includes(searchQuery.toLowerCase()) ||
wholesaler.inn?.toLowerCase().includes(searchQuery.toLowerCase())
)
const handleWholesalerClick = (wholesaler: CounterpartyWholesaler) => {
// Адаптируем данные под существующий интерфейс
const adaptedWholesaler: WholesalerForCreation = {
id: wholesaler.id,
inn: wholesaler.inn || '',
name: wholesaler.name || 'Неизвестная организация',
fullName: wholesaler.fullName || wholesaler.name || 'Неизвестная организация',
address: wholesaler.address || 'Адрес не указан',
phone: wholesaler.phones?.[0]?.value,
email: wholesaler.emails?.[0]?.value,
rating: 4.5, // Временное значение
productCount: 0, // Временное значение
specialization: ['Оптовая торговля'] // Временное значение
}
onWholesalerSelect(adaptedWholesaler)
}
if (loading) {
return (
<div className="flex items-center justify-center p-8">
<div className="text-center">
<div className="animate-spin rounded-full h-12 w-12 border-4 border-white border-t-transparent mx-auto mb-4"></div>
<p className="text-white/60">Загружаем поставщиков...</p>
</div>
</div>
)
}
return (
<div>
{/* Поиск */}
<div className="mb-4">
<div className="relative max-w-md">
<Search className="absolute left-3 top-3 h-4 w-4 text-white/40" />
<Input
placeholder="Поиск поставщиков..."
value={searchQuery}
onChange={(e) => onSearchChange(e.target.value)}
className="pl-10 glass-input text-white placeholder:text-white/40 h-10"
/>
</div>
</div>
{filteredWholesalers.length === 0 ? (
<div className="text-center p-8">
<Users className="h-12 w-12 text-white/20 mx-auto mb-4" />
<p className="text-white/60">
{searchQuery ? 'Поставщики не найдены' : 'У вас нет контрагентов-поставщиков'}
</p>
<p className="text-white/40 text-sm mt-2">
{searchQuery ? 'Попробуйте изменить условия поиска' : 'Добавьте поставщиков в разделе "Партнеры"'}
</p>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
{filteredWholesalers.map((wholesaler) => {
const adaptedWholesaler: WholesalerForCreation = {
id: wholesaler.id,
inn: wholesaler.inn || '',
name: wholesaler.name || 'Неизвестная организация',
fullName: wholesaler.fullName || wholesaler.name || 'Неизвестная организация',
address: wholesaler.address || 'Адрес не указан',
phone: wholesaler.phones?.[0]?.value,
email: wholesaler.emails?.[0]?.value,
rating: 4.5,
productCount: 0,
specialization: ['Оптовая торговля']
}
return (
<WholesalerCard
key={wholesaler.id}
wholesaler={adaptedWholesaler}
onClick={() => handleWholesalerClick(wholesaler)}
/>
)
})}
</div>
)}
</div>
)
}