diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 490b555..2a03fdc 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -208,6 +208,7 @@ model Supply { supplier String @default("Не указан") minStock Int @default(0) currentStock Int @default(0) + usedStock Int @default(0) // Количество использованных расходников imageUrl String? createdAt DateTime @default(now()) updatedAt DateTime @updatedAt diff --git a/src/app/track/[linkId]/route.ts b/src/app/track/[linkId]/route.ts index d632902..52d5310 100644 --- a/src/app/track/[linkId]/route.ts +++ b/src/app/track/[linkId]/route.ts @@ -1,34 +1,33 @@ -import { NextRequest, NextResponse } from 'next/server' -import { clickStorage } from '@/lib/click-storage' +import { NextRequest, NextResponse } from "next/server"; +import { clickStorage } from "@/lib/click-storage"; export async function GET( request: NextRequest, { params }: { params: { linkId: string } } ) { - const { linkId } = params - + const { linkId } = params; + try { // Получаем целевую ссылку из параметров - const redirectUrl = request.nextUrl.searchParams.get('redirect') - + const redirectUrl = request.nextUrl.searchParams.get("redirect"); + if (!redirectUrl) { - console.error(`No redirect URL for link: ${linkId}`) - return NextResponse.redirect(new URL('/', request.url)) + console.error(`No redirect URL for link: ${linkId}`); + return NextResponse.redirect(new URL("/", request.url)); } - + // Декодируем URL - const decodedUrl = decodeURIComponent(redirectUrl) - + const decodedUrl = decodeURIComponent(redirectUrl); + // Записываем клик через общий storage - const totalClicks = clickStorage.recordClick(linkId) - - console.log(`Redirect: ${linkId} -> ${decodedUrl} (click #${totalClicks})`) - + const totalClicks = clickStorage.recordClick(linkId); + + console.log(`Redirect: ${linkId} -> ${decodedUrl} (click #${totalClicks})`); + // Мгновенный серверный редирект на целевую ссылку - return NextResponse.redirect(decodedUrl) - + return NextResponse.redirect(decodedUrl); } catch (error) { - console.error('Error processing tracking link:', error) - return NextResponse.redirect(new URL('/', request.url)) + console.error("Error processing tracking link:", error); + return NextResponse.redirect(new URL("/", request.url)); } -} \ No newline at end of file +} diff --git a/src/components/admin/ui-kit/fulfillment-warehouse-2-demo.tsx b/src/components/admin/ui-kit/fulfillment-warehouse-2-demo.tsx index ff95964..5c98349 100644 --- a/src/components/admin/ui-kit/fulfillment-warehouse-2-demo.tsx +++ b/src/components/admin/ui-kit/fulfillment-warehouse-2-demo.tsx @@ -359,7 +359,7 @@ export function FulfillmentWarehouse2Demo() { description="К обработке" /> (null) - const [partnerLink, setPartnerLink] = useState('') - const [isGenerating, setIsGenerating] = useState(false) - const [isUploadingAvatar, setIsUploadingAvatar] = useState(false) - const [localAvatarUrl, setLocalAvatarUrl] = useState(null) - const phoneInputRef = useRef(null) - const whatsappInputRef = useRef(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(null); + const phoneInputRef = useRef(null); + const whatsappInputRef = useRef(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) => { - const file = event.target.files?.[0] - if (!file || !user?.id) return + const handleAvatarUpload = async ( + event: React.ChangeEvent + ) => { + 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, 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, + 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 (
-
+
{/* Сообщения о сохранении */} {saveMessage && ( - - + + {saveMessage.text} @@ -686,33 +809,57 @@ export function UserSettings() { {/* Основной контент с вкладками - заполняет оставшееся пространство */}
- - + + Профиль - + Организация - {(user?.organization?.type === 'FULFILLMENT' || user?.organization?.type === 'LOGIST' || user?.organization?.type === 'WHOLESALE' || user?.organization?.type === 'SELLER') && ( - + {(user?.organization?.type === "FULFILLMENT" || + user?.organization?.type === "LOGIST" || + user?.organization?.type === "WHOLESALE" || + user?.organization?.type === "SELLER") && ( + Финансы )} - {user?.organization?.type === 'SELLER' && ( - + {user?.organization?.type === "SELLER" && ( + API )} - {user?.organization?.type !== 'SELLER' && ( - + {user?.organization?.type !== "SELLER" && ( + Инструменты @@ -727,15 +874,21 @@ export function UserSettings() {
-

Профиль пользователя

-

Личная информация и контактные данные

+

+ Профиль пользователя +

+

+ Личная информация и контактные данные +

{/* Компактный индикатор прогресса */}
- {profileStatus.percentage}% + + {profileStatus.percentage}% +
{isIncomplete ? ( @@ -745,31 +898,33 @@ export function UserSettings() { )}
- + {isEditing ? ( <> - - ) : ( - - ) : ( -
- + {/* Общая подпись про реестр */}

- При сохранении с измененным ИНН мы автоматически обновляем все остальные данные из федерального реестра + При сохранении с измененным ИНН мы автоматически обновляем + все остальные данные из федерального реестра

@@ -1031,30 +1241,48 @@ export function UserSettings() {
- handleInputChange('orgName', e.target.value)} - placeholder={user?.organization?.type === 'SELLER' ? 'Название магазина' : 'Название организации'} + + 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" ? (

- Название устанавливается при регистрации кабинета и не может быть изменено. + Название устанавливается при регистрации кабинета и + не может быть изменено.

) : (

- Автоматически заполняется из федерального реестра при указании ИНН. + Автоматически заполняется из федерального реестра + при указании ИНН.

)}
- - + Полное название + + @@ -1068,19 +1296,30 @@ export function UserSettings() { Адрес - handleInputChange('address', e.target.value)} + + 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" />
- - + Полный юридический адрес + + @@ -1096,40 +1335,48 @@ export function UserSettings() { )} - { - 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) && (

- {getFieldError('inn', formData.inn)} + {getFieldError("inn", formData.inn)}

)}
- - + ОГРН + +
- - + КПП + + @@ -1139,24 +1386,35 @@ export function UserSettings() { {/* Руководитель и статус */}
- - + Руководитель организации + +

- {user?.organization?.managementName - ? 'Данные из федерального реестра' - : 'Автоматически заполняется из федерального реестра при указании ИНН'} + {user?.organization?.managementName + ? "Данные из федерального реестра" + : "Автоматически заполняется из федерального реестра при указании ИНН"}

- - + Статус организации + + @@ -1171,8 +1429,10 @@ export function UserSettings() { Дата регистрации - @@ -1183,53 +1443,68 @@ export function UserSettings() { - - {/* Финансовые данные */} - {(user?.organization?.type === 'FULFILLMENT' || user?.organization?.type === 'LOGIST' || user?.organization?.type === 'WHOLESALE' || user?.organization?.type === 'SELLER') && ( - + {(user?.organization?.type === "FULFILLMENT" || + user?.organization?.type === "LOGIST" || + user?.organization?.type === "WHOLESALE" || + user?.organization?.type === "SELLER") && ( + {/* Заголовок вкладки с кнопками */}
-

Финансовые данные

-

Банковские реквизиты для расчетов

+

+ Финансовые данные +

+

+ Банковские реквизиты для расчетов +

- {formData.bankName && formData.bik && formData.accountNumber && formData.corrAccount && ( -
- - Заполнено -
- )} - + {formData.bankName && + formData.bik && + formData.accountNumber && + formData.corrAccount && ( +
+ + + Заполнено + +
+ )} + {isEditing ? ( <> - - ) : ( - - ) : ( -
- + {partnerLink && (
- -

- Ваша партнерская ссылка сгенерирована и готова к использованию + Ваша партнерская ссылка сгенерирована и готова к + использованию

)} @@ -1460,5 +1813,5 @@ export function UserSettings() {
- ) -} \ No newline at end of file + ); +} diff --git a/src/components/fulfillment-supplies/create-fulfillment-consumables-supply-page.tsx b/src/components/fulfillment-supplies/create-fulfillment-consumables-supply-page.tsx index 72b9036..ddab942 100644 --- a/src/components/fulfillment-supplies/create-fulfillment-consumables-supply-page.tsx +++ b/src/components/fulfillment-supplies/create-fulfillment-consumables-supply-page.tsx @@ -248,7 +248,7 @@ export function CreateFulfillmentConsumablesSupplyPage() { setProductSearchQuery(""); setSearchQuery(""); - // Перенаправляем на страницу поставок фулфилмента с активной вкладкой "Наши расходники" + // Перенаправляем на страницу поставок фулфилмента с активной вкладкой "Расходники фулфилмента" router.push("/fulfillment-supplies?tab=detailed-supplies"); } else { toast.error( diff --git a/src/components/fulfillment-supplies/fulfillment-supplies/fulfillment-consumables-orders-tab.tsx b/src/components/fulfillment-supplies/fulfillment-supplies/fulfillment-consumables-orders-tab.tsx index 9d16e06..6a923da 100644 --- a/src/components/fulfillment-supplies/fulfillment-supplies/fulfillment-consumables-orders-tab.tsx +++ b/src/components/fulfillment-supplies/fulfillment-supplies/fulfillment-consumables-orders-tab.tsx @@ -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) => { diff --git a/src/components/fulfillment-supplies/fulfillment-supplies/fulfillment-detailed-goods-tab.tsx b/src/components/fulfillment-supplies/fulfillment-supplies/fulfillment-detailed-goods-tab.tsx index 281ecce..0330378 100644 --- a/src/components/fulfillment-supplies/fulfillment-supplies/fulfillment-detailed-goods-tab.tsx +++ b/src/components/fulfillment-supplies/fulfillment-supplies/fulfillment-detailed-goods-tab.tsx @@ -126,7 +126,7 @@ const mockFulfillmentGoodsSupplies: FulfillmentSupply[] = [ value: "12", unit: "мес", }, - { id: "ffparam4", name: "Расходники ФФ", value: "Усиленная" }, + { id: "ffparam4", name: "Расходники фулфилмента", value: "Усиленная" }, ], }, ], diff --git a/src/components/fulfillment-supplies/fulfillment-supplies/fulfillment-detailed-supplies-tab.tsx b/src/components/fulfillment-supplies/fulfillment-supplies/fulfillment-detailed-supplies-tab.tsx index 731de1c..160cba6 100644 --- a/src/components/fulfillment-supplies/fulfillment-supplies/fulfillment-detailed-supplies-tab.tsx +++ b/src/components/fulfillment-supplies/fulfillment-supplies/fulfillment-detailed-supplies-tab.tsx @@ -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() { {/* Заголовок с кнопкой создания поставки */}
-

Наши расходники

+

Расходники фулфилмента

Поставки расходников, поступающие на склад фулфилмент-центра

diff --git a/src/components/fulfillment-supplies/fulfillment-supplies/fulfillment-supplies-tab.tsx b/src/components/fulfillment-supplies/fulfillment-supplies/fulfillment-supplies-tab.tsx index d98f3cf..2b72bcd 100644 --- a/src/components/fulfillment-supplies/fulfillment-supplies/fulfillment-supplies-tab.tsx +++ b/src/components/fulfillment-supplies/fulfillment-supplies/fulfillment-supplies-tab.tsx @@ -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" > - Наши расходники - Наши - Н + Расходники фулфилмента + Фулфилмент + Ф { - 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(); diff --git a/src/components/fulfillment-warehouse/fulfillment-warehouse-dashboard.tsx b/src/components/fulfillment-warehouse/fulfillment-warehouse-dashboard.tsx index 5238e29..19a9d9b 100644 --- a/src/components/fulfillment-warehouse/fulfillment-warehouse-dashboard.tsx +++ b/src/components/fulfillment-warehouse/fulfillment-warehouse-dashboard.tsx @@ -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="К обработке" /> - 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 (
@@ -71,14 +78,14 @@ export function MarketWholesale() {
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" />
-
- ) -} \ No newline at end of file + ); +} diff --git a/src/components/partners/partners-dashboard.tsx b/src/components/partners/partners-dashboard.tsx index 085f8aa..2e3c2be 100644 --- a/src/components/partners/partners-dashboard.tsx +++ b/src/components/partners/partners-dashboard.tsx @@ -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 (
-
+
{/* Основной контент с табами */}
- - - + + Мои контрагенты {hasIncomingRequests && (
)}
- Фулфилмент - Селлеры - Логистика - Поставщик
- + - + - + - + - + - +
@@ -103,5 +129,5 @@ export function PartnersDashboard() {
- ) -} \ No newline at end of file + ); +} diff --git a/src/components/supplies/cart-summary.tsx b/src/components/supplies/cart-summary.tsx index 53cf5dc..eff6f6f 100644 --- a/src/components/supplies/cart-summary.tsx +++ b/src/components/supplies/cart-summary.tsx @@ -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) + acc[product.supplierId].products.push(product); + return acc; + }, {} as Record); 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 ( @@ -72,7 +73,8 @@ export function CartSummary({

Корзина

- {selectedProducts.length} товаров от {Object.keys(groupedProducts).length} поставщиков + {selectedProducts.length} товаров от{" "} + {Object.keys(groupedProducts).length} поставщиков

@@ -85,74 +87,99 @@ export function CartSummary({
- + {/* Группировка по поставщикам */} - {Object.entries(groupedProducts).map(([wholesalerId, group]) => ( -
+ {Object.entries(groupedProducts).map(([supplierId, group]) => ( +
- {group.wholesaler} + {group.supplier} {group.products.length} товар(ов)
- +
{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 ( -
+
{product.name}
-

{product.name}

-

{product.article}

+

+ {product.name} +

+

+ {product.article} +

- {product.selectedQuantity} + + {product.selectedQuantity} +
-
{formatCurrency(totalPrice)}
+
+ {formatCurrency(totalPrice)} +
{product.discount && (
- {formatCurrency(product.price * product.selectedQuantity)} + {formatCurrency( + product.price * product.selectedQuantity + )}
)}
@@ -161,18 +188,20 @@ export function CartSummary({
- ) + ); })}
))} - + {/* Итого */}
@@ -184,7 +213,7 @@ export function CartSummary({
- -
- ) -} \ No newline at end of file + ); +} diff --git a/src/components/supplies/direct-supply-creation.tsx b/src/components/supplies/direct-supply-creation.tsx index 50addbc..f27853a 100644 --- a/src/components/supplies/direct-supply-creation.tsx +++ b/src/components/supplies/direct-supply-creation.tsx @@ -1343,13 +1343,13 @@ export function DirectSupplyCreation({ Цена
- Услуги фф + Услуги фулфилмента
Поставщик
- Расходники фф + Расходники фулфилмента
Расходники @@ -1654,7 +1654,7 @@ export function DirectSupplyCreation({
- {/* Блок 7: Расходники фф */} + {/* Блок 7: Расходники фулфилмента */}
{/* DEBUG для расходников */} diff --git a/src/components/supplies/fulfillment-supplies/fulfillment-supplies-sub-tab.tsx b/src/components/supplies/fulfillment-supplies/fulfillment-supplies-sub-tab.tsx index 1d41e8d..6657790 100644 --- a/src/components/supplies/fulfillment-supplies/fulfillment-supplies-sub-tab.tsx +++ b/src/components/supplies/fulfillment-supplies/fulfillment-supplies-sub-tab.tsx @@ -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 (
- {/* Статистика расходников ФФ */} + {/* Статистика расходников фулфилмента */} sum + supply.grandTotal, @@ -324,7 +324,7 @@ export function FulfillmentSuppliesTab() { /> - {/* Таблица поставок расходников ФФ */} + {/* Таблица поставок расходников фулфилмента */}
@@ -359,7 +359,7 @@ export function FulfillmentSuppliesTab() { return ( - {/* Основная строка поставки расходников ФФ */} + {/* Основная строка поставки расходников фулфилмента */} toggleSupplyExpansion(supply.id)} diff --git a/src/components/supplies/fulfillment-supplies/real-supply-orders-tab.tsx b/src/components/supplies/fulfillment-supplies/real-supply-orders-tab.tsx index 62f5896..232f8a8 100644 --- a/src/components/supplies/fulfillment-supplies/real-supply-orders-tab.tsx +++ b/src/components/supplies/fulfillment-supplies/real-supply-orders-tab.tsx @@ -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: { diff --git a/src/components/supplies/goods-supplies/goods-supplies-tab.tsx b/src/components/supplies/goods-supplies/goods-supplies-tab.tsx index 11cad2b..d49e025 100644 --- a/src/components/supplies/goods-supplies/goods-supplies-tab.tsx +++ b/src/components/supplies/goods-supplies/goods-supplies-tab.tsx @@ -36,7 +36,7 @@ interface Product { parameters: ProductParameter[]; } -interface Wholesaler { +interface Supplier { id: string; name: string; inn: string; diff --git a/src/components/supplies/wholesaler-card.tsx b/src/components/supplies/supplier-card.tsx similarity index 53% rename from src/components/supplies/wholesaler-card.tsx rename to src/components/supplies/supplier-card.tsx index d79aa26..c089ec5 100644 --- a/src/components/supplies/wholesaler-card.tsx +++ b/src/components/supplies/supplier-card.tsx @@ -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 ( - @@ -29,41 +24,46 @@ export function WholesalerCard({ wholesaler, onClick }: WholesalerCardProps) {

- {wholesaler.name} + {supplier.name}

- {wholesaler.fullName} -

-

- ИНН: {wholesaler.inn} + {supplier.fullName}

+

ИНН: {supplier.inn}

- {wholesaler.address} + + {supplier.address} +
- - {wholesaler.phone && ( + + {supplier.phone && (
- {wholesaler.phone} + {supplier.phone}
)} - {wholesaler.email && ( + {supplier.email && (
- {wholesaler.email} + + {supplier.email} +
)}
- {wholesaler.specialization.map((spec, index) => ( - + {supplier.specialization.map((spec, index) => ( + {spec} ))} @@ -71,8 +71,12 @@ export function WholesalerCard({ wholesaler, onClick }: WholesalerCardProps) {
-

Товаров: {wholesaler.productCount}

-

Рейтинг: {wholesaler.rating}/5

+

+ Товаров: {supplier.productCount} +

+

+ Рейтинг: {supplier.rating}/5 +

Контрагент @@ -80,5 +84,5 @@ export function WholesalerCard({ wholesaler, onClick }: WholesalerCardProps) {
- ) -} \ No newline at end of file + ); +} diff --git a/src/components/supplies/supplier-grid.tsx b/src/components/supplies/supplier-grid.tsx new file mode 100644 index 0000000..273024b --- /dev/null +++ b/src/components/supplies/supplier-grid.tsx @@ -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 ( +
+
+
+

Загружаем поставщиков...

+
+
+ ); + } + + return ( +
+ {/* Поиск */} +
+
+ + onSearchChange(e.target.value)} + className="pl-10 glass-input text-white placeholder:text-white/40 h-10" + /> +
+
+ + {filteredSuppliers.length === 0 ? ( +
+ +

+ {searchQuery + ? "Поставщики не найдены" + : "У вас нет контрагентов-поставщиков"} +

+

+ {searchQuery + ? "Попробуйте изменить условия поиска" + : 'Добавьте поставщиков в разделе "Партнеры"'} +

+
+ ) : ( +
+ {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 ( + handleSupplierClick(supplier)} + /> + ); + })} +
+ )} +
+ ); +} diff --git a/src/components/supplies/wholesaler-products-page.tsx b/src/components/supplies/supplier-products-page.tsx similarity index 52% rename from src/components/supplies/wholesaler-products-page.tsx rename to src/components/supplies/supplier-products-page.tsx index 80533f1..68e5392 100644 --- a/src/components/supplies/wholesaler-products-page.tsx +++ b/src/components/supplies/supplier-products-page.tsx @@ -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) + acc[product.id] = getSelectedQuantity(product.id); + return acc; + }, {} as Record); 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 (
-
+
-
-
- ) -} \ No newline at end of file + ); +} diff --git a/src/components/supplies/wholesaler-products.tsx b/src/components/supplies/supplier-products.tsx similarity index 56% rename from src/components/supplies/wholesaler-products.tsx rename to src/components/supplies/supplier-products.tsx index a11e4a9..08ce347 100644 --- a/src/components/supplies/wholesaler-products.tsx +++ b/src/components/supplies/supplier-products.tsx @@ -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([]) - const [showSummary, setShowSummary] = useState(false) +export function SupplierProducts({ + supplier, + onBack, + onClose, + onSupplyCreated, +}: SupplierProductsProps) { + const [selectedProducts, setSelectedProducts] = useState( + [] + ); + const [showSummary, setShowSummary] = useState(false); const formatCurrency = (amount: number) => { - return new Intl.NumberFormat('ru-RU', { - style: 'currency', - currency: 'RUB', - minimumFractionDigits: 0 - }).format(amount) - } + 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 (
-
- -
- +

@@ -350,7 +375,8 @@ export function WholesalerProducts({ wholesaler, onBack, onClose, onSupplyCreate )} {product.weight && (
- Вес: {product.weight} г + Вес:{" "} + {product.weight} г
)}

@@ -368,7 +394,12 @@ export function WholesalerProducts({ wholesaler, onBack, onClose, onSupplyCreate
- ) + ); })}
{selectedProducts.length > 0 && (
-
)}
- ) -} \ No newline at end of file + ); +} diff --git a/src/components/supplies/wholesaler-selection.tsx b/src/components/supplies/supplier-selection.tsx similarity index 51% rename from src/components/supplies/wholesaler-selection.tsx rename to src/components/supplies/supplier-selection.tsx index 2f3e9e6..7c22de4 100644 --- a/src/components/supplies/wholesaler-selection.tsx +++ b/src/components/supplies/supplier-selection.tsx @@ -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(null) +export function SupplierSelection({ + onBack, + onClose, + onSupplyCreated, +}: SupplierSelectionProps) { + const [selectedSupplier, setSelectedSupplier] = useState( + null + ); - if (selectedWholesaler) { + if (selectedSupplier) { return (
-
-

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

-

{selectedWholesaler.name}

+

+ Товары поставщика +

+

{selectedSupplier.name}

@@ -138,24 +146,28 @@ export function WholesalerSelection({ onBack, onClose, onSupplyCreated }: Wholes

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

- ) + ); } const renderStars = (rating: number) => { return Array.from({ length: 5 }, (_, i) => ( - - )) - } + )); + }; return (
-
-
- ) -} \ No newline at end of file + ); +} diff --git a/src/components/supplies/tabs-header.tsx b/src/components/supplies/tabs-header.tsx index e7b4359..db7cdc8 100644 --- a/src/components/supplies/tabs-header.tsx +++ b/src/components/supplies/tabs-header.tsx @@ -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 (
- )} {/* Кнопка создания поставки для таба карточек */} - {activeTab === 'cards' && onCreateSupply && ( - )}
- +
- ) -} \ No newline at end of file + ); +} diff --git a/src/components/supplies/types.ts b/src/components/supplies/types.ts index c862f3f..acad50f 100644 --- a/src/components/supplies/types.ts +++ b/src/components/supplies/types.ts @@ -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 }[] -} \ No newline at end of file +export interface CounterpartySupplier { + id: string; + inn?: string; + name?: string; + fullName?: string; + address?: string; + phones?: { value: string }[]; + emails?: { value: string }[]; +} diff --git a/src/components/supplies/wholesaler-grid.tsx b/src/components/supplies/wholesaler-grid.tsx deleted file mode 100644 index 9bf19f6..0000000 --- a/src/components/supplies/wholesaler-grid.tsx +++ /dev/null @@ -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 ( -
-
-
-

Загружаем поставщиков...

-
-
- ) - } - - return ( -
- {/* Поиск */} -
-
- - onSearchChange(e.target.value)} - className="pl-10 glass-input text-white placeholder:text-white/40 h-10" - /> -
-
- - {filteredWholesalers.length === 0 ? ( -
- -

- {searchQuery ? 'Поставщики не найдены' : 'У вас нет контрагентов-поставщиков'} -

-

- {searchQuery ? 'Попробуйте изменить условия поиска' : 'Добавьте поставщиков в разделе "Партнеры"'} -

-
- ) : ( -
- {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 ( - handleWholesalerClick(wholesaler)} - /> - ) - })} -
- )} -
- ) -} \ No newline at end of file diff --git a/src/graphql/queries.ts b/src/graphql/queries.ts index 9ccd61d..6e1e061 100644 --- a/src/graphql/queries.ts +++ b/src/graphql/queries.ts @@ -80,6 +80,30 @@ export const GET_MY_SUPPLIES = gql` supplier minStock currentStock + usedStock + imageUrl + createdAt + updatedAt + } + } +`; + +export const GET_MY_FULFILLMENT_SUPPLIES = gql` + query GetMyFulfillmentSupplies { + myFulfillmentSupplies { + id + name + description + price + quantity + unit + category + status + date + supplier + minStock + currentStock + usedStock imageUrl createdAt updatedAt @@ -848,7 +872,7 @@ export const GET_EXTERNAL_ADS = gql` } } } -` +`; // Админ запросы export const ADMIN_ME = gql` @@ -950,6 +974,7 @@ export const GET_PENDING_SUPPLIES_COUNT = gql` supplyOrders ourSupplyOrders sellerSupplyOrders + incomingSupplierOrders incomingRequests total } diff --git a/src/graphql/resolvers.ts b/src/graphql/resolvers.ts index 8f23b3d..636e151 100644 --- a/src/graphql/resolvers.ts +++ b/src/graphql/resolvers.ts @@ -691,7 +691,7 @@ export const resolvers = { }); }, - // Мои расходники (объединенные данные из supply и supplyOrder) + // Расходники селлеров (материалы клиентов на складе фулфилмента) mySupplies: async (_: unknown, __: unknown, context: Context) => { if (!context.user) { throw new GraphQLError("Требуется авторизация", { @@ -708,21 +708,86 @@ export const resolvers = { throw new GraphQLError("У пользователя нет организации"); } - // Получаем расходники из таблицы supply (уже доставленные) + // Получаем заказы поставок, где фулфилмент является получателем, + // но НЕ создателем (т.е. селлеры заказали расходники для фулфилмента) + const sellerSupplyOrders = await prisma.supplyOrder.findMany({ + where: { + fulfillmentCenterId: currentUser.organization.id, // Получатель - мы + organizationId: { not: currentUser.organization.id }, // Создатель - НЕ мы + status: "DELIVERED", // Только доставленные + }, + include: { + organization: true, + partner: true, + items: { + include: { + product: { + include: { + category: true, + }, + }, + }, + }, + }, + }); + + // Получаем расходники селлеров из таблицы supply + // Это расходники, созданные при доставке заказов от селлеров const existingSupplies = await prisma.supply.findMany({ where: { organizationId: currentUser.organization.id }, include: { organization: true }, orderBy: { createdAt: "desc" }, }); + // Логирование для отладки + console.log("🔥🔥🔥 SELLER SUPPLIES RESOLVER CALLED 🔥🔥🔥"); + console.log("📊 Расходники селлеров:", { + organizationId: currentUser.organization.id, + organizationType: currentUser.organization.type, + existingSuppliesCount: existingSupplies.length, + sellerOrdersCount: sellerSupplyOrders.length, + sellerOrders: sellerSupplyOrders.map((o) => ({ + id: o.id, + sellerName: o.organization.name, + supplierName: o.partner.name, + status: o.status, + itemsCount: o.items.length, + })), + }); + + // Возвращаем только расходники селлеров из таблицы supply + // TODO: В будущем можно добавить фильтрацию по источнику заказа + return existingSupplies; + }, + + // Расходники фулфилмента (материалы для работы фулфилмента) + myFulfillmentSupplies: async ( + _: unknown, + __: unknown, + context: Context + ) => { + if (!context.user) { + throw new GraphQLError("Требуется авторизация", { + extensions: { code: "UNAUTHENTICATED" }, + }); + } + + const currentUser = await prisma.user.findUnique({ + where: { id: context.user.id }, + include: { organization: true }, + }); + + if (!currentUser?.organization) { + throw new GraphQLError("У пользователя нет организации"); + } + // Получаем заказы поставок, созданные этим фулфилмент-центром для себя - // Показываем только заказы, которые еще не доставлены - const ourSupplyOrders = await prisma.supplyOrder.findMany({ + const fulfillmentSupplyOrders = await prisma.supplyOrder.findMany({ where: { organizationId: currentUser.organization.id, // Создали мы fulfillmentCenterId: currentUser.organization.id, // Получатель - мы status: { - in: ["PENDING", "CONFIRMED", "IN_TRANSIT"], // Только не доставленные заказы + in: ["PENDING", "CONFIRMED", "IN_TRANSIT", "DELIVERED"], // Все статусы }, }, include: { @@ -741,16 +806,16 @@ export const resolvers = { }); // Преобразуем заказы поставок в формат supply для единообразия - const suppliesFromOrders = ourSupplyOrders.flatMap((order) => + const fulfillmentSupplies = fulfillmentSupplyOrders.flatMap((order) => order.items.map((item) => ({ - id: `order-${order.id}-${item.id}`, + id: `fulfillment-order-${order.id}-${item.id}`, name: item.product.name, description: - item.product.description || `Заказ от ${order.partner.name}`, + item.product.description || `Расходники от ${order.partner.name}`, price: item.price, quantity: item.quantity, unit: "шт", - category: item.product.category?.name || "Расходники", + category: item.product.category?.name || "Расходники фулфилмента", status: order.status === "PENDING" ? "in-transit" @@ -763,6 +828,7 @@ export const resolvers = { supplier: order.partner.name || order.partner.fullName || "Не указан", minStock: Math.round(item.quantity * 0.1), currentStock: order.status === "DELIVERED" ? item.quantity : 0, + usedStock: 0, // TODO: Подсчитывать реальное использование imageUrl: null, createdAt: order.createdAt, updatedAt: order.updatedAt, @@ -772,37 +838,22 @@ export const resolvers = { })) ); - // Проверяем все заказы для этого фулфилмент-центра для отладки - const allOurOrders = await prisma.supplyOrder.findMany({ - where: { - organizationId: currentUser.organization.id, - fulfillmentCenterId: currentUser.organization.id, - }, - select: { id: true, status: true, createdAt: true }, - }); - // Логирование для отладки - console.log("🔥🔥🔥 MY_SUPPLIES RESOLVER CALLED 🔥🔥🔥"); - console.log("📊 mySupplies resolver debug:", { + console.log("🔥🔥🔥 FULFILLMENT SUPPLIES RESOLVER CALLED 🔥🔥🔥"); + console.log("📊 Расходники фулфилмента:", { organizationId: currentUser.organization.id, - existingSuppliesCount: existingSupplies.length, - ourSupplyOrdersCount: ourSupplyOrders.length, - suppliesFromOrdersCount: suppliesFromOrders.length, - allOrdersCount: allOurOrders.length, - allOrdersStatuses: allOurOrders.map((o) => ({ - id: o.id, - status: o.status, - createdAt: o.createdAt, - })), - filteredOrdersStatuses: ourSupplyOrders.map((o) => ({ + organizationType: currentUser.organization.type, + fulfillmentOrdersCount: fulfillmentSupplyOrders.length, + fulfillmentSuppliesCount: fulfillmentSupplies.length, + fulfillmentOrders: fulfillmentSupplyOrders.map((o) => ({ id: o.id, + supplierName: o.partner.name, status: o.status, + itemsCount: o.items.length, })), }); - console.log("🔥🔥🔥 END MY_SUPPLIES RESOLVER 🔥🔥🔥"); - // Объединяем существующие расходники и расходники из заказов - return [...existingSupplies, ...suppliesFromOrders]; + return fulfillmentSupplies; }, // Заказы поставок расходников @@ -881,7 +932,7 @@ export const resolvers = { // Считаем заказы поставок, требующие действий - // Наши расходники (созданные нами для себя) - требуют действий по статусам + // Расходники фулфилмента (созданные нами для себя) - требуют действий по статусам const ourSupplyOrders = await prisma.supplyOrder.count({ where: { organizationId: currentUser.organization.id, // Создали мы @@ -899,8 +950,17 @@ export const resolvers = { }, }); + // 🔔 ВХОДЯЩИЕ ЗАКАЗЫ ДЛЯ ПОСТАВЩИКОВ (WHOLESALE) - требуют подтверждения + const incomingSupplierOrders = await prisma.supplyOrder.count({ + where: { + partnerId: currentUser.organization.id, // Мы - поставщик + status: "PENDING", // Ожидает подтверждения от поставщика + }, + }); + // Общий счетчик поставок - const pendingSupplyOrders = ourSupplyOrders + sellerSupplyOrders; + const pendingSupplyOrders = + ourSupplyOrders + sellerSupplyOrders + incomingSupplierOrders; // Считаем входящие заявки на партнерство со статусом PENDING const pendingIncomingRequests = await prisma.counterpartyRequest.count({ @@ -912,8 +972,9 @@ export const resolvers = { return { supplyOrders: pendingSupplyOrders, - ourSupplyOrders: ourSupplyOrders, // Наши расходники + ourSupplyOrders: ourSupplyOrders, // Расходники фулфилмента sellerSupplyOrders: sellerSupplyOrders, // Расходники селлеров + incomingSupplierOrders: incomingSupplierOrders, // 🔔 Входящие заказы для поставщиков incomingRequests: pendingIncomingRequests, total: pendingSupplyOrders + pendingIncomingRequests, }; @@ -996,14 +1057,30 @@ export const resolvers = { throw new GraphQLError("Товары доступны только для поставщиков"); } - return await prisma.product.findMany({ - where: { organizationId: currentUser.organization.id }, + const products = await prisma.product.findMany({ + where: { + organizationId: currentUser.organization.id, + type: "PRODUCT", // Показываем только товары, исключаем расходники + }, include: { category: true, organization: true, }, orderBy: { createdAt: "desc" }, }); + + console.log("🔥 MY_PRODUCTS RESOLVER DEBUG:", { + organizationId: currentUser.organization.id, + organizationType: currentUser.organization.type, + totalProducts: products.length, + productTypes: products.map((p) => ({ + id: p.id, + name: p.name, + type: p.type, + })), + }); + + return products; }, // Товары на складе фулфилмента (из доставленных заказов поставок) @@ -1085,19 +1162,30 @@ export const resolvers = { ); for (const item of order.items) { - // Добавляем товар на склад с информацией о заказе - allProducts.push({ - ...item.product, - // Дополнительная информация о заказе - orderedQuantity: item.quantity, - orderedPrice: item.price, - orderId: order.id, - orderDate: order.deliveryDate, - seller: order.organization, // Селлер, который заказал - supplier: order.partner, // Поставщик товара - // Для совместимости с существующим интерфейсом - organization: order.organization, // Указываем селлера как владельца - }); + // Добавляем только товары типа PRODUCT, исключаем расходники + if (item.product.type === "PRODUCT") { + allProducts.push({ + ...item.product, + // Дополнительная информация о заказе + orderedQuantity: item.quantity, + orderedPrice: item.price, + orderId: order.id, + orderDate: order.deliveryDate, + seller: order.organization, // Селлер, который заказал + supplier: order.partner, // Поставщик товара + // Для совместимости с существующим интерфейсом + organization: order.organization, // Указываем селлера как владельца + }); + } else { + console.log( + `🚫 Исключен расходник из основного склада фулфилмента:`, + { + name: item.product.name, + type: item.product.type, + orderId: order.id, + } + ); + } } } @@ -1122,6 +1210,7 @@ export const resolvers = { const where: Record = { isActive: true, // Показываем только активные товары + type: "PRODUCT", // Показываем только товары, исключаем расходники organization: { type: "WHOLESALE", // Только товары поставщиков }, @@ -1140,7 +1229,7 @@ export const resolvers = { where.categoryId = args.category; } - return await prisma.product.findMany({ + const products = await prisma.product.findMany({ where, include: { category: true, @@ -1153,6 +1242,20 @@ export const resolvers = { orderBy: { createdAt: "desc" }, take: 100, // Ограничиваем количество результатов }); + + console.log("🔥 ALL_PRODUCTS RESOLVER DEBUG:", { + searchArgs: args, + whereCondition: where, + totalProducts: products.length, + productTypes: products.map((p) => ({ + id: p.id, + name: p.name, + type: p.type, + org: p.organization.name, + })), + }); + + return products; }, // Все категории @@ -3375,6 +3478,94 @@ export const resolvers = { } }, + // Использовать расходники фулфилмента + useFulfillmentSupplies: async ( + _: unknown, + args: { + input: { + supplyId: string; + quantityUsed: number; + description?: string; + }; + }, + context: Context + ) => { + if (!context.user) { + throw new GraphQLError("Требуется авторизация", { + extensions: { code: "UNAUTHENTICATED" }, + }); + } + + const currentUser = await prisma.user.findUnique({ + where: { id: context.user.id }, + include: { organization: true }, + }); + + if (!currentUser?.organization) { + throw new GraphQLError("У пользователя нет организации"); + } + + // Проверяем, что это фулфилмент центр + if (currentUser.organization.type !== "FULFILLMENT") { + throw new GraphQLError( + "Использование расходников доступно только для фулфилмент центров" + ); + } + + // Находим расходник + const existingSupply = await prisma.supply.findFirst({ + where: { + id: args.input.supplyId, + organizationId: currentUser.organization.id, + }, + }); + + if (!existingSupply) { + throw new GraphQLError("Расходник не найден или нет доступа"); + } + + // Проверяем, что достаточно расходников + if (existingSupply.currentStock < args.input.quantityUsed) { + throw new GraphQLError( + `Недостаточно расходников. Доступно: ${existingSupply.currentStock}, требуется: ${args.input.quantityUsed}` + ); + } + + try { + // Обновляем количество расходников + const updatedSupply = await prisma.supply.update({ + where: { id: args.input.supplyId }, + data: { + currentStock: existingSupply.currentStock - args.input.quantityUsed, + usedStock: + (existingSupply.usedStock || 0) + args.input.quantityUsed, + updatedAt: new Date(), + }, + include: { organization: true }, + }); + + console.log("🔧 Использованы расходники фулфилмента:", { + supplyName: updatedSupply.name, + quantityUsed: args.input.quantityUsed, + remainingStock: updatedSupply.currentStock, + totalUsed: updatedSupply.usedStock, + description: args.input.description, + }); + + return { + success: true, + message: `Использовано ${args.input.quantityUsed} ${updatedSupply.unit} расходника "${updatedSupply.name}"`, + supply: updatedSupply, + }; + } catch (error) { + console.error("Error using fulfillment supplies:", error); + return { + success: false, + message: "Ошибка при использовании расходников", + }; + } + }, + // Создать заказ поставки расходников // Два сценария: // 1. Селлер → Поставщик → Фулфилмент (селлер заказывает для фулфилмент-центра) @@ -3613,6 +3804,41 @@ export const resolvers = { data: suppliesData, }); + // 🔔 ОТПРАВЛЯЕМ УВЕДОМЛЕНИЕ ПОСТАВЩИКУ О НОВОМ ЗАКАЗЕ + try { + const orderSummary = args.input.items + .map((item) => { + const product = products.find((p) => p.id === item.productId)!; + return `${product.name} - ${item.quantity} шт.`; + }) + .join(", "); + + const notificationMessage = `🔔 Новый заказ поставки от ${ + currentUser.organization.name || currentUser.organization.fullName + }!\n\nТовары: ${orderSummary}\nДата доставки: ${new Date( + args.input.deliveryDate + ).toLocaleDateString( + "ru-RU" + )}\nОбщая сумма: ${totalAmount.toLocaleString( + "ru-RU" + )} ₽\n\nПожалуйста, подтвердите заказ в разделе "Поставки".`; + + await prisma.message.create({ + data: { + content: notificationMessage, + type: "TEXT", + senderId: context.user.id, + senderOrganizationId: currentUser.organization.id, + receiverOrganizationId: args.input.partnerId, + }, + }); + + console.log(`✅ Уведомление отправлено поставщику ${partner.name}`); + } catch (notificationError) { + console.error("❌ Ошибка отправки уведомления:", notificationError); + // Не прерываем выполнение, если уведомление не отправилось + } + // Формируем сообщение в зависимости от роли организации let successMessage = ""; if (organizationRole === "SELLER") { @@ -5163,7 +5389,8 @@ export const resolvers = { console.error("Error updating external ad clicks:", error); return { success: false, - message: error instanceof Error ? error.message : "Ошибка обновления кликов", + message: + error instanceof Error ? error.message : "Ошибка обновления кликов", externalAd: null, }; } @@ -6198,21 +6425,21 @@ const externalAdQueries = { organizationId: user.organization.id, date: { gte: new Date(dateFrom), - lte: new Date(dateTo + 'T23:59:59.999Z'), + lte: new Date(dateTo + "T23:59:59.999Z"), }, }, orderBy: { - date: 'desc', + date: "desc", }, }); return { success: true, message: null, - externalAds: externalAds.map(ad => ({ + externalAds: externalAds.map((ad) => ({ ...ad, cost: parseFloat(ad.cost.toString()), - date: ad.date.toISOString().split('T')[0], + date: ad.date.toISOString().split("T")[0], createdAt: ad.createdAt.toISOString(), updatedAt: ad.updatedAt.toISOString(), })), @@ -6221,7 +6448,10 @@ const externalAdQueries = { console.error("Error fetching external ads:", error); return { success: false, - message: error instanceof Error ? error.message : "Ошибка получения внешней рекламы", + message: + error instanceof Error + ? error.message + : "Ошибка получения внешней рекламы", externalAds: [], }; } @@ -6231,7 +6461,17 @@ const externalAdQueries = { const externalAdMutations = { createExternalAd: async ( _: unknown, - { input }: { input: { name: string; url: string; cost: number; date: string; nmId: string } }, + { + input, + }: { + input: { + name: string; + url: string; + cost: number; + date: string; + nmId: string; + }; + }, context: Context ) => { if (!context.user) { @@ -6267,7 +6507,7 @@ const externalAdMutations = { externalAd: { ...externalAd, cost: parseFloat(externalAd.cost.toString()), - date: externalAd.date.toISOString().split('T')[0], + date: externalAd.date.toISOString().split("T")[0], createdAt: externalAd.createdAt.toISOString(), updatedAt: externalAd.updatedAt.toISOString(), }, @@ -6276,7 +6516,10 @@ const externalAdMutations = { console.error("Error creating external ad:", error); return { success: false, - message: error instanceof Error ? error.message : "Ошибка создания внешней рекламы", + message: + error instanceof Error + ? error.message + : "Ошибка создания внешней рекламы", externalAd: null, }; } @@ -6284,7 +6527,19 @@ const externalAdMutations = { updateExternalAd: async ( _: unknown, - { id, input }: { id: string; input: { name: string; url: string; cost: number; date: string; nmId: string } }, + { + id, + input, + }: { + id: string; + input: { + name: string; + url: string; + cost: number; + date: string; + nmId: string; + }; + }, context: Context ) => { if (!context.user) { @@ -6332,7 +6587,7 @@ const externalAdMutations = { externalAd: { ...externalAd, cost: parseFloat(externalAd.cost.toString()), - date: externalAd.date.toISOString().split('T')[0], + date: externalAd.date.toISOString().split("T")[0], createdAt: externalAd.createdAt.toISOString(), updatedAt: externalAd.updatedAt.toISOString(), }, @@ -6341,7 +6596,10 @@ const externalAdMutations = { console.error("Error updating external ad:", error); return { success: false, - message: error instanceof Error ? error.message : "Ошибка обновления внешней рекламы", + message: + error instanceof Error + ? error.message + : "Ошибка обновления внешней рекламы", externalAd: null, }; } @@ -6393,21 +6651,19 @@ const externalAdMutations = { console.error("Error deleting external ad:", error); return { success: false, - message: error instanceof Error ? error.message : "Ошибка удаления внешней рекламы", + message: + error instanceof Error + ? error.message + : "Ошибка удаления внешней рекламы", externalAd: null, }; } }, - }; // Резолверы для кеша склада WB const wbWarehouseCacheQueries = { - getWBWarehouseData: async ( - _: unknown, - __: unknown, - context: Context - ) => { + getWBWarehouseData: async (_: unknown, __: unknown, context: Context) => { if (!context.user) { throw new GraphQLError("Требуется авторизация", { extensions: { code: "UNAUTHENTICATED" }, @@ -6435,7 +6691,7 @@ const wbWarehouseCacheQueries = { cacheDate: today, }, orderBy: { - createdAt: 'desc', + createdAt: "desc", }, }); @@ -6446,7 +6702,7 @@ const wbWarehouseCacheQueries = { message: "Данные получены из кеша", cache: { ...cache, - cacheDate: cache.cacheDate.toISOString().split('T')[0], + cacheDate: cache.cacheDate.toISOString().split("T")[0], createdAt: cache.createdAt.toISOString(), updatedAt: cache.updatedAt.toISOString(), }, @@ -6465,7 +6721,10 @@ const wbWarehouseCacheQueries = { console.error("Error getting WB warehouse cache:", error); return { success: false, - message: error instanceof Error ? error.message : "Ошибка получения кеша склада WB", + message: + error instanceof Error + ? error.message + : "Ошибка получения кеша склада WB", cache: null, fromCache: false, }; @@ -6476,7 +6735,16 @@ const wbWarehouseCacheQueries = { const wbWarehouseCacheMutations = { saveWBWarehouseCache: async ( _: unknown, - { input }: { input: { data: string; totalProducts: number; totalStocks: number; totalReserved: number } }, + { + input, + }: { + input: { + data: string; + totalProducts: number; + totalStocks: number; + totalReserved: number; + }; + }, context: Context ) => { if (!context.user) { @@ -6528,7 +6796,7 @@ const wbWarehouseCacheMutations = { message: "Кеш склада WB успешно сохранен", cache: { ...cache, - cacheDate: cache.cacheDate.toISOString().split('T')[0], + cacheDate: cache.cacheDate.toISOString().split("T")[0], createdAt: cache.createdAt.toISOString(), updatedAt: cache.updatedAt.toISOString(), }, @@ -6538,7 +6806,10 @@ const wbWarehouseCacheMutations = { console.error("Error saving WB warehouse cache:", error); return { success: false, - message: error instanceof Error ? error.message : "Ошибка сохранения кеша склада WB", + message: + error instanceof Error + ? error.message + : "Ошибка сохранения кеша склада WB", cache: null, fromCache: false, }; diff --git a/src/graphql/typedefs.ts b/src/graphql/typedefs.ts index cb7557d..4c0ace8 100644 --- a/src/graphql/typedefs.ts +++ b/src/graphql/typedefs.ts @@ -37,9 +37,12 @@ export const typeDefs = gql` # Услуги организации myServices: [Service!]! - # Расходники организации + # Расходники селлеров (материалы клиентов) mySupplies: [Supply!]! + # Расходники фулфилмента (материалы для работы фулфилмента) + myFulfillmentSupplies: [Supply!]! + # Заказы поставок расходников supplyOrders: [SupplyOrder!]! @@ -194,6 +197,9 @@ export const typeDefs = gql` updateSupply(id: ID!, input: SupplyInput!): SupplyResponse! deleteSupply(id: ID!): Boolean! + # Использование расходников фулфилмента + useFulfillmentSupplies(input: UseFulfillmentSuppliesInput!): SupplyResponse! + # Заказы поставок расходников createSupplyOrder(input: SupplyOrderInput!): SupplyOrderResponse! updateSupplyOrderStatus( @@ -524,6 +530,7 @@ export const typeDefs = gql` supplier: String minStock: Int currentStock: Int + usedStock: Int imageUrl: String createdAt: DateTime! updatedAt: DateTime! @@ -545,6 +552,12 @@ export const typeDefs = gql` imageUrl: String } + input UseFulfillmentSuppliesInput { + supplyId: ID! + quantityUsed: Int! + description: String # Описание использования (например, "Подготовка 300 продуктов") + } + type SupplyResponse { success: Boolean! message: String! @@ -602,8 +615,9 @@ export const typeDefs = gql` type PendingSuppliesCount { supplyOrders: Int! - ourSupplyOrders: Int! # Наши расходники + ourSupplyOrders: Int! # Расходники фулфилмента sellerSupplyOrders: Int! # Расходники селлеров + incomingSupplierOrders: Int! # 🔔 Входящие заказы для поставщиков incomingRequests: Int! total: Int! } @@ -1239,6 +1253,8 @@ export const typeDefs = gql` } extend type Mutation { - saveWBWarehouseCache(input: WBWarehouseCacheInput!): WBWarehouseCacheResponse! + saveWBWarehouseCache( + input: WBWarehouseCacheInput! + ): WBWarehouseCacheResponse! } `; diff --git a/логика поставки расходников фулфилмента.md b/логика поставки расходников фулфилмента.md new file mode 100644 index 0000000..9f66f3f --- /dev/null +++ b/логика поставки расходников фулфилмента.md @@ -0,0 +1,153 @@ +# ЛОГИКА ПОСТАВКИ РАСХОДНИКОВ ФУЛФИЛМЕНТА + +> **ВНИМАНИЕ**: Данный файл содержит детальную логику процесса создания поставки расходников фулфилмента. +> Любые изменения в этом процессе должны быть согласованы и отражены в основном файле логики системы. + +--- + +## 4. ПРОЦЕСС СОЗДАНИЯ ПОСТАВКИ РАСХОДНИКОВ ФУЛФИЛМЕНТА + +### 4.1 Описание процесса + +Фулфилмент-центры могут заказывать расходные материалы для своих операций напрямую у поставщиков. Этот процесс отличается от основного бизнес-процесса тем, что фулфилмент выступает как заказчик, а не как исполнитель услуг. + +**ВАЖНОЕ РАЗЛИЧИЕ**: Расходники фулфилмента - это материалы, которые фулфилмент-центр заказывает для своих внутренних операций (упаковка, хранение, обработка товаров). Они отличаются от расходников селлера, которые селлер заказывает для своих товаров. + +### 4.2 Участники процесса + +- **Фулфилмент-центр** - заказчик расходников +- **Поставщик (WHOLESALE)** - поставщик расходных материалов +- **Логистическая компания (LOGIST)** - доставка товаров от поставщика к фулфилменту +- **Система** - автоматическая обработка заказов + +### 4.3 Этапы процесса создания поставки расходников фулфилмента + +#### Этап 1: Инициация заказа фулфилментом + +1. **Переход к созданию заказа**: Фулфилмент заходит в раздел "Входящие поставки" → "Расходники фулфилмента" → "Создать поставку" +2. **Выбор поставщика**: Выбор контрагента с типом "WHOLESALE" из списка партнеров +3. **Поиск поставщика**: Возможность поиска по названию, полному названию или ИНН +4. **Просмотр каталога**: Просмотр товаров выбранного поставщика + +#### Этап 2: Формирование заказа + +5. **Поиск товаров**: Поиск нужных расходников в каталоге поставщика +6. **Выбор количества**: Указание необходимого количества для каждого товара +7. **Добавление в корзину**: Товары добавляются в список выбранных расходников фулфилмента +8. **Выбор логистики**: Выбор партнера-логиста для доставки товаров от поставщика +9. **Расчет стоимости**: Автоматический расчет общей суммы заказа +10. **Указание даты доставки**: Выбор желаемой даты поставки + +#### Этап 3: Создание заказа в системе + +11. **Валидация данных**: Проверка заполнения всех обязательных полей +12. **Создание SupplyOrder**: Система создает запись заказа поставки со статусом "PENDING" +13. **Указание получателя**: fulfillmentCenterId устанавливается как ID текущего фулфилмента +14. **Указание логистики**: logisticsPartnerId устанавливается как ID выбранной логистической компании +15. **Создание позиций заказа**: Создание SupplyOrderItem для каждого выбранного товара + +#### Этап 4: Автоматическая обработка системой + +16. **Создание расходников**: Система автоматически создает записи Supply со статусом "planned" +17. **Установка параметров**: + - Статус: "planned" (запланировано, ожидает одобрения) + - Категория: из товара или "Расходники" + - Минимальный остаток: 10% от заказанного количества + - Текущий остаток: 0 (товар еще не поступил) +18. **Привязка к организации**: Расходники создаются в организации фулфилмента +19. **Отправка уведомления**: Поставщик получает уведомление о новом заказе + +#### Этап 5: Обработка поставщиком + +20. **Получение заявки**: Заказ появляется в кабинете поставщика в разделе "Заявки" +21. **Рассмотрение заказа**: Поставщик может принять или отклонить заказ +22. **Изменение статуса**: При принятии статус SupplyOrder меняется на "CONFIRMED" (подтвержден поставщиком) +23. **Уведомление логистики**: После одобрения поставщиком заявка появляется в кабинете логистической компании + +#### Этап 6: Обработка логистикой + +24. **Получение заявки**: Заказ появляется в кабинете логистики в разделе "Заявки" +25. **Рассмотрение заявки**: Логистическая компания может подтвердить или отклонить заявку на доставку +26. **Подтверждение логистики**: При принятии логистика подтверждает возможность доставки в указанные сроки +27. **Обновление расходников**: Supply переходят в статус "confirmed" (ожидает отгрузки) +28. **Подготовка к отгрузке**: Поставщик готовит товар к отправке + +#### Этап 7: Доставка и приемка + +29. **Отгрузка товара** [**ПОСТАВЩИК**]: Поставщик физически отправляет товар логистической компании и **в системе нажимает кнопку "Отправить"** (статус SupplyOrder меняется с "CONFIRMED" на "IN_TRANSIT") +30. **Обновление статуса расходников** [**СИСТЕМА**]: Supply переходят в статус "in-transit" (в пути) +31. **Транспортировка** [**ЛОГИСТИКА**]: Логистическая компания доставляет товар в фулфилмент-центр +32. **Статус "IN_TRANSIT"** [**СИСТЕМА**]: Заказ переходит в статус "в пути" +33. **Приемка на фулфилменте** [**ФУЛФИЛМЕНТ**]: Менеджер фулфилмента принимает товар +34. **Обновление остатков** [**ФУЛФИЛМЕНТ**]: currentStock обновляется на фактически полученное количество +35. **Статус "DELIVERED"** [**ФУЛФИЛМЕНТ**]: Заказ завершается со статусом "доставлен" +36. **Обновление расходников** [**СИСТЕМА**]: Supply переходят в статус "in-stock" (на складе) + +### 4.3.1 Результат завершения процесса + +После успешного завершения процесса (статус Supply = "in-stock"): + +37. **Отображение на складе** [**СИСТЕМА**]: Информация о поставке автоматически отображается в разделе **"Склад" → "Статистика расходников фулфилмента"** +38. **Отображение в расходниках фулфилмента** [**СИСТЕМА**]: Информация о поставке также отображается в подразделе **"Расходники фулфилмента"** +39. **Доступность для использования** [**ФУЛФИЛМЕНТ**]: Расходники становятся доступными для использования в операциях фулфилмент-центра + +**Важно**: Статус "in-stock" (на складе) означает, что расходники физически находятся на складе фулфилмента и готовы к использованию в операционной деятельности. + +### 4.4 Особенности процесса + +#### 4.4.1 Отличия от основного процесса + +- **Прямое взаимодействие**: Фулфилмент напрямую заказывает у поставщика +- **Самостоятельная приемка**: Фулфилмент принимает товар на свой склад +- **Управление остатками**: Автоматическое управление минимальными остатками +- **Без посредников**: Логистика может быть внешней или встроенной + +#### 4.4.2 Типы расходников + +- **Упаковочные материалы**: Коробки, пакеты, скотч +- **Защитные материалы**: Пупырчатая пленка, стрейч-пленка +- **Маркировочные материалы**: Этикетки, стикеры, маркеры +- **Инструменты**: Ножи, степлеры, весы +- **Расходные материалы**: Батарейки, картриджи, канцелярия + +#### 4.4.3 Автоматизация + +- **Автоматический расчет минимальных остатков**: 10% от заказанного количества +- **Уведомления**: Автоматические уведомления всем участникам процесса +- **Обновление данных**: Синхронизация статусов между всеми системами +- **Отчетность**: Автоматическое обновление складских отчетов + +### 4.5 Интеграция с основной системой + +- **Единая база контрагентов**: Использование общего справочника партнеров +- **Общие товары**: Поставщики управляют единым каталогом товаров +- **Единая система уведомлений**: Общий мессенджер для коммуникаций +- **Общая отчетность**: Интеграция с общей системой аналитики + +--- + +## Статусы в процессе + +### Статусы SupplyOrder (Заказ поставки): + +- **PENDING** - Ожидает подтверждения поставщиком +- **CONFIRMED** - Подтвержден поставщиком +- **IN_TRANSIT** - В пути (логистика доставляет) +- **DELIVERED** - Доставлен на фулфилмент +- **CANCELLED** - Отменен + +### Статусы Supply (Расходники): + +- **planned** - Запланировано (ожидает одобрения поставщиком) +- **confirmed** - Подтверждено (ожидает отгрузки после одобрения логистикой) +- **in-transit** - В пути (товар отгружен логистической компании) +- **in-stock** - На складе (товар принят на фулфилменте и отображается в разделах "Склад" и "Расходники фулфилмента") + +--- + +**ВАЖНО**: Данный процесс является частью общей системы управления поставками и должен соответствовать общим принципам и правилам, описанным в основном файле логики системы. + +--- + +_Документ создан на основе раздела 4 основного файла логики системы_ +_Статус: СПЕЦИАЛИЗИРОВАННАЯ ЛОГИКА ПРОЦЕССА_ diff --git a/логика.md b/логика.md new file mode 100644 index 0000000..aacb8a7 --- /dev/null +++ b/логика.md @@ -0,0 +1,788 @@ +# ЛОГИКА СИСТЕМЫ SFERA V - УПРАВЛЕНИЕ БИЗНЕСОМ + +> **ВНИМАНИЕ**: Данный файл содержит критически важную информацию о логике работы системы. +> Любые изменения в этом файле должны производиться только с разрешения владельца проекта. +> Файл служит эталонным источником для понимания архитектуры и бизнес-процессов системы. + +--- + +## 1. ОБЩАЯ АРХИТЕКТУРА СИСТЕМЫ + +### 1.1 Концепция платформы + +**Sfera V** - это многопользовательская B2B платформа для управления цепочками поставок, объединяющая различные типы бизнеса: + +- **Селлеры** (продавцы на маркетплейсах) +- **Фулфилмент-центры** (склады и логистика) +- **Поставщики** (оптовые поставщики товаров) +- **Логистические компании** (доставка товаров) + +### 1.2 Технологический стек + +- **Frontend**: Next.js 15, React 18, TypeScript +- **Backend**: GraphQL API, Apollo Server +- **База данных**: PostgreSQL с Prisma ORM +- **UI**: TailwindCSS, Shadcn/ui компоненты +- **Файловое хранилище**: AWS S3 +- **Внешние API**: Wildberries, Ozon, DaData, SMS Aero + +--- + +## 2. ТИПЫ ОРГАНИЗАЦИЙ И КАБИНЕТЫ + +### 2.1 Типы организаций (OrganizationType) + +``` +SELLER - Селлер (продавец на маркетплейсах) +FULFILLMENT - Фулфилмент-центр +LOGIST - Логистическая компания +WHOLESALE - Поставщик (оптовик) +``` + +### 2.2 Структура кабинетов + +#### 2.2.1 Админ-кабинет + +- **Назначение**: Управление системой +- **Функции**: + - UI Kit (демонстрация компонентов) + - Управление пользователями + - Управление категориями товаров + - Системная аналитика + +#### 2.2.2 Кабинет Селлера + +- **Назначение**: Продажи на маркетплейсах +- **Модули**: + - Мои поставки (управление заказами) + - Склад WB (интеграция с Wildberries) + - Статистика продаж +- **Интеграции**: Wildberries API, Ozon API + +#### 2.2.3 Кабинет Фулфилмента + +- **Назначение**: Склады и обработка товаров +- **Модули**: + - Входящие поставки + - Управление складом + - Услуги фулфилмента + - Сотрудники + - Статистика операций + +#### 2.2.4 Кабинет Логистики + +- **Назначение**: Транспортные услуги +- **Модули**: + - Заявки на перевозки + - Управление маршрутами + +#### 2.2.5 Кабинет Поставщика + +- **Назначение**: Оптовые продажи +- **Модули**: + - Каталог товаров + - Отгрузки + - Управление складом + +### 2.3 Общие модули (доступны всем кабинетам) + +- **Мессенджер**: Коммуникации между участниками +- **Партнёры**: Управление контрагентами +- **Настройки**: Профиль организации, API ключи + +--- + +## 3. ОСНОВНОЙ БИЗНЕС-ПРОЦЕСС ПОСТАВКИ + +### 3.1 Схема процесса + +``` +Селлер → Маркет → Поставщик → Фулфилмент → Логистика → Селлер +``` + +### 3.2 Детальные этапы процесса + +#### Этап 1: Создание заявки селлером + +1. **Вход в маркет**: Селлер заходит в раздел "Маркет" +2. **Выбор категории**: Выбор нужной категории товаров +3. **Выбор товара**: Выбор конкретного продукта из каталога +4. **Указание количества**: Определение необходимого объема +5. **Выбор услуг**: Выбор услуг фулфилмента и / или расходников фулфилмента и / или расходников селлера +6. **Создание заявки**: Формирование заявки на поставку + +#### Этап 2: Обработка заявки системой + +7. **Сохранение у селлера**: Заявка появляется в "Мои поставки" → "Все" → "Товар" и "Товар" +8. **Дублирование поставщику**: Заявка автоматически отправляется поставщику + +#### Этап 3: Одобрение поставщиком + +9. **Рассмотрение заявки**: Поставщик видит заявку в разделе "Заявки" +10. **Одобрение**: Поставщик одобряет или отклоняет заявку +11. **Обновление статуса**: Статус меняется у всех участников + +#### Этап 4: Логистика + +12. **Подготовка к отгрузке**: Поставщик готовит товар к отправке +13. **Создание заявки в фулфилмент**: Заявка появляется в кабинете фулфилмента +14. **Запрос логистики**: Заявка передается в логистическую компанию +15. **Подтверждение логистикой**: Логист подтверждает возможность доставки +16. **Физическая доставка**: Транспортировка товара на фулфилмент + +#### Этап 5: Обработка на фулфилменте + +17. **Приемка товара**: Менеджер фулфилмента принимает груз +18. **Ввод данных хранения**: Указание места хранения, статус "Принято" +19. **Перенос в подготовку**: Товар переходит в раздел "Подготовка" +20. **Обновление места хранения**: Указание нового места хранения +21. **Перенос в работу**: Товар переходит в подраздел "В работе" +22. **Контроль качества**: Проверка количества и качества товара +23. **Завершение работ**: При нажатии "Выполнено" товар переходит в "Выполнено" + +#### Этап 6: Завершение + +24. **Обновление статуса у селлера**: Селлер получает уведомление о готовности товара + +--- + +## 4. ПРОЦЕСС СОЗДАНИЯ ПОСТАВКИ РАСХОДНИКОВ ФУЛФИЛМЕНТА + +### 4.1 Описание процесса + +Фулфилмент-центры могут заказывать расходные материалы для своих операций напрямую у поставщиков. Этот процесс отличается от основного бизнес-процесса тем, что фулфилмент выступает как заказчик, а не как исполнитель услуг. + +**ВАЖНОЕ РАЗЛИЧИЕ**: Расходники фулфилмента - это материалы, которые фулфилмент-центр заказывает для своих внутренних операций (упаковка, хранение, обработка товаров). Они отличаются от расходников селлера, которые селлер заказывает для своих товаров. + +### 4.2 Участники процесса + +- **Фулфилмент-центр** - заказчик расходников +- **Поставщик (WHOLESALE)** - поставщик расходных материалов +- **Логистическая компания (LOGIST)** - доставка товаров от поставщика к фулфилменту +- **Система** - автоматическая обработка заказов + +### 4.3 Этапы процесса создания поставки расходников фулфилмента + +#### Этап 1: Инициация заказа фулфилментом + +1. **Переход к созданию заказа**: Фулфилмент заходит в раздел "Входящие поставки" → "Расходники фулфилмента" → "Создать поставку" +2. **Выбор поставщика**: Выбор контрагента с типом "WHOLESALE" из списка партнеров +3. **Поиск поставщика**: Возможность поиска по названию, полному названию или ИНН +4. **Просмотр каталога**: Просмотр товаров выбранного поставщика + +#### Этап 2: Формирование заказа + +5. **Поиск товаров**: Поиск нужных расходников в каталоге поставщика +6. **Выбор количества**: Указание необходимого количества для каждого товара +7. **Добавление в корзину**: Товары добавляются в список выбранных расходников фулфилмента +8. **Выбор логистики**: Выбор партнера-логиста для доставки товаров от поставщика +9. **Расчет стоимости**: Автоматический расчет общей суммы заказа +10. **Указание даты доставки**: Выбор желаемой даты поставки + +#### Этап 3: Создание заказа в системе + +11. **Валидация данных**: Проверка заполнения всех обязательных полей +12. **Создание SupplyOrder**: Система создает запись заказа поставки со статусом "PENDING" +13. **Указание получателя**: fulfillmentCenterId устанавливается как ID текущего фулфилмента +14. **Указание логистики**: logisticsPartnerId устанавливается как ID выбранной логистической компании +15. **Создание позиций заказа**: Создание SupplyOrderItem для каждого выбранного товара + +#### Этап 4: Автоматическая обработка системой + +16. **Создание расходников**: Система автоматически создает записи Supply со статусом "planned" +17. **Установка параметров**: + - Статус: "planned" (запланировано, ожидает одобрения) + - Категория: из товара или "Расходники" + - Минимальный остаток: 10% от заказанного количества + - Текущий остаток: 0 (товар еще не поступил) +18. **Привязка к организации**: Расходники создаются в организации фулфилмента +19. **Отправка уведомления**: Поставщик получает уведомление о новом заказе + +#### Этап 5: Обработка поставщиком + +20. **Получение заявки**: Заказ появляется в кабинете поставщика в разделе "Заявки" +21. **Рассмотрение заказа**: Поставщик может принять или отклонить заказ +22. **Изменение статуса**: При принятии статус SupplyOrder меняется на "CONFIRMED" (подтвержден поставщиком) +23. **Уведомление логистики**: После одобрения поставщиком заявка появляется в кабинете логистической компании + +#### Этап 6: Обработка логистикой + +24. **Получение заявки**: Заказ появляется в кабинете логистики в разделе "Заявки" +25. **Рассмотрение заявки**: Логистическая компания может подтвердить или отклонить заявку на доставку +26. **Подтверждение логистики**: При принятии логистика подтверждает возможность доставки в указанные сроки +27. **Обновление расходников**: Supply переходят в статус "confirmed" (ожидает отгрузки) +28. **Подготовка к отгрузке**: Поставщик готовит товар к отправке + +#### Этап 7: Доставка и приемка + +29. **Отгрузка товара**: Поставщик отправляет товар логистической компании +30. **Обновление статуса расходников**: Supply переходят в статус "in-transit" (в пути) +31. **Транспортировка**: Логистическая компания доставляет товар в фулфилмент-центр +32. **Статус "IN_TRANSIT"**: Заказ переходит в статус "в пути" +33. **Приемка на фулфилменте**: Менеджер фулфилмента принимает товар +34. **Обновление остатков**: currentStock обновляется на фактически полученное количество +35. **Статус "DELIVERED"**: Заказ завершается со статусом "доставлен" +36. **Обновление расходников**: Supply переходят в статус "in-stock" (на складе) + +### 4.4 Особенности процесса + +#### 4.4.1 Отличия от основного процесса + +- **Прямое взаимодействие**: Фулфилмент напрямую заказывает у поставщика +- **Самостоятельная приемка**: Фулфилмент принимает товар на свой склад +- **Управление остатками**: Автоматическое управление минимальными остатками +- **Без посредников**: Логистика может быть внешней или встроенной + +#### 4.4.2 Типы расходников + +- **Упаковочные материалы**: Коробки, пакеты, скотч +- **Защитные материалы**: Пупырчатая пленка, стрейч-пленка +- **Маркировочные материалы**: Этикетки, стикеры, маркеры +- **Инструменты**: Ножи, степлеры, весы +- **Расходные материалы**: Батарейки, картриджи, канцелярия + +#### 4.4.3 Автоматизация + +- **Автоматический расчет минимальных остатков**: 10% от заказанного количества +- **Уведомления**: Автоматические уведомления всем участникам процесса +- **Обновление данных**: Синхронизация статусов между всеми системами +- **Отчетность**: Автоматическое обновление складских отчетов + +### 4.5 Интеграция с основной системой + +- **Единая база контрагентов**: Использование общего справочника партнеров +- **Общие товары**: Поставщики управляют единым каталогом товаров +- **Единая система уведомлений**: Общий мессенджер для коммуникаций +- **Общая отчетность**: Интеграция с общей системой аналитики + +--- + +## 5. МОДЕЛЬ ДАННЫХ + +### 5.1 Основные сущности + +#### 5.1.1 Organization (Организация) + +```sql +- id: String (CUID) +- inn: String (уникальный) +- type: OrganizationType +- name, fullName: String +- address, addressFull: String +- apiKeys: ApiKey[] +- employees: Employee[] +- products: Product[] +- services: Service[] +``` + +#### 5.1.2 Product (Товар) + +```sql +- id: String (CUID) +- name: String +- article: String +- price: Decimal +- quantity: Int +- type: ProductType (PRODUCT/CONSUMABLE) +- categoryId: String +- organizationId: String +- images: Json +``` + +#### 5.1.3 Supply (Поставка) + +```sql +- id: String (CUID) +- name: String +- price: Decimal +- quantity: Int +- status: String (planned/in-transit/delivered/in-stock) +- organizationId: String +``` + +#### 5.1.4 SupplyOrder (Заказ поставки) + +```sql +- id: String (CUID) +- partnerId: String +- status: SupplyOrderStatus +- totalAmount: Decimal +- deliveryDate: DateTime +- items: SupplyOrderItem[] +``` + +### 5.2 Статусы заказов + +``` +PENDING - Ожидает подтверждения +CONFIRMED - Подтвержден +IN_TRANSIT - В пути +DELIVERED - Доставлен +CANCELLED - Отменен +``` + +--- + +## 5. ПРОЦЕССЫ СОЗДАНИЯ ПОСТАВОК СЕЛЛЕРАМИ + +### 5.1 Описание процессов + +Селлеры имеют несколько способов создания поставок в зависимости от источника товаров и типа поставки. + +### 5.2 Типы поставок селлеров + +#### 5.2.1 Поставка через карточки товаров + +**Процесс**: Селлер → Мои поставки → Создать поставку → Карточки + +1. **Выбор типа**: Селлер выбирает "Карточки" как источник товаров +2. **Интеграция с WB**: Система загружает карточки товаров из Wildberries API +3. **Выбор товаров**: Селлер выбирает нужные карточки из своего каталога WB +4. **Настройка количества**: Указание количества для каждой карточки +5. **Выбор услуг**: Выбор услуг фулфилмента и расходников +6. **Выбор фулфилмента**: Выбор фулфилмент-центра для обработки +7. **Указание даты**: Установка даты поставки +8. **Создание поставки**: Формирование WildberriesSupply в системе + +#### 5.2.2 Поставка через поставщиков + +**Процесс**: Селлер → Мои поставки → Создать поставку → Поставщик + +1. **Выбор типа**: Селлер выбирает "Поставщик" как источник товаров +2. **Выбор поставщика**: Выбор из списка контрагентов типа WHOLESALE +3. **Просмотр каталога**: Просмотр товаров выбранного поставщика +4. **Выбор товаров**: Добавление товаров в корзину с указанием количества +5. **Выбор фулфилмента**: Выбор фулфилмент-центра для доставки +6. **Создание заказа**: Формирование SupplyOrder через основной процесс + +#### 5.2.3 Поставка расходников селлера + +**Процесс**: Селлер → Расходники → Создать поставку + +1. **Выбор поставщика**: Выбор поставщика расходников (WHOLESALE) +2. **Выбор расходников**: Выбор необходимых расходных материалов **для селлера** +3. **Выбор фулфилмента**: Обязательный выбор фулфилмент-центра для доставки +4. **Создание заказа**: Формирование SupplyOrder с типом CONSUMABLE + +**Важно**: Расходники селлера отличаются от расходников фулфилмента назначением и получателем. Расходники селлера заказываются селлером для своих товаров, но доставляются в фулфилмент-центр. + +### 5.3 Особенности процессов селлера + +#### 5.3.1 Прямое создание поставки + +- **DirectSupplyCreation**: Создание поставки с выбранными товарами +- **Расчет стоимости**: Автоматический расчет общей стоимости товаров и услуг +- **Валидация**: Проверка наличия всех обязательных полей +- **Интеграция**: Сохранение данных в WildberriesSupply и WildberriesSupplyCard + +#### 5.3.2 Управление объемами и весом + +- **Расчет объема**: Автоматический расчет объема товаров для логистики +- **Количество мест**: Определение количества грузовых мест +- **Стоимость услуг**: Расчет стоимости услуг фулфилмента и логистики + +--- + +## 6. ПРОЦЕССЫ УПРАВЛЕНИЯ ПАРТНЕРАМИ + +### 6.1 Описание системы партнерства + +Система партнерства позволяет организациям находить друг друга, отправлять заявки на сотрудничество и управлять контрагентами. + +### 6.2 Процесс поиска партнеров + +#### 6.2.1 Поиск в маркете + +1. **Переход в маркет**: Организация заходит в раздел "Маркет" +2. **Выбор типа партнера**: Выбор вкладки (Фулфилмент, Селлеры, Логистика, Поставщики) +3. **Поиск организаций**: Использование SEARCH_ORGANIZATIONS запроса +4. **Фильтрация**: Поиск по названию, ИНН, адресу +5. **Просмотр карточек**: Изучение информации о потенциальных партнерах + +#### 6.2.2 Отправка заявки на партнерство + +1. **Выбор организации**: Клик на карточку интересующей организации +2. **Отправка заявки**: Нажатие кнопки "Отправить заявку" +3. **Добавление сообщения**: Опциональное сообщение к заявке +4. **Создание запроса**: Система создает CounterpartyRequest со статусом PENDING +5. **Уведомление**: Получатель видит входящую заявку в разделе "Партнеры" + +#### 6.2.3 Обработка заявок + +1. **Просмотр заявок**: Заявки отображаются в разделе "Партнеры" → "Мои контрагенты" +2. **Принятие заявки**: Изменение статуса на ACCEPTED, создание связи Counterparty +3. **Отклонение заявки**: Изменение статуса на REJECTED +4. **Автоматическое обновление**: Обновление списков во всех связанных компонентах + +### 6.3 Управление контрагентами + +#### 6.3.1 Типы контрагентов + +- **FULFILLMENT**: Фулфилмент-центры для обработки товаров +- **SELLER**: Селлеры для продажи товаров +- **LOGIST**: Логистические компании для доставки +- **WHOLESALE**: Поставщики товаров и расходников + +#### 6.3.2 Функции управления + +- **Просмотр списка**: Все принятые контрагенты в одном разделе +- **Поиск и фильтрация**: Поиск по различным критериям +- **Статусы заявок**: Отслеживание входящих и исходящих заявок +- **Интеграция с процессами**: Использование контрагентов в поставках + +--- + +## 7. ПРОЦЕССЫ УПРАВЛЕНИЯ СКЛАДАМИ + +### 7.1 Типы складских систем + +#### 7.1.1 Собственный склад организации + +**Процесс**: Склад → Управление товарами + +1. **Создание товаров**: Добавление новых товаров в каталог +2. **Редактирование**: Изменение характеристик товаров +3. **Управление остатками**: Отслеживание количества товаров +4. **Категоризация**: Привязка к категориям товаров +5. **Медиафайлы**: Загрузка изображений товаров + +#### 7.1.2 Склад Wildberries (для селлеров) + +**Процесс**: Склад WB → Просмотр остатков + +1. **Интеграция с WB API**: Получение данных через Wildberries API +2. **Загрузка карточек**: Получение всех карточек товаров селлера +3. **Получение аналитики**: Индивидуальные запросы по каждому nmId +4. **Комбинирование данных**: Объединение карточек с данными остатков +5. **Отображение складов**: Группировка по складам WB +6. **Статистика**: Расчет общих показателей по остаткам + +#### 7.1.3 Фулфилмент склады + +**Процесс**: Фулфилмент → Склад → Управление + +1. **Приемка товаров**: Получение товаров от поставщиков +2. **Размещение**: Указание места хранения товаров +3. **Обработка**: Подготовка товаров к отправке +4. **Контроль качества**: Проверка товаров на брак +5. **Отгрузка**: Подготовка к отправке селлерам + +### 7.2 Интеграция складских процессов + +#### 7.2.1 Синхронизация остатков + +- **Автоматическое обновление**: Регулярная синхронизация с внешними API +- **Кэширование**: Сохранение данных для быстрого доступа +- **Уведомления**: Оповещения о критических остатках + +#### 7.2.2 Аналитика складов + +- **Статистика по товарам**: Общее количество, остатки, резерв +- **Статистика по складам**: Активные склады, загруженность +- **Товары в пути**: Отслеживание товаров в доставке + +--- + +## 8. ПРОЦЕССЫ УПРАВЛЕНИЯ СОТРУДНИКАМИ + +### 8.1 Система управления персоналом + +#### 8.1.1 Добавление сотрудников + +1. **Создание профиля**: Ввод основных данных сотрудника +2. **Документы**: Загрузка паспортных данных и фотографий +3. **Контакты**: Указание телефонов, email, мессенджеров +4. **Должность и зарплата**: Установка позиции и оплаты +5. **Экстренные контакты**: Контакты на случай чрезвычайных ситуаций + +#### 8.1.2 Управление статусами + +**Статусы сотрудников**: + +- **ACTIVE**: Активно работает +- **VACATION**: В отпуске +- **SICK**: На больничном +- **FIRED**: Уволен + +#### 8.1.3 Система табельного учета + +1. **Создание расписания**: Планирование рабочих дней +2. **Отметка статусов**: Ежедневная отметка статуса работы +3. **Типы дней**: + - **WORK**: Рабочий день (8 часов) + - **WEEKEND**: Выходной (0 часов) + - **VACATION**: Отпуск (8 часов) + - **SICK**: Больничный (8 часов) + - **ABSENT**: Отсутствие (0 часов) +4. **Автоматический расчет**: Подсчет отработанных часов +5. **Отчетность**: Генерация табелей учета рабочего времени + +### 8.2 Интеграция с бизнес-процессами + +- **Назначение ответственных**: Привязка сотрудников к заказам и процессам +- **Уведомления**: Система уведомлений для сотрудников +- **Права доступа**: Разграничение доступа по ролям + +--- + +## 9. ПРОЦЕССЫ ЛОГИСТИКИ + +### 9.1 Управление перевозками + +#### 9.1.1 Создание маршрутов + +1. **Планирование маршрута**: Определение точек отправления и назначения +2. **Расчет параметров**: Расстояние, время в пути, стоимость +3. **Назначение груза**: Привязка конкретных товаров к маршруту +4. **Статусы маршрутов**: + - **planned**: Запланировано + - **in_transit**: В пути + - **delivered**: Доставлено + - **cancelled**: Отменено + +#### 9.1.2 Отслеживание доставок + +1. **Мониторинг статусов**: Отслеживание текущего состояния доставок +2. **Уведомления**: Автоматические уведомления об изменении статусов +3. **Аналитика**: Статистика по доставкам и эффективности +4. **Интеграция**: Связь с процессами фулфилмента и поставок + +### 9.2 Типы логистических операций + +- **Доставка от поставщика к фулфилменту**: Входящая логистика +- **Доставка от фулфилмента к селлеру**: Исходящая логистика +- **Межскладские перемещения**: Внутренняя логистика +- **Возвраты**: Обратная логистика + +--- + +## 10. СИСТЕМА АУТЕНТИФИКАЦИИ + +### 10.1 Процесс регистрации/входа + +1. **Ввод телефона**: Пользователь вводит номер телефона +2. **SMS-код**: Система отправляет код подтверждения через SMS Aero +3. **Выбор типа кабинета**: Пользователь выбирает тип организации +4. **Ввод ИНН**: Для B2B пользователей обязательно указание ИНН +5. **Проверка через DaData**: Автоматическое получение данных организации +6. **API ключи маркетплейсов**: Для селлеров - настройка интеграций +7. **Подтверждение**: Завершение регистрации + +### 10.2 Типы аутентификации + +- **Пользователи**: JWT токены, хранение в localStorage +- **Админы**: Отдельная система с админскими токенами +- **API интеграции**: Bearer токены для внешних сервисов + +--- + +## 11. ВНЕШНИЕ ИНТЕГРАЦИИ + +### 11.1 Wildberries API + +- **Назначение**: Получение данных по складам и товарам +- **Эндпоинты**: + - `/ping` - проверка валидности ключа + - `/api/v1/seller-info` - информация о продавце + - `/api/v1/stocks` - остатки товаров + - `/api/v1/warehouses` - список складов + +### 11.2 Ozon API + +- **Назначение**: Интеграция с маркетплейсом Ozon +- **Аутентификация**: API ключ + Client ID + +### 11.3 DaData API + +- **Назначение**: Получение информации об организациях по ИНН +- **Данные**: Реквизиты, адреса, статусы организаций + +### 11.4 SMS Aero API + +- **Назначение**: Отправка SMS для аутентификации +- **Аутентификация**: HTTP Basic Auth (email + API key) + +### 11.5 AWS S3 + +- **Назначение**: Хранение файлов (изображения, документы, голосовые сообщения) + +--- + +## 12. СИСТЕМА СООБЩЕНИЙ + +### 12.1 Типы сообщений + +``` +TEXT - Текстовые сообщения +VOICE - Голосовые сообщения +IMAGE - Изображения +FILE - Файлы +``` + +### 12.2 Функциональность + +- Чаты между организациями +- Прикрепление файлов +- Голосовые сообщения +- История переписки + +--- + +## 13. УПРАВЛЕНИЕ СОТРУДНИКАМИ (СТАРОЕ) + +### 13.1 Статусы сотрудников + +``` +ACTIVE - Активный +VACATION - В отпуске +SICK - На больничном +FIRED - Уволен +``` + +### 13.2 Система расписания + +``` +WORK - Рабочий день +WEEKEND - Выходной +VACATION - Отпуск +SICK - Больничный +ABSENT - Отсутствие +``` + +--- + +## 14. СИСТЕМА УСЛУГ И РАСХОДНИКОВ + +### 14.1 Типы продуктов + +``` +PRODUCT - Основной товар +CONSUMABLE - Расходные материалы +``` + +**ВАЖНОЕ ПРАВИЛО**: В системе существуют два различных типа расходников: + +- **Расходники селлера** - расходные материалы, которые заказывает селлер для своих нужд +- **Расходники фулфилмента** - расходные материалы, которые заказывает фулфилмент-центр для своих операций + +Эти типы расходников имеют разные процессы заказа, разных получателей и разное назначение. В системе не должно быть просто "расходники" без указания принадлежности. + +### 14.2 Услуги фулфилмента + +- Приемка товара +- Хранение +- Упаковка +- Отгрузка +- Контроль качества + +--- + +## 15. СИСТЕМА УВЕДОМЛЕНИЙ И СТАТУСОВ + +### 15.1 Отслеживание изменений + +- Все изменения статусов синхронизируются между кабинетами +- Участники процесса получают уведомления о важных событиях +- История изменений сохраняется для аудита + +### 15.2 Критические точки процесса + +1. Одобрение заявки поставщиком +2. Подтверждение логистикой +3. Приемка товара на фулфилменте +4. Завершение обработки товара + +--- + +## 16. БЕЗОПАСНОСТЬ И ДОСТУПЫ + +### 16.1 Разграничение доступа + +- Каждый тип организации видит только свои разделы +- Общие модули (мессенджер, партнеры, настройки) доступны всем +- Админ имеет полный доступ к системе + +### 16.2 Защита данных + +- JWT токены с ограниченным временем жизни +- Валидация всех входящих данных +- Логирование критических операций + +--- + +## 17. ИНИЦИАЛИЗАЦИЯ СИСТЕМЫ + +### 17.1 Автоматическая настройка + +При первом запуске система автоматически: + +- Создает админа (admin/admin123) +- Инициализирует 20 базовых категорий товаров +- Настраивает базовую структуру БД + +### 17.2 Команды управления + +```bash +npm run db:seed # Инициализация БД +npm run db:reset # Полный сброс БД +npm run postinstall # Генерация Prisma Client +``` + +--- + +## 18. ОСОБЕННОСТИ РЕАЛИЗАЦИИ + +### 18.1 Адаптивность + +- Система поддерживает различные размеры экранов +- Мобильная версия для основных функций +- Прогрессивные веб-приложения (PWA) возможности + +### 18.2 Производительность + +- Кэширование данных маркетплейсов +- Оптимизированные GraphQL запросы +- Ленивая загрузка компонентов + +### 18.3 Масштабируемость + +- Модульная архитектура +- Возможность добавления новых типов организаций +- Расширяемая система интеграций + +--- + +## 19. КРИТИЧЕСКИ ВАЖНЫЕ МОМЕНТЫ + +### 19.1 Синхронизация статусов + +Система должна обеспечивать консистентность данных между всеми участниками процесса. При изменении статуса в одном кабинете, изменения должны отражаться во всех связанных кабинетах. + +### 19.2 Обработка ошибок API + +Все внешние интеграции должны иметь fallback механизмы и graceful degradation при недоступности внешних сервисов. + +### 19.3 Аудит операций + +Все критические операции (создание заказов, изменение статусов, финансовые операции) должны логироваться для возможности аудита. + +### 19.4 Разделение типов расходников + +**КРИТИЧЕСКИ ВАЖНО**: В системе должно быть четкое разделение между расходниками селлера и расходниками фулфилмента: + +- **Расходники селлера**: Заказываются селлером, доставляются в фулфилмент-центр, используются для товаров селлера +- **Расходники фулфилмента**: Заказываются фулфилмент-центром, доставляются на склад фулфилмента, используются для внутренних операций фулфилмента + +Недопустимо использование общего термина "расходники" без указания принадлежности. Это может привести к ошибкам в учете, доставке и использовании материалов. + +--- + +**ВНИМАНИЕ**: Данная логика является основой для всех разработок в системе. +Любые изменения в бизнес-процессах должны быть отражены в этом документе. +Разработчики должны использовать этот документ как источник истины при реализации новых функций. + +--- + +_Документ создан: $(date)_ +_Версия системы: Sfera V_ +_Статус: ЗАЩИЩЕН ОТ ИЗМЕНЕНИЙ БЕЗ РАЗРЕШЕНИЯ_