Merge pull request 'Refactor: Replace wholesaler with supplier terminology and add fulfillment consumables logic' (#1) from testing into main
Reviewed-on: #1
This commit is contained in:
@ -209,6 +209,7 @@ model Supply {
|
|||||||
supplier String @default("Не указан")
|
supplier String @default("Не указан")
|
||||||
minStock Int @default(0)
|
minStock Int @default(0)
|
||||||
currentStock Int @default(0)
|
currentStock Int @default(0)
|
||||||
|
usedStock Int @default(0) // Количество использованных расходников
|
||||||
imageUrl String?
|
imageUrl String?
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
@ -1,34 +1,33 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server'
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import { clickStorage } from '@/lib/click-storage'
|
import { clickStorage } from "@/lib/click-storage";
|
||||||
|
|
||||||
export async function GET(
|
export async function GET(
|
||||||
request: NextRequest,
|
request: NextRequest,
|
||||||
{ params }: { params: { linkId: string } }
|
{ params }: { params: { linkId: string } }
|
||||||
) {
|
) {
|
||||||
const { linkId } = params
|
const { linkId } = params;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Получаем целевую ссылку из параметров
|
// Получаем целевую ссылку из параметров
|
||||||
const redirectUrl = request.nextUrl.searchParams.get('redirect')
|
const redirectUrl = request.nextUrl.searchParams.get("redirect");
|
||||||
|
|
||||||
if (!redirectUrl) {
|
if (!redirectUrl) {
|
||||||
console.error(`No redirect URL for link: ${linkId}`)
|
console.error(`No redirect URL for link: ${linkId}`);
|
||||||
return NextResponse.redirect(new URL('/', request.url))
|
return NextResponse.redirect(new URL("/", request.url));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Декодируем URL
|
// Декодируем URL
|
||||||
const decodedUrl = decodeURIComponent(redirectUrl)
|
const decodedUrl = decodeURIComponent(redirectUrl);
|
||||||
|
|
||||||
// Записываем клик через общий storage
|
// Записываем клик через общий storage
|
||||||
const totalClicks = clickStorage.recordClick(linkId)
|
const totalClicks = clickStorage.recordClick(linkId);
|
||||||
|
|
||||||
console.log(`Redirect: ${linkId} -> ${decodedUrl} (click #${totalClicks})`)
|
console.log(`Redirect: ${linkId} -> ${decodedUrl} (click #${totalClicks})`);
|
||||||
|
|
||||||
// Мгновенный серверный редирект на целевую ссылку
|
// Мгновенный серверный редирект на целевую ссылку
|
||||||
return NextResponse.redirect(decodedUrl)
|
return NextResponse.redirect(decodedUrl);
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error processing tracking link:', error)
|
console.error("Error processing tracking link:", error);
|
||||||
return NextResponse.redirect(new URL('/', request.url))
|
return NextResponse.redirect(new URL("/", request.url));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -359,7 +359,7 @@ export function FulfillmentWarehouse2Demo() {
|
|||||||
description="К обработке"
|
description="К обработке"
|
||||||
/>
|
/>
|
||||||
<StatCard
|
<StatCard
|
||||||
title="Расходники ФФ"
|
title="Расходники фулфилмента"
|
||||||
icon={Wrench}
|
icon={Wrench}
|
||||||
current={warehouseStats.fulfillmentSupplies.current}
|
current={warehouseStats.fulfillmentSupplies.current}
|
||||||
change={warehouseStats.fulfillmentSupplies.change}
|
change={warehouseStats.fulfillmentSupplies.change}
|
||||||
|
@ -1,29 +1,32 @@
|
|||||||
"use client"
|
"use client";
|
||||||
|
|
||||||
import { useAuth } from '@/hooks/useAuth'
|
import { useAuth } from "@/hooks/useAuth";
|
||||||
import { useMutation } from '@apollo/client'
|
import { useMutation } from "@apollo/client";
|
||||||
import { UPDATE_USER_PROFILE, UPDATE_ORGANIZATION_BY_INN } from '@/graphql/mutations'
|
import {
|
||||||
import { GET_ME } from '@/graphql/queries'
|
UPDATE_USER_PROFILE,
|
||||||
import { apolloClient } from '@/lib/apollo-client'
|
UPDATE_ORGANIZATION_BY_INN,
|
||||||
import { formatPhone } from '@/lib/utils'
|
} from "@/graphql/mutations";
|
||||||
import S3Service from '@/services/s3-service'
|
import { GET_ME } from "@/graphql/queries";
|
||||||
import { Card } from '@/components/ui/card'
|
import { apolloClient } from "@/lib/apollo-client";
|
||||||
import { Avatar, AvatarFallback } from '@/components/ui/avatar'
|
import { formatPhone } from "@/lib/utils";
|
||||||
import { Button } from '@/components/ui/button'
|
import S3Service from "@/services/s3-service";
|
||||||
import { Input } from '@/components/ui/input'
|
import { Card } from "@/components/ui/card";
|
||||||
import { Label } from '@/components/ui/label'
|
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
|
||||||
import { Badge } from '@/components/ui/badge'
|
import { Button } from "@/components/ui/button";
|
||||||
import { Alert, AlertDescription } from '@/components/ui/alert'
|
import { Input } from "@/components/ui/input";
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
import { Label } from "@/components/ui/label";
|
||||||
import { Sidebar } from './sidebar'
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { useSidebar } from '@/hooks/useSidebar'
|
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||||
import {
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
User,
|
import { Sidebar } from "./sidebar";
|
||||||
Building2,
|
import { useSidebar } from "@/hooks/useSidebar";
|
||||||
Phone,
|
import {
|
||||||
Mail,
|
User,
|
||||||
MapPin,
|
Building2,
|
||||||
CreditCard,
|
Phone,
|
||||||
|
Mail,
|
||||||
|
MapPin,
|
||||||
|
CreditCard,
|
||||||
Key,
|
Key,
|
||||||
Edit3,
|
Edit3,
|
||||||
ExternalLink,
|
ExternalLink,
|
||||||
@ -35,649 +38,769 @@ import {
|
|||||||
RefreshCw,
|
RefreshCw,
|
||||||
Calendar,
|
Calendar,
|
||||||
Settings,
|
Settings,
|
||||||
Camera
|
Camera,
|
||||||
} from 'lucide-react'
|
} from "lucide-react";
|
||||||
import { useState, useEffect, useRef } from 'react'
|
import { useState, useEffect, useRef } from "react";
|
||||||
import Image from 'next/image'
|
import Image from "next/image";
|
||||||
|
|
||||||
export function UserSettings() {
|
export function UserSettings() {
|
||||||
const { getSidebarMargin } = useSidebar()
|
const { getSidebarMargin } = useSidebar();
|
||||||
const { user, updateUser } = useAuth()
|
const { user, updateUser } = useAuth();
|
||||||
const [updateUserProfile, { loading: isSaving }] = useMutation(UPDATE_USER_PROFILE)
|
const [updateUserProfile, { loading: isSaving }] =
|
||||||
const [updateOrganizationByInn, { loading: isUpdatingOrganization }] = useMutation(UPDATE_ORGANIZATION_BY_INN)
|
useMutation(UPDATE_USER_PROFILE);
|
||||||
const [isEditing, setIsEditing] = useState(false)
|
const [updateOrganizationByInn, { loading: isUpdatingOrganization }] =
|
||||||
const [saveMessage, setSaveMessage] = useState<{ type: 'success' | 'error', text: string } | null>(null)
|
useMutation(UPDATE_ORGANIZATION_BY_INN);
|
||||||
const [partnerLink, setPartnerLink] = useState('')
|
const [isEditing, setIsEditing] = useState(false);
|
||||||
const [isGenerating, setIsGenerating] = useState(false)
|
const [saveMessage, setSaveMessage] = useState<{
|
||||||
const [isUploadingAvatar, setIsUploadingAvatar] = useState(false)
|
type: "success" | "error";
|
||||||
const [localAvatarUrl, setLocalAvatarUrl] = useState<string | null>(null)
|
text: string;
|
||||||
const phoneInputRef = useRef<HTMLInputElement>(null)
|
} | null>(null);
|
||||||
const whatsappInputRef = useRef<HTMLInputElement>(null)
|
const [partnerLink, setPartnerLink] = useState("");
|
||||||
|
const [isGenerating, setIsGenerating] = useState(false);
|
||||||
|
const [isUploadingAvatar, setIsUploadingAvatar] = useState(false);
|
||||||
|
const [localAvatarUrl, setLocalAvatarUrl] = useState<string | null>(null);
|
||||||
|
const phoneInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const whatsappInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
// Инициализируем данные из пользователя и организации
|
// Инициализируем данные из пользователя и организации
|
||||||
const [formData, setFormData] = useState({
|
const [formData, setFormData] = useState({
|
||||||
// Контактные данные организации
|
// Контактные данные организации
|
||||||
orgPhone: '', // телефон организации, не пользователя
|
orgPhone: "", // телефон организации, не пользователя
|
||||||
managerName: '',
|
managerName: "",
|
||||||
telegram: '',
|
telegram: "",
|
||||||
whatsapp: '',
|
whatsapp: "",
|
||||||
email: '',
|
email: "",
|
||||||
|
|
||||||
// Организация - данные могут быть заполнены из DaData
|
// Организация - данные могут быть заполнены из DaData
|
||||||
orgName: '',
|
orgName: "",
|
||||||
address: '',
|
address: "",
|
||||||
|
|
||||||
// Юридические данные - могут быть заполнены из DaData
|
// Юридические данные - могут быть заполнены из DaData
|
||||||
fullName: '',
|
fullName: "",
|
||||||
inn: '',
|
inn: "",
|
||||||
ogrn: '',
|
ogrn: "",
|
||||||
registrationPlace: '',
|
registrationPlace: "",
|
||||||
|
|
||||||
// Финансовые данные - требуют ручного заполнения
|
// Финансовые данные - требуют ручного заполнения
|
||||||
bankName: '',
|
bankName: "",
|
||||||
bik: '',
|
bik: "",
|
||||||
accountNumber: '',
|
accountNumber: "",
|
||||||
corrAccount: '',
|
corrAccount: "",
|
||||||
|
|
||||||
// API ключи маркетплейсов
|
// API ключи маркетплейсов
|
||||||
wildberriesApiKey: '',
|
wildberriesApiKey: "",
|
||||||
ozonApiKey: ''
|
ozonApiKey: "",
|
||||||
})
|
});
|
||||||
|
|
||||||
// Загружаем данные организации при монтировании компонента
|
// Загружаем данные организации при монтировании компонента
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (user?.organization) {
|
if (user?.organization) {
|
||||||
const org = user.organization
|
const org = user.organization;
|
||||||
|
|
||||||
// Извлекаем первый телефон из phones JSON
|
// Извлекаем первый телефон из phones JSON
|
||||||
let orgPhone = ''
|
let orgPhone = "";
|
||||||
if (org.phones && Array.isArray(org.phones) && org.phones.length > 0) {
|
if (org.phones && Array.isArray(org.phones) && org.phones.length > 0) {
|
||||||
orgPhone = org.phones[0].value || org.phones[0] || ''
|
orgPhone = org.phones[0].value || org.phones[0] || "";
|
||||||
} else if (org.phones && typeof org.phones === 'object') {
|
} else if (org.phones && typeof org.phones === "object") {
|
||||||
const phoneValues = Object.values(org.phones)
|
const phoneValues = Object.values(org.phones);
|
||||||
if (phoneValues.length > 0) {
|
if (phoneValues.length > 0) {
|
||||||
orgPhone = String(phoneValues[0])
|
orgPhone = String(phoneValues[0]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Извлекаем email из emails JSON
|
// Извлекаем email из emails JSON
|
||||||
let email = ''
|
let email = "";
|
||||||
if (org.emails && Array.isArray(org.emails) && org.emails.length > 0) {
|
if (org.emails && Array.isArray(org.emails) && org.emails.length > 0) {
|
||||||
email = org.emails[0].value || org.emails[0] || ''
|
email = org.emails[0].value || org.emails[0] || "";
|
||||||
} else if (org.emails && typeof org.emails === 'object') {
|
} else if (org.emails && typeof org.emails === "object") {
|
||||||
const emailValues = Object.values(org.emails)
|
const emailValues = Object.values(org.emails);
|
||||||
if (emailValues.length > 0) {
|
if (emailValues.length > 0) {
|
||||||
email = String(emailValues[0])
|
email = String(emailValues[0]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Извлекаем дополнительные данные из managementPost (JSON)
|
// Извлекаем дополнительные данные из managementPost (JSON)
|
||||||
let customContacts: {
|
let customContacts: {
|
||||||
managerName?: string
|
managerName?: string;
|
||||||
telegram?: string
|
telegram?: string;
|
||||||
whatsapp?: string
|
whatsapp?: string;
|
||||||
bankDetails?: {
|
bankDetails?: {
|
||||||
bankName?: string
|
bankName?: string;
|
||||||
bik?: string
|
bik?: string;
|
||||||
accountNumber?: string
|
accountNumber?: string;
|
||||||
corrAccount?: string
|
corrAccount?: string;
|
||||||
}
|
};
|
||||||
} = {}
|
} = {};
|
||||||
try {
|
try {
|
||||||
if (org.managementPost && typeof org.managementPost === 'string') {
|
if (org.managementPost && typeof org.managementPost === "string") {
|
||||||
customContacts = JSON.parse(org.managementPost)
|
customContacts = JSON.parse(org.managementPost);
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn('Ошибка парсинга managementPost:', e)
|
console.warn("Ошибка парсинга managementPost:", e);
|
||||||
}
|
}
|
||||||
|
|
||||||
setFormData({
|
setFormData({
|
||||||
orgPhone: orgPhone || '+7',
|
orgPhone: orgPhone || "+7",
|
||||||
managerName: user?.managerName || '',
|
managerName: user?.managerName || "",
|
||||||
telegram: customContacts?.telegram || '',
|
telegram: customContacts?.telegram || "",
|
||||||
whatsapp: customContacts?.whatsapp || '',
|
whatsapp: customContacts?.whatsapp || "",
|
||||||
email: email,
|
email: email,
|
||||||
orgName: org.name || '',
|
orgName: org.name || "",
|
||||||
address: org.address || '',
|
address: org.address || "",
|
||||||
fullName: org.fullName || '',
|
fullName: org.fullName || "",
|
||||||
inn: org.inn || '',
|
inn: org.inn || "",
|
||||||
ogrn: org.ogrn || '',
|
ogrn: org.ogrn || "",
|
||||||
registrationPlace: org.address || '',
|
registrationPlace: org.address || "",
|
||||||
bankName: customContacts?.bankDetails?.bankName || '',
|
bankName: customContacts?.bankDetails?.bankName || "",
|
||||||
bik: customContacts?.bankDetails?.bik || '',
|
bik: customContacts?.bankDetails?.bik || "",
|
||||||
accountNumber: customContacts?.bankDetails?.accountNumber || '',
|
accountNumber: customContacts?.bankDetails?.accountNumber || "",
|
||||||
corrAccount: customContacts?.bankDetails?.corrAccount || '',
|
corrAccount: customContacts?.bankDetails?.corrAccount || "",
|
||||||
wildberriesApiKey: '',
|
wildberriesApiKey: "",
|
||||||
ozonApiKey: ''
|
ozonApiKey: "",
|
||||||
})
|
});
|
||||||
}
|
}
|
||||||
}, [user])
|
}, [user]);
|
||||||
|
|
||||||
const getInitials = () => {
|
const getInitials = () => {
|
||||||
const orgName = user?.organization?.name || user?.organization?.fullName
|
const orgName = user?.organization?.name || user?.organization?.fullName;
|
||||||
if (orgName) {
|
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 = () => {
|
const getCabinetTypeName = () => {
|
||||||
if (!user?.organization?.type) return 'Не указан'
|
if (!user?.organization?.type) return "Не указан";
|
||||||
|
|
||||||
switch (user.organization.type) {
|
switch (user.organization.type) {
|
||||||
case 'FULFILLMENT':
|
case "FULFILLMENT":
|
||||||
return 'Фулфилмент'
|
return "Фулфилмент";
|
||||||
case 'SELLER':
|
case "SELLER":
|
||||||
return 'Селлер'
|
return "Селлер";
|
||||||
case 'LOGIST':
|
case "LOGIST":
|
||||||
return 'Логистика'
|
return "Логистика";
|
||||||
case 'WHOLESALE':
|
case "WHOLESALE":
|
||||||
return 'Поставщик'
|
return "Поставщик";
|
||||||
default:
|
default:
|
||||||
return 'Не указан'
|
return "Не указан";
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
// Обновленная функция для проверки заполненности профиля
|
// Обновленная функция для проверки заполненности профиля
|
||||||
const checkProfileCompleteness = () => {
|
const checkProfileCompleteness = () => {
|
||||||
// Базовые поля (обязательные для всех)
|
// Базовые поля (обязательные для всех)
|
||||||
const baseFields = [
|
const baseFields = [
|
||||||
{ field: 'orgPhone', label: 'Телефон организации', value: formData.orgPhone },
|
{
|
||||||
{ field: 'managerName', label: 'Имя управляющего', value: formData.managerName },
|
field: "orgPhone",
|
||||||
{ field: 'email', label: 'Email', value: formData.email }
|
label: "Телефон организации",
|
||||||
]
|
value: formData.orgPhone,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
field: "managerName",
|
||||||
|
label: "Имя управляющего",
|
||||||
|
value: formData.managerName,
|
||||||
|
},
|
||||||
|
{ field: "email", label: "Email", value: formData.email },
|
||||||
|
];
|
||||||
|
|
||||||
// Дополнительные поля в зависимости от типа кабинета
|
// Дополнительные поля в зависимости от типа кабинета
|
||||||
const additionalFields = []
|
const additionalFields = [];
|
||||||
if (user?.organization?.type === 'FULFILLMENT' || user?.organization?.type === 'LOGIST' || user?.organization?.type === 'WHOLESALE' || user?.organization?.type === 'SELLER') {
|
if (
|
||||||
|
user?.organization?.type === "FULFILLMENT" ||
|
||||||
|
user?.organization?.type === "LOGIST" ||
|
||||||
|
user?.organization?.type === "WHOLESALE" ||
|
||||||
|
user?.organization?.type === "SELLER"
|
||||||
|
) {
|
||||||
// Финансовые данные - всегда обязательны для всех типов кабинетов
|
// Финансовые данные - всегда обязательны для всех типов кабинетов
|
||||||
additionalFields.push(
|
additionalFields.push(
|
||||||
{ field: 'bankName', label: 'Название банка', value: formData.bankName },
|
{
|
||||||
{ field: 'bik', label: 'БИК', value: formData.bik },
|
field: "bankName",
|
||||||
{ field: 'accountNumber', label: 'Расчетный счет', value: formData.accountNumber },
|
label: "Название банка",
|
||||||
{ field: 'corrAccount', label: 'Корр. счет', value: formData.corrAccount }
|
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 allRequiredFields = [...baseFields, ...additionalFields];
|
||||||
const filledRequiredFields = allRequiredFields.filter(field => field.value && field.value.trim() !== '').length
|
const filledRequiredFields = allRequiredFields.filter(
|
||||||
|
(field) => field.value && field.value.trim() !== ""
|
||||||
|
).length;
|
||||||
|
|
||||||
// Подсчитываем бонусные баллы за автоматически заполненные поля
|
// Подсчитываем бонусные баллы за автоматически заполненные поля
|
||||||
let autoFilledFields = 0
|
let autoFilledFields = 0;
|
||||||
let totalAutoFields = 0
|
let totalAutoFields = 0;
|
||||||
|
|
||||||
// Номер телефона пользователя для авторизации (не считаем в процентах заполненности)
|
// Номер телефона пользователя для авторизации (не считаем в процентах заполненности)
|
||||||
// Телефон организации учитывается отдельно как обычное поле
|
// Телефон организации учитывается отдельно как обычное поле
|
||||||
|
|
||||||
// Данные организации из DaData (если есть ИНН)
|
// Данные организации из DaData (если есть ИНН)
|
||||||
if (formData.inn || user?.organization?.inn) {
|
if (formData.inn || user?.organization?.inn) {
|
||||||
totalAutoFields += 5 // ИНН + название + адрес + полное название + ОГРН
|
totalAutoFields += 5; // ИНН + название + адрес + полное название + ОГРН
|
||||||
|
|
||||||
if (formData.inn || user?.organization?.inn) autoFilledFields += 1 // ИНН
|
if (formData.inn || user?.organization?.inn) autoFilledFields += 1; // ИНН
|
||||||
if (formData.orgName || user?.organization?.name) autoFilledFields += 1 // Название
|
if (formData.orgName || user?.organization?.name) autoFilledFields += 1; // Название
|
||||||
if (formData.address || user?.organization?.address) autoFilledFields += 1 // Адрес
|
if (formData.address || user?.organization?.address)
|
||||||
if (formData.fullName || user?.organization?.fullName) autoFilledFields += 1 // Полное название
|
autoFilledFields += 1; // Адрес
|
||||||
if (formData.ogrn || user?.organization?.ogrn) autoFilledFields += 1 // ОГРН
|
if (formData.fullName || user?.organization?.fullName)
|
||||||
|
autoFilledFields += 1; // Полное название
|
||||||
|
if (formData.ogrn || user?.organization?.ogrn) autoFilledFields += 1; // ОГРН
|
||||||
}
|
}
|
||||||
|
|
||||||
// Место регистрации
|
// Место регистрации
|
||||||
if (formData.registrationPlace || user?.organization?.registrationDate) {
|
if (formData.registrationPlace || user?.organization?.registrationDate) {
|
||||||
autoFilledFields += 1
|
autoFilledFields += 1;
|
||||||
totalAutoFields += 1
|
totalAutoFields += 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
const totalPossibleFields = allRequiredFields.length + totalAutoFields
|
const totalPossibleFields = allRequiredFields.length + totalAutoFields;
|
||||||
const totalFilledFields = filledRequiredFields + autoFilledFields
|
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)
|
|
||||||
|
|
||||||
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()
|
return { percentage, missingFields };
|
||||||
const isIncomplete = profileStatus.percentage < 100
|
};
|
||||||
|
|
||||||
|
const profileStatus = checkProfileCompleteness();
|
||||||
|
const isIncomplete = profileStatus.percentage < 100;
|
||||||
|
|
||||||
const generatePartnerLink = async () => {
|
const generatePartnerLink = async () => {
|
||||||
if (!user?.id) return
|
if (!user?.id) return;
|
||||||
|
|
||||||
setIsGenerating(true)
|
setIsGenerating(true);
|
||||||
setSaveMessage(null)
|
setSaveMessage(null);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Генерируем уникальный код партнера
|
// Генерируем уникальный код партнера
|
||||||
const partnerCode = btoa(user.id + Date.now()).replace(/[^a-zA-Z0-9]/g, '').substring(0, 12)
|
const partnerCode = btoa(user.id + Date.now())
|
||||||
const link = `${window.location.origin}/register?partner=${partnerCode}`
|
.replace(/[^a-zA-Z0-9]/g, "")
|
||||||
|
.substring(0, 12);
|
||||||
setPartnerLink(link)
|
const link = `${window.location.origin}/register?partner=${partnerCode}`;
|
||||||
setSaveMessage({ type: 'success', text: 'Партнерская ссылка сгенерирована!' })
|
|
||||||
|
setPartnerLink(link);
|
||||||
|
setSaveMessage({
|
||||||
|
type: "success",
|
||||||
|
text: "Партнерская ссылка сгенерирована!",
|
||||||
|
});
|
||||||
|
|
||||||
// TODO: Сохранить партнерский код в базе данных
|
// TODO: Сохранить партнерский код в базе данных
|
||||||
console.log('Partner code generated:', partnerCode)
|
console.log("Partner code generated:", partnerCode);
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error generating partner link:', error)
|
console.error("Error generating partner link:", error);
|
||||||
setSaveMessage({ type: 'error', text: 'Ошибка при генерации ссылки' })
|
setSaveMessage({ type: "error", text: "Ошибка при генерации ссылки" });
|
||||||
} finally {
|
} finally {
|
||||||
setIsGenerating(false)
|
setIsGenerating(false);
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
const handleCopyLink = async () => {
|
const handleCopyLink = async () => {
|
||||||
if (!partnerLink) {
|
if (!partnerLink) {
|
||||||
await generatePartnerLink()
|
await generatePartnerLink();
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await navigator.clipboard.writeText(partnerLink)
|
await navigator.clipboard.writeText(partnerLink);
|
||||||
setSaveMessage({ type: 'success', text: 'Ссылка скопирована!' })
|
setSaveMessage({ type: "success", text: "Ссылка скопирована!" });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error copying to clipboard:', error)
|
console.error("Error copying to clipboard:", error);
|
||||||
setSaveMessage({ type: 'error', text: 'Ошибка при копировании' })
|
setSaveMessage({ type: "error", text: "Ошибка при копировании" });
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
const handleOpenLink = async () => {
|
const handleOpenLink = async () => {
|
||||||
if (!partnerLink) {
|
if (!partnerLink) {
|
||||||
await generatePartnerLink()
|
await generatePartnerLink();
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
window.open(partnerLink, '_blank')
|
window.open(partnerLink, "_blank");
|
||||||
}
|
};
|
||||||
|
|
||||||
const handleAvatarUpload = async (event: React.ChangeEvent<HTMLInputElement>) => {
|
const handleAvatarUpload = async (
|
||||||
const file = event.target.files?.[0]
|
event: React.ChangeEvent<HTMLInputElement>
|
||||||
if (!file || !user?.id) return
|
) => {
|
||||||
|
const file = event.target.files?.[0];
|
||||||
|
if (!file || !user?.id) return;
|
||||||
|
|
||||||
setIsUploadingAvatar(true)
|
setIsUploadingAvatar(true);
|
||||||
setSaveMessage(null)
|
setSaveMessage(null);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const avatarUrl = await S3Service.uploadAvatar(file, user.id)
|
const avatarUrl = await S3Service.uploadAvatar(file, user.id);
|
||||||
|
|
||||||
// Сразу обновляем локальное состояние для мгновенного отображения
|
// Сразу обновляем локальное состояние для мгновенного отображения
|
||||||
setLocalAvatarUrl(avatarUrl)
|
setLocalAvatarUrl(avatarUrl);
|
||||||
|
|
||||||
// Обновляем аватар пользователя через GraphQL
|
// Обновляем аватар пользователя через GraphQL
|
||||||
const result = await updateUserProfile({
|
const result = await updateUserProfile({
|
||||||
variables: {
|
variables: {
|
||||||
input: {
|
input: {
|
||||||
avatar: avatarUrl
|
avatar: avatarUrl,
|
||||||
}
|
},
|
||||||
},
|
},
|
||||||
update: (cache, { data }) => {
|
update: (cache, { data }) => {
|
||||||
if (data?.updateUserProfile?.success) {
|
if (data?.updateUserProfile?.success) {
|
||||||
// Обновляем кеш Apollo Client
|
// Обновляем кеш Apollo Client
|
||||||
try {
|
try {
|
||||||
const existingData: any = cache.readQuery({ query: GET_ME })
|
const existingData: any = cache.readQuery({ query: GET_ME });
|
||||||
if (existingData?.me) {
|
if (existingData?.me) {
|
||||||
cache.writeQuery({
|
cache.writeQuery({
|
||||||
query: GET_ME,
|
query: GET_ME,
|
||||||
data: {
|
data: {
|
||||||
me: {
|
me: {
|
||||||
...existingData.me,
|
...existingData.me,
|
||||||
avatar: avatarUrl
|
avatar: avatarUrl,
|
||||||
}
|
},
|
||||||
}
|
},
|
||||||
})
|
});
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.log('Cache update error:', error)
|
console.log("Cache update error:", error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
})
|
});
|
||||||
|
|
||||||
if (result.data?.updateUserProfile?.success) {
|
if (result.data?.updateUserProfile?.success) {
|
||||||
setSaveMessage({ type: 'success', text: 'Аватар успешно обновлен!' })
|
setSaveMessage({ type: "success", text: "Аватар успешно обновлен!" });
|
||||||
|
|
||||||
// Обновляем локальное состояние в useAuth для мгновенного отображения в сайдбаре
|
// Обновляем локальное состояние в useAuth для мгновенного отображения в сайдбаре
|
||||||
updateUser({ avatar: avatarUrl })
|
updateUser({ avatar: avatarUrl });
|
||||||
|
|
||||||
// Принудительно обновляем Apollo Client кеш
|
// Принудительно обновляем Apollo Client кеш
|
||||||
await apolloClient.refetchQueries({
|
await apolloClient.refetchQueries({
|
||||||
include: [GET_ME]
|
include: [GET_ME],
|
||||||
})
|
});
|
||||||
|
|
||||||
// Очищаем input файла
|
// Очищаем input файла
|
||||||
if (event.target) {
|
if (event.target) {
|
||||||
event.target.value = ''
|
event.target.value = "";
|
||||||
}
|
}
|
||||||
|
|
||||||
// Очищаем сообщение через 3 секунды
|
// Очищаем сообщение через 3 секунды
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
setSaveMessage(null)
|
setSaveMessage(null);
|
||||||
}, 3000)
|
}, 3000);
|
||||||
} else {
|
} 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) {
|
} catch (error) {
|
||||||
console.error('Error uploading avatar:', error)
|
console.error("Error uploading avatar:", error);
|
||||||
// Сбрасываем локальное состояние при ошибке
|
// Сбрасываем локальное состояние при ошибке
|
||||||
setLocalAvatarUrl(null)
|
setLocalAvatarUrl(null);
|
||||||
const errorMessage = error instanceof Error ? error.message : 'Ошибка при загрузке аватара'
|
const errorMessage =
|
||||||
setSaveMessage({ type: 'error', text: errorMessage })
|
error instanceof Error ? error.message : "Ошибка при загрузке аватара";
|
||||||
|
setSaveMessage({ type: "error", text: errorMessage });
|
||||||
// Очищаем сообщение об ошибке через 5 секунд
|
// Очищаем сообщение об ошибке через 5 секунд
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
setSaveMessage(null)
|
setSaveMessage(null);
|
||||||
}, 5000)
|
}, 5000);
|
||||||
} finally {
|
} finally {
|
||||||
setIsUploadingAvatar(false)
|
setIsUploadingAvatar(false);
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
// Функции для валидации и масок
|
// Функции для валидации и масок
|
||||||
const validateEmail = (email: string) => {
|
const validateEmail = (email: string) => {
|
||||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
|
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||||
return emailRegex.test(email)
|
return emailRegex.test(email);
|
||||||
}
|
};
|
||||||
|
|
||||||
const formatPhoneInput = (value: string, isOptional: boolean = false) => {
|
const formatPhoneInput = (value: string, isOptional: boolean = false) => {
|
||||||
// Убираем все нецифровые символы
|
// Убираем все нецифровые символы
|
||||||
const digitsOnly = value.replace(/\D/g, '')
|
const digitsOnly = value.replace(/\D/g, "");
|
||||||
|
|
||||||
// Если строка пустая
|
// Если строка пустая
|
||||||
if (!digitsOnly) {
|
if (!digitsOnly) {
|
||||||
// Для необязательных полей возвращаем пустую строку
|
// Для необязательных полей возвращаем пустую строку
|
||||||
if (isOptional) return ''
|
if (isOptional) return "";
|
||||||
// Для обязательных полей возвращаем +7
|
// Для обязательных полей возвращаем +7
|
||||||
return '+7'
|
return "+7";
|
||||||
}
|
}
|
||||||
|
|
||||||
// Если пользователь ввел первую цифру не 7, добавляем 7 перед ней
|
|
||||||
let cleaned = digitsOnly
|
|
||||||
if (!cleaned.startsWith('7')) {
|
|
||||||
cleaned = '7' + cleaned
|
|
||||||
}
|
|
||||||
|
|
||||||
// Ограничиваем до 11 цифр (7 + 10 цифр номера)
|
|
||||||
cleaned = cleaned.slice(0, 11)
|
|
||||||
|
|
||||||
// Форматируем в зависимости от длины
|
|
||||||
if (cleaned.length <= 1) return isOptional && cleaned === '7' ? '' : '+7'
|
|
||||||
if (cleaned.length <= 4) return `+7 (${cleaned.slice(1)}`
|
|
||||||
if (cleaned.length <= 7) return `+7 (${cleaned.slice(1, 4)}) ${cleaned.slice(4)}`
|
|
||||||
if (cleaned.length <= 9) return `+7 (${cleaned.slice(1, 4)}) ${cleaned.slice(4, 7)}-${cleaned.slice(7)}`
|
|
||||||
if (cleaned.length <= 11) return `+7 (${cleaned.slice(1, 4)}) ${cleaned.slice(4, 7)}-${cleaned.slice(7, 9)}-${cleaned.slice(9)}`
|
|
||||||
|
|
||||||
return `+7 (${cleaned.slice(1, 4)}) ${cleaned.slice(4, 7)}-${cleaned.slice(7, 9)}-${cleaned.slice(9, 11)}`
|
|
||||||
}
|
|
||||||
|
|
||||||
const handlePhoneInputChange = (field: string, value: string, inputRef?: React.RefObject<HTMLInputElement>, isOptional: boolean = false) => {
|
// Если пользователь ввел первую цифру не 7, добавляем 7 перед ней
|
||||||
const currentInput = inputRef?.current
|
let cleaned = digitsOnly;
|
||||||
const currentCursorPosition = currentInput?.selectionStart || 0
|
if (!cleaned.startsWith("7")) {
|
||||||
const currentValue = formData[field as keyof typeof formData] as string || ''
|
cleaned = "7" + cleaned;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ограничиваем до 11 цифр (7 + 10 цифр номера)
|
||||||
|
cleaned = cleaned.slice(0, 11);
|
||||||
|
|
||||||
|
// Форматируем в зависимости от длины
|
||||||
|
if (cleaned.length <= 1) return isOptional && cleaned === "7" ? "" : "+7";
|
||||||
|
if (cleaned.length <= 4) return `+7 (${cleaned.slice(1)}`;
|
||||||
|
if (cleaned.length <= 7)
|
||||||
|
return `+7 (${cleaned.slice(1, 4)}) ${cleaned.slice(4)}`;
|
||||||
|
if (cleaned.length <= 9)
|
||||||
|
return `+7 (${cleaned.slice(1, 4)}) ${cleaned.slice(
|
||||||
|
4,
|
||||||
|
7
|
||||||
|
)}-${cleaned.slice(7)}`;
|
||||||
|
if (cleaned.length <= 11)
|
||||||
|
return `+7 (${cleaned.slice(1, 4)}) ${cleaned.slice(
|
||||||
|
4,
|
||||||
|
7
|
||||||
|
)}-${cleaned.slice(7, 9)}-${cleaned.slice(9)}`;
|
||||||
|
|
||||||
|
return `+7 (${cleaned.slice(1, 4)}) ${cleaned.slice(4, 7)}-${cleaned.slice(
|
||||||
|
7,
|
||||||
|
9
|
||||||
|
)}-${cleaned.slice(9, 11)}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePhoneInputChange = (
|
||||||
|
field: string,
|
||||||
|
value: string,
|
||||||
|
inputRef?: React.RefObject<HTMLInputElement>,
|
||||||
|
isOptional: boolean = false
|
||||||
|
) => {
|
||||||
|
const currentInput = inputRef?.current;
|
||||||
|
const currentCursorPosition = currentInput?.selectionStart || 0;
|
||||||
|
const currentValue =
|
||||||
|
(formData[field as keyof typeof formData] as string) || "";
|
||||||
|
|
||||||
// Для необязательных полей разрешаем пустое значение
|
// Для необязательных полей разрешаем пустое значение
|
||||||
if (isOptional && value.length < 2) {
|
if (isOptional && value.length < 2) {
|
||||||
const formatted = formatPhoneInput(value, true)
|
const formatted = formatPhoneInput(value, true);
|
||||||
setFormData(prev => ({ ...prev, [field]: formatted }))
|
setFormData((prev) => ({ ...prev, [field]: formatted }));
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Для обязательных полей если пользователь пытается удалить +7, предотвращаем это
|
// Для обязательных полей если пользователь пытается удалить +7, предотвращаем это
|
||||||
if (!isOptional && value.length < 2) {
|
if (!isOptional && value.length < 2) {
|
||||||
value = '+7'
|
value = "+7";
|
||||||
}
|
}
|
||||||
|
|
||||||
const formatted = formatPhoneInput(value, isOptional)
|
const formatted = formatPhoneInput(value, isOptional);
|
||||||
setFormData(prev => ({ ...prev, [field]: formatted }))
|
setFormData((prev) => ({ ...prev, [field]: formatted }));
|
||||||
|
|
||||||
// Вычисляем новую позицию курсора
|
// Вычисляем новую позицию курсора
|
||||||
if (currentInput) {
|
if (currentInput) {
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
let newCursorPosition = currentCursorPosition
|
let newCursorPosition = currentCursorPosition;
|
||||||
|
|
||||||
// Если длина увеличилась (добавили цифру), передвигаем курсор
|
// Если длина увеличилась (добавили цифру), передвигаем курсор
|
||||||
if (formatted.length > currentValue.length) {
|
if (formatted.length > currentValue.length) {
|
||||||
newCursorPosition = currentCursorPosition + (formatted.length - currentValue.length)
|
newCursorPosition =
|
||||||
|
currentCursorPosition + (formatted.length - currentValue.length);
|
||||||
}
|
}
|
||||||
// Если длина уменьшилась (удалили цифру), оставляем курсор на месте или сдвигаем немного
|
// Если длина уменьшилась (удалили цифру), оставляем курсор на месте или сдвигаем немного
|
||||||
else if (formatted.length < currentValue.length) {
|
else if (formatted.length < currentValue.length) {
|
||||||
newCursorPosition = Math.min(currentCursorPosition, formatted.length)
|
newCursorPosition = Math.min(currentCursorPosition, formatted.length);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Не позволяем курсору находиться перед +7
|
// Не позволяем курсору находиться перед +7
|
||||||
newCursorPosition = Math.max(newCursorPosition, 2)
|
newCursorPosition = Math.max(newCursorPosition, 2);
|
||||||
|
|
||||||
// Ограничиваем курсор длиной строки
|
// Ограничиваем курсор длиной строки
|
||||||
newCursorPosition = Math.min(newCursorPosition, formatted.length)
|
newCursorPosition = Math.min(newCursorPosition, formatted.length);
|
||||||
|
|
||||||
currentInput.setSelectionRange(newCursorPosition, newCursorPosition)
|
currentInput.setSelectionRange(newCursorPosition, newCursorPosition);
|
||||||
}, 0)
|
}, 0);
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
const formatTelegram = (value: string) => {
|
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('@')) {
|
if (cleaned && !cleaned.startsWith("@")) {
|
||||||
cleaned = '@' + cleaned
|
cleaned = "@" + cleaned;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ограничиваем длину (максимум 32 символа для Telegram)
|
// Ограничиваем длину (максимум 32 символа для Telegram)
|
||||||
if (cleaned.length > 33) {
|
if (cleaned.length > 33) {
|
||||||
cleaned = cleaned.substring(0, 33)
|
cleaned = cleaned.substring(0, 33);
|
||||||
}
|
}
|
||||||
|
|
||||||
return cleaned
|
return cleaned;
|
||||||
}
|
};
|
||||||
|
|
||||||
const validateName = (name: string) => {
|
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) => {
|
const handleInputChange = (field: string, value: string) => {
|
||||||
let processedValue = value
|
let processedValue = value;
|
||||||
|
|
||||||
// Применяем маски и валидации
|
// Применяем маски и валидации
|
||||||
switch (field) {
|
switch (field) {
|
||||||
case 'orgPhone':
|
case "orgPhone":
|
||||||
case 'whatsapp':
|
case "whatsapp":
|
||||||
processedValue = formatPhoneInput(value)
|
processedValue = formatPhoneInput(value);
|
||||||
break
|
break;
|
||||||
case 'telegram':
|
case "telegram":
|
||||||
processedValue = formatTelegram(value)
|
processedValue = formatTelegram(value);
|
||||||
break
|
break;
|
||||||
case 'email':
|
case "email":
|
||||||
// Для email не применяем маску, только валидацию при потере фокуса
|
// Для email не применяем маску, только валидацию при потере фокуса
|
||||||
break
|
break;
|
||||||
case 'managerName':
|
case "managerName":
|
||||||
// Разрешаем только буквы, пробелы и дефисы
|
// Разрешаем только буквы, пробелы и дефисы
|
||||||
processedValue = value.replace(/[^а-яёА-ЯЁa-zA-Z\s-]/g, '')
|
processedValue = value.replace(/[^а-яёА-ЯЁa-zA-Z\s-]/g, "");
|
||||||
break
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
setFormData(prev => ({ ...prev, [field]: processedValue }))
|
setFormData((prev) => ({ ...prev, [field]: processedValue }));
|
||||||
}
|
};
|
||||||
|
|
||||||
// Функции для проверки ошибок
|
// Функции для проверки ошибок
|
||||||
const getFieldError = (field: string, value: string) => {
|
const getFieldError = (field: string, value: string) => {
|
||||||
if (!isEditing || !value.trim()) return null
|
if (!isEditing || !value.trim()) return null;
|
||||||
|
|
||||||
switch (field) {
|
switch (field) {
|
||||||
case 'email':
|
case "email":
|
||||||
return !validateEmail(value) ? 'Неверный формат email' : null
|
return !validateEmail(value) ? "Неверный формат email" : null;
|
||||||
case 'managerName':
|
case "managerName":
|
||||||
return !validateName(value) ? 'Только буквы, пробелы и дефисы' : null
|
return !validateName(value) ? "Только буквы, пробелы и дефисы" : null;
|
||||||
case 'orgPhone':
|
case "orgPhone":
|
||||||
case 'whatsapp':
|
case "whatsapp":
|
||||||
const cleaned = value.replace(/\D/g, '')
|
const cleaned = value.replace(/\D/g, "");
|
||||||
return cleaned.length !== 11 ? 'Неверный формат телефона' : null
|
return cleaned.length !== 11 ? "Неверный формат телефона" : null;
|
||||||
case 'telegram':
|
case "telegram":
|
||||||
// Проверяем что после @ есть минимум 5 символов
|
// Проверяем что после @ есть минимум 5 символов
|
||||||
const usernameLength = value.startsWith('@') ? value.length - 1 : value.length
|
const usernameLength = value.startsWith("@")
|
||||||
return usernameLength < 5 ? 'Минимум 5 символов после @' : null
|
? value.length - 1
|
||||||
case 'inn':
|
: value.length;
|
||||||
|
return usernameLength < 5 ? "Минимум 5 символов после @" : null;
|
||||||
|
case "inn":
|
||||||
// Игнорируем автоматически сгенерированные ИНН селлеров
|
// Игнорируем автоматически сгенерированные ИНН селлеров
|
||||||
if (value.startsWith('SELLER_')) {
|
if (value.startsWith("SELLER_")) {
|
||||||
return null
|
return null;
|
||||||
}
|
}
|
||||||
const innCleaned = value.replace(/\D/g, '')
|
const innCleaned = value.replace(/\D/g, "");
|
||||||
if (innCleaned.length !== 10 && innCleaned.length !== 12) {
|
if (innCleaned.length !== 10 && innCleaned.length !== 12) {
|
||||||
return 'ИНН должен содержать 10 или 12 цифр'
|
return "ИНН должен содержать 10 или 12 цифр";
|
||||||
}
|
}
|
||||||
return null
|
return null;
|
||||||
case 'bankName':
|
case "bankName":
|
||||||
return value.trim().length < 3 ? 'Минимум 3 символа' : null
|
return value.trim().length < 3 ? "Минимум 3 символа" : null;
|
||||||
case 'bik':
|
case "bik":
|
||||||
const bikCleaned = value.replace(/\D/g, '')
|
const bikCleaned = value.replace(/\D/g, "");
|
||||||
return bikCleaned.length !== 9 ? 'БИК должен содержать 9 цифр' : null
|
return bikCleaned.length !== 9 ? "БИК должен содержать 9 цифр" : null;
|
||||||
case 'accountNumber':
|
case "accountNumber":
|
||||||
const accountCleaned = value.replace(/\D/g, '')
|
const accountCleaned = value.replace(/\D/g, "");
|
||||||
return accountCleaned.length !== 20 ? 'Расчетный счет должен содержать 20 цифр' : null
|
return accountCleaned.length !== 20
|
||||||
case 'corrAccount':
|
? "Расчетный счет должен содержать 20 цифр"
|
||||||
const corrCleaned = value.replace(/\D/g, '')
|
: null;
|
||||||
return corrCleaned.length !== 20 ? 'Корр. счет должен содержать 20 цифр' : null
|
case "corrAccount":
|
||||||
|
const corrCleaned = value.replace(/\D/g, "");
|
||||||
|
return corrCleaned.length !== 20
|
||||||
|
? "Корр. счет должен содержать 20 цифр"
|
||||||
|
: null;
|
||||||
default:
|
default:
|
||||||
return null
|
return null;
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
// Проверка наличия ошибок валидации
|
// Проверка наличия ошибок валидации
|
||||||
const hasValidationErrors = () => {
|
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 hasErrors = fields.some((field) => {
|
||||||
const value = formData[field as keyof typeof formData]
|
const value = formData[field as keyof typeof formData];
|
||||||
// Проверяем ошибки только для заполненных полей
|
// Проверяем ошибки только для заполненных полей
|
||||||
if (!value || !value.trim()) return false
|
if (!value || !value.trim()) return false;
|
||||||
|
|
||||||
const error = getFieldError(field, value)
|
const error = getFieldError(field, value);
|
||||||
return error !== null
|
return error !== null;
|
||||||
})
|
});
|
||||||
|
|
||||||
// Убираем проверку обязательных полей - пользователь может заполнять постепенно
|
// Убираем проверку обязательных полей - пользователь может заполнять постепенно
|
||||||
return hasErrors
|
return hasErrors;
|
||||||
}
|
};
|
||||||
|
|
||||||
const handleSave = async () => {
|
const handleSave = async () => {
|
||||||
// Сброс предыдущих сообщений
|
// Сброс предыдущих сообщений
|
||||||
setSaveMessage(null)
|
setSaveMessage(null);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Проверяем, изменился ли ИНН и нужно ли обновить данные организации
|
// Проверяем, изменился ли ИНН и нужно ли обновить данные организации
|
||||||
const currentInn = formData.inn || user?.organization?.inn || ''
|
const currentInn = formData.inn || user?.organization?.inn || "";
|
||||||
const originalInn = user?.organization?.inn || ''
|
const originalInn = user?.organization?.inn || "";
|
||||||
const innCleaned = currentInn.replace(/\D/g, '')
|
const innCleaned = currentInn.replace(/\D/g, "");
|
||||||
const originalInnCleaned = originalInn.replace(/\D/g, '')
|
const originalInnCleaned = originalInn.replace(/\D/g, "");
|
||||||
|
|
||||||
// Если ИНН изменился и валиден, сначала обновляем данные организации
|
// Если ИНН изменился и валиден, сначала обновляем данные организации
|
||||||
if (innCleaned !== originalInnCleaned && (innCleaned.length === 10 || innCleaned.length === 12)) {
|
if (
|
||||||
setSaveMessage({ type: 'success', text: 'Обновляем данные организации...' })
|
innCleaned !== originalInnCleaned &&
|
||||||
|
(innCleaned.length === 10 || innCleaned.length === 12)
|
||||||
|
) {
|
||||||
|
setSaveMessage({
|
||||||
|
type: "success",
|
||||||
|
text: "Обновляем данные организации...",
|
||||||
|
});
|
||||||
|
|
||||||
const orgResult = await updateOrganizationByInn({
|
const orgResult = await updateOrganizationByInn({
|
||||||
variables: { inn: innCleaned }
|
variables: { inn: innCleaned },
|
||||||
})
|
});
|
||||||
|
|
||||||
if (!orgResult.data?.updateOrganizationByInn?.success) {
|
if (!orgResult.data?.updateOrganizationByInn?.success) {
|
||||||
setSaveMessage({
|
setSaveMessage({
|
||||||
type: 'error',
|
type: "error",
|
||||||
text: orgResult.data?.updateOrganizationByInn?.message || 'Ошибка при обновлении данных организации'
|
text:
|
||||||
})
|
orgResult.data?.updateOrganizationByInn?.message ||
|
||||||
return
|
"Ошибка при обновлении данных организации",
|
||||||
|
});
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setSaveMessage({ type: 'success', text: 'Данные организации обновлены. Сохраняем профиль...' })
|
setSaveMessage({
|
||||||
|
type: "success",
|
||||||
|
text: "Данные организации обновлены. Сохраняем профиль...",
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Подготавливаем только заполненные поля для отправки
|
// Подготавливаем только заполненные поля для отправки
|
||||||
const inputData: {
|
const inputData: {
|
||||||
orgPhone?: string
|
orgPhone?: string;
|
||||||
managerName?: string
|
managerName?: string;
|
||||||
telegram?: string
|
telegram?: string;
|
||||||
whatsapp?: string
|
whatsapp?: string;
|
||||||
email?: string
|
email?: string;
|
||||||
bankName?: string
|
bankName?: string;
|
||||||
bik?: string
|
bik?: string;
|
||||||
accountNumber?: string
|
accountNumber?: string;
|
||||||
corrAccount?: string
|
corrAccount?: string;
|
||||||
} = {}
|
} = {};
|
||||||
|
|
||||||
// orgName больше не редактируется - устанавливается только при регистрации
|
// orgName больше не редактируется - устанавливается только при регистрации
|
||||||
if (formData.orgPhone?.trim()) inputData.orgPhone = formData.orgPhone.trim()
|
if (formData.orgPhone?.trim())
|
||||||
if (formData.managerName?.trim()) inputData.managerName = formData.managerName.trim()
|
inputData.orgPhone = formData.orgPhone.trim();
|
||||||
if (formData.telegram?.trim()) inputData.telegram = formData.telegram.trim()
|
if (formData.managerName?.trim())
|
||||||
if (formData.whatsapp?.trim()) inputData.whatsapp = formData.whatsapp.trim()
|
inputData.managerName = formData.managerName.trim();
|
||||||
if (formData.email?.trim()) inputData.email = formData.email.trim()
|
if (formData.telegram?.trim())
|
||||||
if (formData.bankName?.trim()) inputData.bankName = formData.bankName.trim()
|
inputData.telegram = formData.telegram.trim();
|
||||||
if (formData.bik?.trim()) inputData.bik = formData.bik.trim()
|
if (formData.whatsapp?.trim())
|
||||||
if (formData.accountNumber?.trim()) inputData.accountNumber = formData.accountNumber.trim()
|
inputData.whatsapp = formData.whatsapp.trim();
|
||||||
if (formData.corrAccount?.trim()) inputData.corrAccount = formData.corrAccount.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({
|
const result = await updateUserProfile({
|
||||||
variables: {
|
variables: {
|
||||||
input: inputData
|
input: inputData,
|
||||||
}
|
},
|
||||||
})
|
});
|
||||||
|
|
||||||
if (result.data?.updateUserProfile?.success) {
|
if (result.data?.updateUserProfile?.success) {
|
||||||
setSaveMessage({ type: 'success', text: 'Профиль успешно сохранен! Обновляем страницу...' })
|
setSaveMessage({
|
||||||
|
type: "success",
|
||||||
|
text: "Профиль успешно сохранен! Обновляем страницу...",
|
||||||
|
});
|
||||||
|
|
||||||
// Простое обновление страницы после успешного сохранения
|
// Простое обновление страницы после успешного сохранения
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
window.location.reload()
|
window.location.reload();
|
||||||
}, 1000)
|
}, 1000);
|
||||||
} else {
|
} else {
|
||||||
setSaveMessage({
|
setSaveMessage({
|
||||||
type: 'error',
|
type: "error",
|
||||||
text: result.data?.updateUserProfile?.message || 'Ошибка при сохранении профиля'
|
text:
|
||||||
})
|
result.data?.updateUserProfile?.message ||
|
||||||
|
"Ошибка при сохранении профиля",
|
||||||
|
});
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error saving profile:', error)
|
console.error("Error saving profile:", error);
|
||||||
setSaveMessage({ type: 'error', text: 'Ошибка при сохранении профиля' })
|
setSaveMessage({ type: "error", text: "Ошибка при сохранении профиля" });
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
const formatDate = (dateString?: string) => {
|
const formatDate = (dateString?: string) => {
|
||||||
if (!dateString) return ''
|
if (!dateString) return "";
|
||||||
try {
|
try {
|
||||||
let date: Date
|
let date: Date;
|
||||||
|
|
||||||
// Проверяем, является ли строка числом (Unix timestamp)
|
// Проверяем, является ли строка числом (Unix timestamp)
|
||||||
if (/^\d+$/.test(dateString)) {
|
if (/^\d+$/.test(dateString)) {
|
||||||
// Если это Unix timestamp в миллисекундах
|
// Если это Unix timestamp в миллисекундах
|
||||||
const timestamp = parseInt(dateString, 10)
|
const timestamp = parseInt(dateString, 10);
|
||||||
date = new Date(timestamp)
|
date = new Date(timestamp);
|
||||||
} else {
|
} else {
|
||||||
// Обычная строка даты
|
// Обычная строка даты
|
||||||
date = new Date(dateString)
|
date = new Date(dateString);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isNaN(date.getTime())) {
|
if (isNaN(date.getTime())) {
|
||||||
console.warn('Invalid date string:', dateString)
|
console.warn("Invalid date string:", dateString);
|
||||||
return 'Неверная дата'
|
return "Неверная дата";
|
||||||
}
|
}
|
||||||
|
|
||||||
return date.toLocaleDateString('ru-RU', {
|
return date.toLocaleDateString("ru-RU", {
|
||||||
year: 'numeric',
|
year: "numeric",
|
||||||
month: 'long',
|
month: "long",
|
||||||
day: 'numeric'
|
day: "numeric",
|
||||||
})
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error formatting date:', error, dateString)
|
console.error("Error formatting date:", error, dateString);
|
||||||
return 'Ошибка даты'
|
return "Ошибка даты";
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-screen flex overflow-hidden">
|
<div className="h-screen flex overflow-hidden">
|
||||||
<Sidebar />
|
<Sidebar />
|
||||||
<main className={`flex-1 ${getSidebarMargin()} px-6 py-4 overflow-hidden transition-all duration-300`}>
|
<main
|
||||||
|
className={`flex-1 ${getSidebarMargin()} px-6 py-4 overflow-hidden transition-all duration-300`}
|
||||||
|
>
|
||||||
<div className="h-full w-full flex flex-col">
|
<div className="h-full w-full flex flex-col">
|
||||||
{/* Сообщения о сохранении */}
|
{/* Сообщения о сохранении */}
|
||||||
{saveMessage && (
|
{saveMessage && (
|
||||||
<Alert className={`mb-4 ${saveMessage.type === 'success' ? 'border-green-500 bg-green-500/10' : 'border-red-500 bg-red-500/10'}`}>
|
<Alert
|
||||||
<AlertDescription className={saveMessage.type === 'success' ? 'text-green-400' : 'text-red-400'}>
|
className={`mb-4 ${
|
||||||
|
saveMessage.type === "success"
|
||||||
|
? "border-green-500 bg-green-500/10"
|
||||||
|
: "border-red-500 bg-red-500/10"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<AlertDescription
|
||||||
|
className={
|
||||||
|
saveMessage.type === "success"
|
||||||
|
? "text-green-400"
|
||||||
|
: "text-red-400"
|
||||||
|
}
|
||||||
|
>
|
||||||
{saveMessage.text}
|
{saveMessage.text}
|
||||||
</AlertDescription>
|
</AlertDescription>
|
||||||
</Alert>
|
</Alert>
|
||||||
@ -686,33 +809,57 @@ export function UserSettings() {
|
|||||||
{/* Основной контент с вкладками - заполняет оставшееся пространство */}
|
{/* Основной контент с вкладками - заполняет оставшееся пространство */}
|
||||||
<div className="flex-1 overflow-hidden">
|
<div className="flex-1 overflow-hidden">
|
||||||
<Tabs defaultValue="profile" className="h-full flex flex-col">
|
<Tabs defaultValue="profile" className="h-full flex flex-col">
|
||||||
<TabsList className={`grid w-full glass-card mb-4 flex-shrink-0 ${
|
<TabsList
|
||||||
user?.organization?.type === 'SELLER' ? 'grid-cols-4' :
|
className={`grid w-full glass-card mb-4 flex-shrink-0 ${
|
||||||
(user?.organization?.type === 'FULFILLMENT' || user?.organization?.type === 'LOGIST' || user?.organization?.type === 'WHOLESALE') ? 'grid-cols-4' :
|
user?.organization?.type === "SELLER"
|
||||||
'grid-cols-3'
|
? "grid-cols-4"
|
||||||
}`}>
|
: user?.organization?.type === "FULFILLMENT" ||
|
||||||
<TabsTrigger value="profile" className="text-white data-[state=active]:bg-white/20 cursor-pointer">
|
user?.organization?.type === "LOGIST" ||
|
||||||
|
user?.organization?.type === "WHOLESALE"
|
||||||
|
? "grid-cols-4"
|
||||||
|
: "grid-cols-3"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<TabsTrigger
|
||||||
|
value="profile"
|
||||||
|
className="text-white data-[state=active]:bg-white/20 cursor-pointer"
|
||||||
|
>
|
||||||
<User className="h-4 w-4 mr-2" />
|
<User className="h-4 w-4 mr-2" />
|
||||||
Профиль
|
Профиль
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
<TabsTrigger value="organization" className="text-white data-[state=active]:bg-white/20 cursor-pointer">
|
<TabsTrigger
|
||||||
|
value="organization"
|
||||||
|
className="text-white data-[state=active]:bg-white/20 cursor-pointer"
|
||||||
|
>
|
||||||
<Building2 className="h-4 w-4 mr-2" />
|
<Building2 className="h-4 w-4 mr-2" />
|
||||||
Организация
|
Организация
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
{(user?.organization?.type === 'FULFILLMENT' || user?.organization?.type === 'LOGIST' || user?.organization?.type === 'WHOLESALE' || user?.organization?.type === 'SELLER') && (
|
{(user?.organization?.type === "FULFILLMENT" ||
|
||||||
<TabsTrigger value="financial" className="text-white data-[state=active]:bg-white/20 cursor-pointer">
|
user?.organization?.type === "LOGIST" ||
|
||||||
|
user?.organization?.type === "WHOLESALE" ||
|
||||||
|
user?.organization?.type === "SELLER") && (
|
||||||
|
<TabsTrigger
|
||||||
|
value="financial"
|
||||||
|
className="text-white data-[state=active]:bg-white/20 cursor-pointer"
|
||||||
|
>
|
||||||
<CreditCard className="h-4 w-4 mr-2" />
|
<CreditCard className="h-4 w-4 mr-2" />
|
||||||
Финансы
|
Финансы
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
)}
|
)}
|
||||||
{user?.organization?.type === 'SELLER' && (
|
{user?.organization?.type === "SELLER" && (
|
||||||
<TabsTrigger value="api" className="text-white data-[state=active]:bg-white/20 cursor-pointer">
|
<TabsTrigger
|
||||||
|
value="api"
|
||||||
|
className="text-white data-[state=active]:bg-white/20 cursor-pointer"
|
||||||
|
>
|
||||||
<Key className="h-4 w-4 mr-2" />
|
<Key className="h-4 w-4 mr-2" />
|
||||||
API
|
API
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
)}
|
)}
|
||||||
{user?.organization?.type !== 'SELLER' && (
|
{user?.organization?.type !== "SELLER" && (
|
||||||
<TabsTrigger value="tools" className="text-white data-[state=active]:bg-white/20 cursor-pointer">
|
<TabsTrigger
|
||||||
|
value="tools"
|
||||||
|
className="text-white data-[state=active]:bg-white/20 cursor-pointer"
|
||||||
|
>
|
||||||
<Settings className="h-4 w-4 mr-2" />
|
<Settings className="h-4 w-4 mr-2" />
|
||||||
Инструменты
|
Инструменты
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
@ -727,15 +874,21 @@ export function UserSettings() {
|
|||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
<User className="h-6 w-6 text-purple-400" />
|
<User className="h-6 w-6 text-purple-400" />
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-lg font-semibold text-white">Профиль пользователя</h2>
|
<h2 className="text-lg font-semibold text-white">
|
||||||
<p className="text-white/70 text-sm">Личная информация и контактные данные</p>
|
Профиль пользователя
|
||||||
|
</h2>
|
||||||
|
<p className="text-white/70 text-sm">
|
||||||
|
Личная информация и контактные данные
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
{/* Компактный индикатор прогресса */}
|
{/* Компактный индикатор прогресса */}
|
||||||
<div className="flex items-center gap-2 mr-2">
|
<div className="flex items-center gap-2 mr-2">
|
||||||
<div className="w-8 h-8 rounded-full bg-white/10 flex items-center justify-center">
|
<div className="w-8 h-8 rounded-full bg-white/10 flex items-center justify-center">
|
||||||
<span className="text-xs text-white font-medium">{profileStatus.percentage}%</span>
|
<span className="text-xs text-white font-medium">
|
||||||
|
{profileStatus.percentage}%
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="hidden sm:block text-xs text-white/70">
|
<div className="hidden sm:block text-xs text-white/70">
|
||||||
{isIncomplete ? (
|
{isIncomplete ? (
|
||||||
@ -745,31 +898,33 @@ export function UserSettings() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{isEditing ? (
|
{isEditing ? (
|
||||||
<>
|
<>
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => setIsEditing(false)}
|
onClick={() => setIsEditing(false)}
|
||||||
className="glass-secondary text-white hover:text-white cursor-pointer"
|
className="glass-secondary text-white hover:text-white cursor-pointer"
|
||||||
>
|
>
|
||||||
Отмена
|
Отмена
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={handleSave}
|
onClick={handleSave}
|
||||||
disabled={hasValidationErrors() || isSaving}
|
disabled={hasValidationErrors() || isSaving}
|
||||||
className={`glass-button text-white cursor-pointer ${
|
className={`glass-button text-white cursor-pointer ${
|
||||||
hasValidationErrors() || isSaving ? 'opacity-50 cursor-not-allowed' : ''
|
hasValidationErrors() || isSaving
|
||||||
|
? "opacity-50 cursor-not-allowed"
|
||||||
|
: ""
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<Save className="h-4 w-4 mr-2" />
|
<Save className="h-4 w-4 mr-2" />
|
||||||
{isSaving ? 'Сохранение...' : 'Сохранить'}
|
{isSaving ? "Сохранение..." : "Сохранить"}
|
||||||
</Button>
|
</Button>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => setIsEditing(true)}
|
onClick={() => setIsEditing(true)}
|
||||||
className="glass-button text-white cursor-pointer"
|
className="glass-button text-white cursor-pointer"
|
||||||
@ -783,10 +938,10 @@ export function UserSettings() {
|
|||||||
<div className="flex items-center gap-4 mb-6">
|
<div className="flex items-center gap-4 mb-6">
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<Avatar className="h-16 w-16">
|
<Avatar className="h-16 w-16">
|
||||||
{(localAvatarUrl || user?.avatar) ? (
|
{localAvatarUrl || user?.avatar ? (
|
||||||
<Image
|
<Image
|
||||||
src={localAvatarUrl || user.avatar}
|
src={localAvatarUrl || user.avatar}
|
||||||
alt="Аватар"
|
alt="Аватар"
|
||||||
width={64}
|
width={64}
|
||||||
height={64}
|
height={64}
|
||||||
className="w-full h-full object-cover rounded-full"
|
className="w-full h-full object-cover rounded-full"
|
||||||
@ -798,7 +953,10 @@ export function UserSettings() {
|
|||||||
)}
|
)}
|
||||||
</Avatar>
|
</Avatar>
|
||||||
<div className="absolute -bottom-1 -right-1">
|
<div className="absolute -bottom-1 -right-1">
|
||||||
<label htmlFor="avatar-upload" className="cursor-pointer">
|
<label
|
||||||
|
htmlFor="avatar-upload"
|
||||||
|
className="cursor-pointer"
|
||||||
|
>
|
||||||
<div className="w-6 h-6 bg-purple-600 rounded-full flex items-center justify-center hover:bg-purple-700 transition-colors">
|
<div className="w-6 h-6 bg-purple-600 rounded-full flex items-center justify-center hover:bg-purple-700 transition-colors">
|
||||||
{isUploadingAvatar ? (
|
{isUploadingAvatar ? (
|
||||||
<RefreshCw className="h-3 w-3 text-white animate-spin" />
|
<RefreshCw className="h-3 w-3 text-white animate-spin" />
|
||||||
@ -819,13 +977,18 @@ export function UserSettings() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<p className="text-white font-medium text-lg">
|
<p className="text-white font-medium text-lg">
|
||||||
{user?.organization?.name || user?.organization?.fullName || 'Пользователь'}
|
{user?.organization?.name ||
|
||||||
|
user?.organization?.fullName ||
|
||||||
|
"Пользователь"}
|
||||||
</p>
|
</p>
|
||||||
<Badge variant="outline" className="bg-white/10 text-white border-white/20 mt-1">
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
className="bg-white/10 text-white border-white/20 mt-1"
|
||||||
|
>
|
||||||
{getCabinetTypeName()}
|
{getCabinetTypeName()}
|
||||||
</Badge>
|
</Badge>
|
||||||
<p className="text-white/60 text-sm mt-2">
|
<p className="text-white/60 text-sm mt-2">
|
||||||
Авторизован по номеру: {formatPhone(user?.phone || '')}
|
Авторизован по номеру: {formatPhone(user?.phone || "")}
|
||||||
</p>
|
</p>
|
||||||
{user?.createdAt && (
|
{user?.createdAt && (
|
||||||
<p className="text-white/50 text-xs mt-1 flex items-center gap-1">
|
<p className="text-white/50 text-xs mt-1 flex items-center gap-1">
|
||||||
@ -840,47 +1003,65 @@ export function UserSettings() {
|
|||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
<div>
|
<div>
|
||||||
<Label className="text-white/80 text-sm mb-2 block">Номер телефона организации</Label>
|
<Label className="text-white/80 text-sm mb-2 block">
|
||||||
<Input
|
Номер телефона организации
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
ref={phoneInputRef}
|
ref={phoneInputRef}
|
||||||
value={formData.orgPhone || ''}
|
value={formData.orgPhone || ""}
|
||||||
onChange={(e) => handlePhoneInputChange('orgPhone', e.target.value, phoneInputRef)}
|
onChange={(e) =>
|
||||||
|
handlePhoneInputChange(
|
||||||
|
"orgPhone",
|
||||||
|
e.target.value,
|
||||||
|
phoneInputRef
|
||||||
|
)
|
||||||
|
}
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
// Предотвращаем удаление +7
|
// Предотвращаем удаление +7
|
||||||
if ((e.key === 'Backspace' || e.key === 'Delete') &&
|
if (
|
||||||
phoneInputRef.current?.selectionStart <= 2) {
|
(e.key === "Backspace" || e.key === "Delete") &&
|
||||||
e.preventDefault()
|
phoneInputRef.current?.selectionStart <= 2
|
||||||
|
) {
|
||||||
|
e.preventDefault();
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
placeholder="+7 (999) 999-99-99"
|
placeholder="+7 (999) 999-99-99"
|
||||||
readOnly={!isEditing}
|
readOnly={!isEditing}
|
||||||
className={`glass-input text-white placeholder:text-white/40 h-10 read-only:opacity-70 ${
|
className={`glass-input text-white placeholder:text-white/40 h-10 read-only:opacity-70 ${
|
||||||
getFieldError('orgPhone', formData.orgPhone) ? 'border-red-400' : ''
|
getFieldError("orgPhone", formData.orgPhone)
|
||||||
|
? "border-red-400"
|
||||||
|
: ""
|
||||||
}`}
|
}`}
|
||||||
/>
|
/>
|
||||||
{getFieldError('orgPhone', formData.orgPhone) && (
|
{getFieldError("orgPhone", formData.orgPhone) && (
|
||||||
<p className="text-red-400 text-xs mt-1 flex items-center gap-1">
|
<p className="text-red-400 text-xs mt-1 flex items-center gap-1">
|
||||||
<AlertTriangle className="h-3 w-3" />
|
<AlertTriangle className="h-3 w-3" />
|
||||||
{getFieldError('orgPhone', formData.orgPhone)}
|
{getFieldError("orgPhone", formData.orgPhone)}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<Label className="text-white/80 text-sm mb-2 block">Имя управляющего</Label>
|
<Label className="text-white/80 text-sm mb-2 block">
|
||||||
<Input
|
Имя управляющего
|
||||||
value={formData.managerName || ''}
|
</Label>
|
||||||
onChange={(e) => handleInputChange('managerName', e.target.value)}
|
<Input
|
||||||
|
value={formData.managerName || ""}
|
||||||
|
onChange={(e) =>
|
||||||
|
handleInputChange("managerName", e.target.value)
|
||||||
|
}
|
||||||
placeholder="Иван Иванов"
|
placeholder="Иван Иванов"
|
||||||
readOnly={!isEditing}
|
readOnly={!isEditing}
|
||||||
className={`glass-input text-white placeholder:text-white/40 h-10 read-only:opacity-70 ${
|
className={`glass-input text-white placeholder:text-white/40 h-10 read-only:opacity-70 ${
|
||||||
getFieldError('managerName', formData.managerName) ? 'border-red-400' : ''
|
getFieldError("managerName", formData.managerName)
|
||||||
|
? "border-red-400"
|
||||||
|
: ""
|
||||||
}`}
|
}`}
|
||||||
/>
|
/>
|
||||||
{getFieldError('managerName', formData.managerName) && (
|
{getFieldError("managerName", formData.managerName) && (
|
||||||
<p className="text-red-400 text-xs mt-1 flex items-center gap-1">
|
<p className="text-red-400 text-xs mt-1 flex items-center gap-1">
|
||||||
<AlertTriangle className="h-3 w-3" />
|
<AlertTriangle className="h-3 w-3" />
|
||||||
{getFieldError('managerName', formData.managerName)}
|
{getFieldError("managerName", formData.managerName)}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@ -892,19 +1073,23 @@ export function UserSettings() {
|
|||||||
<MessageCircle className="h-4 w-4 text-blue-400" />
|
<MessageCircle className="h-4 w-4 text-blue-400" />
|
||||||
Telegram
|
Telegram
|
||||||
</Label>
|
</Label>
|
||||||
<Input
|
<Input
|
||||||
value={formData.telegram || ''}
|
value={formData.telegram || ""}
|
||||||
onChange={(e) => handleInputChange('telegram', e.target.value)}
|
onChange={(e) =>
|
||||||
|
handleInputChange("telegram", e.target.value)
|
||||||
|
}
|
||||||
placeholder="@username"
|
placeholder="@username"
|
||||||
readOnly={!isEditing}
|
readOnly={!isEditing}
|
||||||
className={`glass-input text-white placeholder:text-white/40 h-10 read-only:opacity-70 ${
|
className={`glass-input text-white placeholder:text-white/40 h-10 read-only:opacity-70 ${
|
||||||
getFieldError('telegram', formData.telegram) ? 'border-red-400' : ''
|
getFieldError("telegram", formData.telegram)
|
||||||
|
? "border-red-400"
|
||||||
|
: ""
|
||||||
}`}
|
}`}
|
||||||
/>
|
/>
|
||||||
{getFieldError('telegram', formData.telegram) && (
|
{getFieldError("telegram", formData.telegram) && (
|
||||||
<p className="text-red-400 text-xs mt-1 flex items-center gap-1">
|
<p className="text-red-400 text-xs mt-1 flex items-center gap-1">
|
||||||
<AlertTriangle className="h-3 w-3" />
|
<AlertTriangle className="h-3 w-3" />
|
||||||
{getFieldError('telegram', formData.telegram)}
|
{getFieldError("telegram", formData.telegram)}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@ -914,10 +1099,17 @@ export function UserSettings() {
|
|||||||
<Phone className="h-4 w-4 text-green-400" />
|
<Phone className="h-4 w-4 text-green-400" />
|
||||||
WhatsApp
|
WhatsApp
|
||||||
</Label>
|
</Label>
|
||||||
<Input
|
<Input
|
||||||
ref={whatsappInputRef}
|
ref={whatsappInputRef}
|
||||||
value={formData.whatsapp || ''}
|
value={formData.whatsapp || ""}
|
||||||
onChange={(e) => handlePhoneInputChange('whatsapp', e.target.value, whatsappInputRef, true)}
|
onChange={(e) =>
|
||||||
|
handlePhoneInputChange(
|
||||||
|
"whatsapp",
|
||||||
|
e.target.value,
|
||||||
|
whatsappInputRef,
|
||||||
|
true
|
||||||
|
)
|
||||||
|
}
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
// Для WhatsApp разрешаем полное удаление (поле необязательное)
|
// Для WhatsApp разрешаем полное удаление (поле необязательное)
|
||||||
// Никаких ограничений на удаление
|
// Никаких ограничений на удаление
|
||||||
@ -925,13 +1117,15 @@ export function UserSettings() {
|
|||||||
placeholder="Необязательно"
|
placeholder="Необязательно"
|
||||||
readOnly={!isEditing}
|
readOnly={!isEditing}
|
||||||
className={`glass-input text-white placeholder:text-white/40 h-10 read-only:opacity-70 ${
|
className={`glass-input text-white placeholder:text-white/40 h-10 read-only:opacity-70 ${
|
||||||
getFieldError('whatsapp', formData.whatsapp) ? 'border-red-400' : ''
|
getFieldError("whatsapp", formData.whatsapp)
|
||||||
|
? "border-red-400"
|
||||||
|
: ""
|
||||||
}`}
|
}`}
|
||||||
/>
|
/>
|
||||||
{getFieldError('whatsapp', formData.whatsapp) && (
|
{getFieldError("whatsapp", formData.whatsapp) && (
|
||||||
<p className="text-red-400 text-xs mt-1 flex items-center gap-1">
|
<p className="text-red-400 text-xs mt-1 flex items-center gap-1">
|
||||||
<AlertTriangle className="h-3 w-3" />
|
<AlertTriangle className="h-3 w-3" />
|
||||||
{getFieldError('whatsapp', formData.whatsapp)}
|
{getFieldError("whatsapp", formData.whatsapp)}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@ -941,20 +1135,24 @@ export function UserSettings() {
|
|||||||
<Mail className="h-4 w-4 text-red-400" />
|
<Mail className="h-4 w-4 text-red-400" />
|
||||||
Email
|
Email
|
||||||
</Label>
|
</Label>
|
||||||
<Input
|
<Input
|
||||||
type="email"
|
type="email"
|
||||||
value={formData.email || ''}
|
value={formData.email || ""}
|
||||||
onChange={(e) => handleInputChange('email', e.target.value)}
|
onChange={(e) =>
|
||||||
|
handleInputChange("email", e.target.value)
|
||||||
|
}
|
||||||
placeholder="example@company.com"
|
placeholder="example@company.com"
|
||||||
readOnly={!isEditing}
|
readOnly={!isEditing}
|
||||||
className={`glass-input text-white placeholder:text-white/40 h-10 read-only:opacity-70 ${
|
className={`glass-input text-white placeholder:text-white/40 h-10 read-only:opacity-70 ${
|
||||||
getFieldError('email', formData.email) ? 'border-red-400' : ''
|
getFieldError("email", formData.email)
|
||||||
|
? "border-red-400"
|
||||||
|
: ""
|
||||||
}`}
|
}`}
|
||||||
/>
|
/>
|
||||||
{getFieldError('email', formData.email) && (
|
{getFieldError("email", formData.email) && (
|
||||||
<p className="text-red-400 text-xs mt-1 flex items-center gap-1">
|
<p className="text-red-400 text-xs mt-1 flex items-center gap-1">
|
||||||
<AlertTriangle className="h-3 w-3" />
|
<AlertTriangle className="h-3 w-3" />
|
||||||
{getFieldError('email', formData.email)}
|
{getFieldError("email", formData.email)}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@ -964,49 +1162,60 @@ export function UserSettings() {
|
|||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
{/* Организация и юридические данные */}
|
{/* Организация и юридические данные */}
|
||||||
<TabsContent value="organization" className="flex-1 overflow-hidden">
|
<TabsContent
|
||||||
|
value="organization"
|
||||||
|
className="flex-1 overflow-hidden"
|
||||||
|
>
|
||||||
<Card className="glass-card p-6 h-full overflow-hidden">
|
<Card className="glass-card p-6 h-full overflow-hidden">
|
||||||
{/* Заголовок вкладки с кнопками */}
|
{/* Заголовок вкладки с кнопками */}
|
||||||
<div className="flex items-center justify-between mb-6 pb-4 border-b border-white/10">
|
<div className="flex items-center justify-between mb-6 pb-4 border-b border-white/10">
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
<Building2 className="h-6 w-6 text-blue-400" />
|
<Building2 className="h-6 w-6 text-blue-400" />
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-lg font-semibold text-white">Данные организации</h2>
|
<h2 className="text-lg font-semibold text-white">
|
||||||
<p className="text-white/70 text-sm">Юридическая информация и реквизиты</p>
|
Данные организации
|
||||||
|
</h2>
|
||||||
|
<p className="text-white/70 text-sm">
|
||||||
|
Юридическая информация и реквизиты
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
{(formData.inn || user?.organization?.inn) && (
|
{(formData.inn || user?.organization?.inn) && (
|
||||||
<div className="flex items-center gap-2 mr-2">
|
<div className="flex items-center gap-2 mr-2">
|
||||||
<CheckCircle className="h-5 w-5 text-green-400" />
|
<CheckCircle className="h-5 w-5 text-green-400" />
|
||||||
<span className="text-green-400 text-sm">Проверено</span>
|
<span className="text-green-400 text-sm">
|
||||||
|
Проверено
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{isEditing ? (
|
{isEditing ? (
|
||||||
<>
|
<>
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => setIsEditing(false)}
|
onClick={() => setIsEditing(false)}
|
||||||
className="glass-secondary text-white hover:text-white cursor-pointer"
|
className="glass-secondary text-white hover:text-white cursor-pointer"
|
||||||
>
|
>
|
||||||
Отмена
|
Отмена
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={handleSave}
|
onClick={handleSave}
|
||||||
disabled={hasValidationErrors() || isSaving}
|
disabled={hasValidationErrors() || isSaving}
|
||||||
className={`glass-button text-white cursor-pointer ${
|
className={`glass-button text-white cursor-pointer ${
|
||||||
hasValidationErrors() || isSaving ? 'opacity-50 cursor-not-allowed' : ''
|
hasValidationErrors() || isSaving
|
||||||
|
? "opacity-50 cursor-not-allowed"
|
||||||
|
: ""
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<Save className="h-4 w-4 mr-2" />
|
<Save className="h-4 w-4 mr-2" />
|
||||||
{isSaving ? 'Сохранение...' : 'Сохранить'}
|
{isSaving ? "Сохранение..." : "Сохранить"}
|
||||||
</Button>
|
</Button>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => setIsEditing(true)}
|
onClick={() => setIsEditing(true)}
|
||||||
className="glass-button text-white cursor-pointer"
|
className="glass-button text-white cursor-pointer"
|
||||||
@ -1017,12 +1226,13 @@ export function UserSettings() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Общая подпись про реестр */}
|
{/* Общая подпись про реестр */}
|
||||||
<div className="mb-6 p-3 bg-blue-500/10 rounded-lg border border-blue-500/20">
|
<div className="mb-6 p-3 bg-blue-500/10 rounded-lg border border-blue-500/20">
|
||||||
<p className="text-blue-300 text-sm flex items-center gap-2">
|
<p className="text-blue-300 text-sm flex items-center gap-2">
|
||||||
<RefreshCw className="h-4 w-4" />
|
<RefreshCw className="h-4 w-4" />
|
||||||
При сохранении с измененным ИНН мы автоматически обновляем все остальные данные из федерального реестра
|
При сохранении с измененным ИНН мы автоматически обновляем
|
||||||
|
все остальные данные из федерального реестра
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -1031,30 +1241,48 @@ export function UserSettings() {
|
|||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
<div>
|
<div>
|
||||||
<Label className="text-white/80 text-sm mb-2 block">
|
<Label className="text-white/80 text-sm mb-2 block">
|
||||||
{user?.organization?.type === 'SELLER' ? 'Название магазина' : 'Название организации'}
|
{user?.organization?.type === "SELLER"
|
||||||
|
? "Название магазина"
|
||||||
|
: "Название организации"}
|
||||||
</Label>
|
</Label>
|
||||||
<Input
|
<Input
|
||||||
value={formData.orgName || user?.organization?.name || ''}
|
value={
|
||||||
onChange={(e) => handleInputChange('orgName', e.target.value)}
|
formData.orgName || user?.organization?.name || ""
|
||||||
placeholder={user?.organization?.type === 'SELLER' ? 'Название магазина' : 'Название организации'}
|
}
|
||||||
|
onChange={(e) =>
|
||||||
|
handleInputChange("orgName", e.target.value)
|
||||||
|
}
|
||||||
|
placeholder={
|
||||||
|
user?.organization?.type === "SELLER"
|
||||||
|
? "Название магазина"
|
||||||
|
: "Название организации"
|
||||||
|
}
|
||||||
readOnly={true}
|
readOnly={true}
|
||||||
className="glass-input text-white placeholder:text-white/40 h-10 read-only:opacity-70"
|
className="glass-input text-white placeholder:text-white/40 h-10 read-only:opacity-70"
|
||||||
/>
|
/>
|
||||||
{user?.organization?.type === 'SELLER' ? (
|
{user?.organization?.type === "SELLER" ? (
|
||||||
<p className="text-white/50 text-xs mt-1">
|
<p className="text-white/50 text-xs mt-1">
|
||||||
Название устанавливается при регистрации кабинета и не может быть изменено.
|
Название устанавливается при регистрации кабинета и
|
||||||
|
не может быть изменено.
|
||||||
</p>
|
</p>
|
||||||
) : (
|
) : (
|
||||||
<p className="text-white/50 text-xs mt-1">
|
<p className="text-white/50 text-xs mt-1">
|
||||||
Автоматически заполняется из федерального реестра при указании ИНН.
|
Автоматически заполняется из федерального реестра
|
||||||
|
при указании ИНН.
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<Label className="text-white/80 text-sm mb-2 block">Полное название</Label>
|
<Label className="text-white/80 text-sm mb-2 block">
|
||||||
<Input
|
Полное название
|
||||||
value={formData.fullName || user?.organization?.fullName || ''}
|
</Label>
|
||||||
|
<Input
|
||||||
|
value={
|
||||||
|
formData.fullName ||
|
||||||
|
user?.organization?.fullName ||
|
||||||
|
""
|
||||||
|
}
|
||||||
readOnly
|
readOnly
|
||||||
className="glass-input text-white h-10 read-only:opacity-70"
|
className="glass-input text-white h-10 read-only:opacity-70"
|
||||||
/>
|
/>
|
||||||
@ -1068,19 +1296,30 @@ export function UserSettings() {
|
|||||||
<MapPin className="h-4 w-4" />
|
<MapPin className="h-4 w-4" />
|
||||||
Адрес
|
Адрес
|
||||||
</Label>
|
</Label>
|
||||||
<Input
|
<Input
|
||||||
value={formData.address || user?.organization?.address || ''}
|
value={
|
||||||
onChange={(e) => handleInputChange('address', e.target.value)}
|
formData.address ||
|
||||||
|
user?.organization?.address ||
|
||||||
|
""
|
||||||
|
}
|
||||||
|
onChange={(e) =>
|
||||||
|
handleInputChange("address", e.target.value)
|
||||||
|
}
|
||||||
placeholder="г. Москва, ул. Примерная, д. 1"
|
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"
|
className="glass-input text-white placeholder:text-white/40 h-10 read-only:opacity-70"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<Label className="text-white/80 text-sm mb-2 block">Полный юридический адрес</Label>
|
<Label className="text-white/80 text-sm mb-2 block">
|
||||||
<Input
|
Полный юридический адрес
|
||||||
value={user?.organization?.addressFull || ''}
|
</Label>
|
||||||
|
<Input
|
||||||
|
value={user?.organization?.addressFull || ""}
|
||||||
readOnly
|
readOnly
|
||||||
className="glass-input text-white h-10 read-only:opacity-70"
|
className="glass-input text-white h-10 read-only:opacity-70"
|
||||||
/>
|
/>
|
||||||
@ -1096,40 +1335,48 @@ export function UserSettings() {
|
|||||||
<RefreshCw className="h-3 w-3 animate-spin text-blue-400" />
|
<RefreshCw className="h-3 w-3 animate-spin text-blue-400" />
|
||||||
)}
|
)}
|
||||||
</Label>
|
</Label>
|
||||||
<Input
|
<Input
|
||||||
value={formData.inn || user?.organization?.inn || ''}
|
value={formData.inn || user?.organization?.inn || ""}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
handleInputChange('inn', e.target.value)
|
handleInputChange("inn", e.target.value);
|
||||||
}}
|
}}
|
||||||
placeholder="Введите ИНН организации"
|
placeholder="Введите ИНН организации"
|
||||||
readOnly={!isEditing}
|
readOnly={!isEditing}
|
||||||
disabled={isUpdatingOrganization}
|
disabled={isUpdatingOrganization}
|
||||||
className={`glass-input text-white placeholder:text-white/40 h-10 ${
|
className={`glass-input text-white placeholder:text-white/40 h-10 ${
|
||||||
!isEditing ? 'read-only:opacity-70' : ''
|
!isEditing ? "read-only:opacity-70" : ""
|
||||||
} ${getFieldError('inn', formData.inn) ? 'border-red-400' : ''} ${
|
} ${
|
||||||
isUpdatingOrganization ? 'opacity-50' : ''
|
getFieldError("inn", formData.inn)
|
||||||
}`}
|
? "border-red-400"
|
||||||
|
: ""
|
||||||
|
} ${isUpdatingOrganization ? "opacity-50" : ""}`}
|
||||||
/>
|
/>
|
||||||
{getFieldError('inn', formData.inn) && (
|
{getFieldError("inn", formData.inn) && (
|
||||||
<p className="text-red-400 text-xs mt-1">
|
<p className="text-red-400 text-xs mt-1">
|
||||||
{getFieldError('inn', formData.inn)}
|
{getFieldError("inn", formData.inn)}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<Label className="text-white/80 text-sm mb-2 block">ОГРН</Label>
|
<Label className="text-white/80 text-sm mb-2 block">
|
||||||
<Input
|
ОГРН
|
||||||
value={formData.ogrn || user?.organization?.ogrn || ''}
|
</Label>
|
||||||
|
<Input
|
||||||
|
value={
|
||||||
|
formData.ogrn || user?.organization?.ogrn || ""
|
||||||
|
}
|
||||||
readOnly
|
readOnly
|
||||||
className="glass-input text-white h-10 read-only:opacity-70"
|
className="glass-input text-white h-10 read-only:opacity-70"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<Label className="text-white/80 text-sm mb-2 block">КПП</Label>
|
<Label className="text-white/80 text-sm mb-2 block">
|
||||||
<Input
|
КПП
|
||||||
value={user?.organization?.kpp || ''}
|
</Label>
|
||||||
|
<Input
|
||||||
|
value={user?.organization?.kpp || ""}
|
||||||
readOnly
|
readOnly
|
||||||
className="glass-input text-white h-10 read-only:opacity-70"
|
className="glass-input text-white h-10 read-only:opacity-70"
|
||||||
/>
|
/>
|
||||||
@ -1139,24 +1386,35 @@ export function UserSettings() {
|
|||||||
{/* Руководитель и статус */}
|
{/* Руководитель и статус */}
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
<div>
|
<div>
|
||||||
<Label className="text-white/80 text-sm mb-2 block">Руководитель организации</Label>
|
<Label className="text-white/80 text-sm mb-2 block">
|
||||||
<Input
|
Руководитель организации
|
||||||
value={user?.organization?.managementName || 'Данные не указаны в реестре'}
|
</Label>
|
||||||
|
<Input
|
||||||
|
value={
|
||||||
|
user?.organization?.managementName ||
|
||||||
|
"Данные не указаны в реестре"
|
||||||
|
}
|
||||||
readOnly
|
readOnly
|
||||||
className="glass-input text-white h-10 read-only:opacity-70"
|
className="glass-input text-white h-10 read-only:opacity-70"
|
||||||
placeholder="Данные отсутствуют в федеральном реестре"
|
placeholder="Данные отсутствуют в федеральном реестре"
|
||||||
/>
|
/>
|
||||||
<p className="text-white/50 text-xs mt-1">
|
<p className="text-white/50 text-xs mt-1">
|
||||||
{user?.organization?.managementName
|
{user?.organization?.managementName
|
||||||
? 'Данные из федерального реестра'
|
? "Данные из федерального реестра"
|
||||||
: 'Автоматически заполняется из федерального реестра при указании ИНН'}
|
: "Автоматически заполняется из федерального реестра при указании ИНН"}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<Label className="text-white/80 text-sm mb-2 block">Статус организации</Label>
|
<Label className="text-white/80 text-sm mb-2 block">
|
||||||
<Input
|
Статус организации
|
||||||
value={user?.organization?.status === 'ACTIVE' ? 'Действующая' : user?.organization?.status || 'Статус не указан'}
|
</Label>
|
||||||
|
<Input
|
||||||
|
value={
|
||||||
|
user?.organization?.status === "ACTIVE"
|
||||||
|
? "Действующая"
|
||||||
|
: user?.organization?.status || "Статус не указан"
|
||||||
|
}
|
||||||
readOnly
|
readOnly
|
||||||
className="glass-input text-white h-10 read-only:opacity-70"
|
className="glass-input text-white h-10 read-only:opacity-70"
|
||||||
/>
|
/>
|
||||||
@ -1171,8 +1429,10 @@ export function UserSettings() {
|
|||||||
<Calendar className="h-4 w-4" />
|
<Calendar className="h-4 w-4" />
|
||||||
Дата регистрации
|
Дата регистрации
|
||||||
</Label>
|
</Label>
|
||||||
<Input
|
<Input
|
||||||
value={formatDate(user.organization.registrationDate)}
|
value={formatDate(
|
||||||
|
user.organization.registrationDate
|
||||||
|
)}
|
||||||
readOnly
|
readOnly
|
||||||
className="glass-input text-white h-10 read-only:opacity-70"
|
className="glass-input text-white h-10 read-only:opacity-70"
|
||||||
/>
|
/>
|
||||||
@ -1183,53 +1443,68 @@ export function UserSettings() {
|
|||||||
</Card>
|
</Card>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
{/* Финансовые данные */}
|
{/* Финансовые данные */}
|
||||||
{(user?.organization?.type === 'FULFILLMENT' || user?.organization?.type === 'LOGIST' || user?.organization?.type === 'WHOLESALE' || user?.organization?.type === 'SELLER') && (
|
{(user?.organization?.type === "FULFILLMENT" ||
|
||||||
<TabsContent value="financial" className="flex-1 overflow-hidden">
|
user?.organization?.type === "LOGIST" ||
|
||||||
|
user?.organization?.type === "WHOLESALE" ||
|
||||||
|
user?.organization?.type === "SELLER") && (
|
||||||
|
<TabsContent
|
||||||
|
value="financial"
|
||||||
|
className="flex-1 overflow-hidden"
|
||||||
|
>
|
||||||
<Card className="glass-card p-6 h-full overflow-auto">
|
<Card className="glass-card p-6 h-full overflow-auto">
|
||||||
{/* Заголовок вкладки с кнопками */}
|
{/* Заголовок вкладки с кнопками */}
|
||||||
<div className="flex items-center justify-between mb-6 pb-4 border-b border-white/10">
|
<div className="flex items-center justify-between mb-6 pb-4 border-b border-white/10">
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
<CreditCard className="h-6 w-6 text-red-400" />
|
<CreditCard className="h-6 w-6 text-red-400" />
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-lg font-semibold text-white">Финансовые данные</h2>
|
<h2 className="text-lg font-semibold text-white">
|
||||||
<p className="text-white/70 text-sm">Банковские реквизиты для расчетов</p>
|
Финансовые данные
|
||||||
|
</h2>
|
||||||
|
<p className="text-white/70 text-sm">
|
||||||
|
Банковские реквизиты для расчетов
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
{formData.bankName && formData.bik && formData.accountNumber && formData.corrAccount && (
|
{formData.bankName &&
|
||||||
<div className="flex items-center gap-2 mr-2">
|
formData.bik &&
|
||||||
<CheckCircle className="h-5 w-5 text-green-400" />
|
formData.accountNumber &&
|
||||||
<span className="text-green-400 text-sm">Заполнено</span>
|
formData.corrAccount && (
|
||||||
</div>
|
<div className="flex items-center gap-2 mr-2">
|
||||||
)}
|
<CheckCircle className="h-5 w-5 text-green-400" />
|
||||||
|
<span className="text-green-400 text-sm">
|
||||||
|
Заполнено
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{isEditing ? (
|
{isEditing ? (
|
||||||
<>
|
<>
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => setIsEditing(false)}
|
onClick={() => setIsEditing(false)}
|
||||||
className="glass-secondary text-white hover:text-white cursor-pointer"
|
className="glass-secondary text-white hover:text-white cursor-pointer"
|
||||||
>
|
>
|
||||||
Отмена
|
Отмена
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={handleSave}
|
onClick={handleSave}
|
||||||
disabled={hasValidationErrors() || isSaving}
|
disabled={hasValidationErrors() || isSaving}
|
||||||
className={`glass-button text-white cursor-pointer ${
|
className={`glass-button text-white cursor-pointer ${
|
||||||
hasValidationErrors() || isSaving ? 'opacity-50 cursor-not-allowed' : ''
|
hasValidationErrors() || isSaving
|
||||||
|
? "opacity-50 cursor-not-allowed"
|
||||||
|
: ""
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<Save className="h-4 w-4 mr-2" />
|
<Save className="h-4 w-4 mr-2" />
|
||||||
{isSaving ? 'Сохранение...' : 'Сохранить'}
|
{isSaving ? "Сохранение..." : "Сохранить"}
|
||||||
</Button>
|
</Button>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => setIsEditing(true)}
|
onClick={() => setIsEditing(true)}
|
||||||
className="glass-button text-white cursor-pointer"
|
className="glass-button text-white cursor-pointer"
|
||||||
@ -1243,10 +1518,14 @@ export function UserSettings() {
|
|||||||
|
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<Label className="text-white/80 text-sm mb-2 block">Название банка</Label>
|
<Label className="text-white/80 text-sm mb-2 block">
|
||||||
<Input
|
Название банка
|
||||||
value={formData.bankName || ''}
|
</Label>
|
||||||
onChange={(e) => handleInputChange('bankName', e.target.value)}
|
<Input
|
||||||
|
value={formData.bankName || ""}
|
||||||
|
onChange={(e) =>
|
||||||
|
handleInputChange("bankName", e.target.value)
|
||||||
|
}
|
||||||
placeholder="ПАО Сбербанк"
|
placeholder="ПАО Сбербанк"
|
||||||
readOnly={!isEditing}
|
readOnly={!isEditing}
|
||||||
className="glass-input text-white placeholder:text-white/40 h-10 read-only:opacity-70"
|
className="glass-input text-white placeholder:text-white/40 h-10 read-only:opacity-70"
|
||||||
@ -1255,10 +1534,14 @@ export function UserSettings() {
|
|||||||
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
<div>
|
<div>
|
||||||
<Label className="text-white/80 text-sm mb-2 block">БИК</Label>
|
<Label className="text-white/80 text-sm mb-2 block">
|
||||||
<Input
|
БИК
|
||||||
value={formData.bik || ''}
|
</Label>
|
||||||
onChange={(e) => handleInputChange('bik', e.target.value)}
|
<Input
|
||||||
|
value={formData.bik || ""}
|
||||||
|
onChange={(e) =>
|
||||||
|
handleInputChange("bik", e.target.value)
|
||||||
|
}
|
||||||
placeholder="044525225"
|
placeholder="044525225"
|
||||||
readOnly={!isEditing}
|
readOnly={!isEditing}
|
||||||
className="glass-input text-white placeholder:text-white/40 h-10 read-only:opacity-70"
|
className="glass-input text-white placeholder:text-white/40 h-10 read-only:opacity-70"
|
||||||
@ -1266,10 +1549,14 @@ export function UserSettings() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<Label className="text-white/80 text-sm mb-2 block">Корр. счет</Label>
|
<Label className="text-white/80 text-sm mb-2 block">
|
||||||
<Input
|
Корр. счет
|
||||||
value={formData.corrAccount || ''}
|
</Label>
|
||||||
onChange={(e) => handleInputChange('corrAccount', e.target.value)}
|
<Input
|
||||||
|
value={formData.corrAccount || ""}
|
||||||
|
onChange={(e) =>
|
||||||
|
handleInputChange("corrAccount", e.target.value)
|
||||||
|
}
|
||||||
placeholder="30101810400000000225"
|
placeholder="30101810400000000225"
|
||||||
readOnly={!isEditing}
|
readOnly={!isEditing}
|
||||||
className="glass-input text-white placeholder:text-white/40 h-10 read-only:opacity-70"
|
className="glass-input text-white placeholder:text-white/40 h-10 read-only:opacity-70"
|
||||||
@ -1278,10 +1565,14 @@ export function UserSettings() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<Label className="text-white/80 text-sm mb-2 block">Расчетный счет</Label>
|
<Label className="text-white/80 text-sm mb-2 block">
|
||||||
<Input
|
Расчетный счет
|
||||||
value={formData.accountNumber || ''}
|
</Label>
|
||||||
onChange={(e) => handleInputChange('accountNumber', e.target.value)}
|
<Input
|
||||||
|
value={formData.accountNumber || ""}
|
||||||
|
onChange={(e) =>
|
||||||
|
handleInputChange("accountNumber", e.target.value)
|
||||||
|
}
|
||||||
placeholder="40702810123456789012"
|
placeholder="40702810123456789012"
|
||||||
readOnly={!isEditing}
|
readOnly={!isEditing}
|
||||||
className="glass-input text-white placeholder:text-white/40 h-10 read-only:opacity-70"
|
className="glass-input text-white placeholder:text-white/40 h-10 read-only:opacity-70"
|
||||||
@ -1293,7 +1584,7 @@ export function UserSettings() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* API ключи для селлера */}
|
{/* API ключи для селлера */}
|
||||||
{user?.organization?.type === 'SELLER' && (
|
{user?.organization?.type === "SELLER" && (
|
||||||
<TabsContent value="api" className="flex-1 overflow-hidden">
|
<TabsContent value="api" className="flex-1 overflow-hidden">
|
||||||
<Card className="glass-card p-6 h-full overflow-auto">
|
<Card className="glass-card p-6 h-full overflow-auto">
|
||||||
{/* Заголовок вкладки с кнопками */}
|
{/* Заголовок вкладки с кнопками */}
|
||||||
@ -1301,42 +1592,50 @@ export function UserSettings() {
|
|||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
<Key className="h-6 w-6 text-green-400" />
|
<Key className="h-6 w-6 text-green-400" />
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-lg font-semibold text-white">API ключи маркетплейсов</h2>
|
<h2 className="text-lg font-semibold text-white">
|
||||||
<p className="text-white/70 text-sm">Интеграция с торговыми площадками</p>
|
API ключи маркетплейсов
|
||||||
|
</h2>
|
||||||
|
<p className="text-white/70 text-sm">
|
||||||
|
Интеграция с торговыми площадками
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
{user?.organization?.apiKeys?.length > 0 && (
|
{user?.organization?.apiKeys?.length > 0 && (
|
||||||
<div className="flex items-center gap-2 mr-2">
|
<div className="flex items-center gap-2 mr-2">
|
||||||
<CheckCircle className="h-5 w-5 text-green-400" />
|
<CheckCircle className="h-5 w-5 text-green-400" />
|
||||||
<span className="text-green-400 text-sm">Настроено</span>
|
<span className="text-green-400 text-sm">
|
||||||
|
Настроено
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{isEditing ? (
|
{isEditing ? (
|
||||||
<>
|
<>
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => setIsEditing(false)}
|
onClick={() => setIsEditing(false)}
|
||||||
className="glass-secondary text-white hover:text-white cursor-pointer"
|
className="glass-secondary text-white hover:text-white cursor-pointer"
|
||||||
>
|
>
|
||||||
Отмена
|
Отмена
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={handleSave}
|
onClick={handleSave}
|
||||||
disabled={hasValidationErrors() || isSaving}
|
disabled={hasValidationErrors() || isSaving}
|
||||||
className={`glass-button text-white cursor-pointer ${
|
className={`glass-button text-white cursor-pointer ${
|
||||||
hasValidationErrors() || isSaving ? 'opacity-50 cursor-not-allowed' : ''
|
hasValidationErrors() || isSaving
|
||||||
|
? "opacity-50 cursor-not-allowed"
|
||||||
|
: ""
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<Save className="h-4 w-4 mr-2" />
|
<Save className="h-4 w-4 mr-2" />
|
||||||
{isSaving ? 'Сохранение...' : 'Сохранить'}
|
{isSaving ? "Сохранение..." : "Сохранить"}
|
||||||
</Button>
|
</Button>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => setIsEditing(true)}
|
onClick={() => setIsEditing(true)}
|
||||||
className="glass-button text-white cursor-pointer"
|
className="glass-button text-white cursor-pointer"
|
||||||
@ -1350,35 +1649,72 @@ export function UserSettings() {
|
|||||||
|
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<Label className="text-white/80 text-sm mb-2 block">Wildberries API</Label>
|
<Label className="text-white/80 text-sm mb-2 block">
|
||||||
<Input
|
Wildberries API
|
||||||
value={isEditing ? (formData.wildberriesApiKey || '') : (user?.organization?.apiKeys?.find(key => key.marketplace === 'WILDBERRIES') ? '••••••••••••••••••••' : '')}
|
</Label>
|
||||||
onChange={(e) => handleInputChange('wildberriesApiKey', e.target.value)}
|
<Input
|
||||||
|
value={
|
||||||
|
isEditing
|
||||||
|
? formData.wildberriesApiKey || ""
|
||||||
|
: user?.organization?.apiKeys?.find(
|
||||||
|
(key) => key.marketplace === "WILDBERRIES"
|
||||||
|
)
|
||||||
|
? "••••••••••••••••••••"
|
||||||
|
: ""
|
||||||
|
}
|
||||||
|
onChange={(e) =>
|
||||||
|
handleInputChange(
|
||||||
|
"wildberriesApiKey",
|
||||||
|
e.target.value
|
||||||
|
)
|
||||||
|
}
|
||||||
placeholder="Введите API ключ Wildberries"
|
placeholder="Введите API ключ Wildberries"
|
||||||
readOnly={!isEditing}
|
readOnly={!isEditing}
|
||||||
className="glass-input text-white placeholder:text-white/40 h-10 read-only:opacity-70"
|
className="glass-input text-white placeholder:text-white/40 h-10 read-only:opacity-70"
|
||||||
/>
|
/>
|
||||||
{(user?.organization?.apiKeys?.find(key => key.marketplace === 'WILDBERRIES') || (formData.wildberriesApiKey && isEditing)) && (
|
{(user?.organization?.apiKeys?.find(
|
||||||
|
(key) => key.marketplace === "WILDBERRIES"
|
||||||
|
) ||
|
||||||
|
(formData.wildberriesApiKey && isEditing)) && (
|
||||||
<p className="text-green-400 text-sm mt-2 flex items-center gap-2">
|
<p className="text-green-400 text-sm mt-2 flex items-center gap-2">
|
||||||
<CheckCircle className="h-4 w-4" />
|
<CheckCircle className="h-4 w-4" />
|
||||||
{!isEditing ? 'API ключ настроен' : 'Будет сохранен'}
|
{!isEditing
|
||||||
|
? "API ключ настроен"
|
||||||
|
: "Будет сохранен"}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<Label className="text-white/80 text-sm mb-2 block">Ozon API</Label>
|
<Label className="text-white/80 text-sm mb-2 block">
|
||||||
<Input
|
Ozon API
|
||||||
value={isEditing ? (formData.ozonApiKey || '') : (user?.organization?.apiKeys?.find(key => key.marketplace === 'OZON') ? '••••••••••••••••••••' : '')}
|
</Label>
|
||||||
onChange={(e) => handleInputChange('ozonApiKey', e.target.value)}
|
<Input
|
||||||
|
value={
|
||||||
|
isEditing
|
||||||
|
? formData.ozonApiKey || ""
|
||||||
|
: user?.organization?.apiKeys?.find(
|
||||||
|
(key) => key.marketplace === "OZON"
|
||||||
|
)
|
||||||
|
? "••••••••••••••••••••"
|
||||||
|
: ""
|
||||||
|
}
|
||||||
|
onChange={(e) =>
|
||||||
|
handleInputChange("ozonApiKey", e.target.value)
|
||||||
|
}
|
||||||
placeholder="Введите API ключ Ozon"
|
placeholder="Введите API ключ Ozon"
|
||||||
readOnly={!isEditing}
|
readOnly={!isEditing}
|
||||||
className="glass-input text-white placeholder:text-white/40 h-10 read-only:opacity-70"
|
className="glass-input text-white placeholder:text-white/40 h-10 read-only:opacity-70"
|
||||||
/>
|
/>
|
||||||
{(user?.organization?.apiKeys?.find(key => key.marketplace === 'OZON') || (formData.ozonApiKey && isEditing)) && (
|
{(user?.organization?.apiKeys?.find(
|
||||||
|
(key) => key.marketplace === "OZON"
|
||||||
|
) ||
|
||||||
|
(formData.ozonApiKey && isEditing)) && (
|
||||||
<p className="text-green-400 text-sm mt-2 flex items-center gap-2">
|
<p className="text-green-400 text-sm mt-2 flex items-center gap-2">
|
||||||
<CheckCircle className="h-4 w-4" />
|
<CheckCircle className="h-4 w-4" />
|
||||||
{!isEditing ? 'API ключ настроен' : 'Будет сохранен'}
|
{!isEditing
|
||||||
|
? "API ключ настроен"
|
||||||
|
: "Будет сохранен"}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@ -1395,49 +1731,65 @@ export function UserSettings() {
|
|||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
<Settings className="h-6 w-6 text-green-400" />
|
<Settings className="h-6 w-6 text-green-400" />
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-lg font-semibold text-white">Инструменты</h2>
|
<h2 className="text-lg font-semibold text-white">
|
||||||
<p className="text-white/70 text-sm">Дополнительные возможности для бизнеса</p>
|
Инструменты
|
||||||
|
</h2>
|
||||||
|
<p className="text-white/70 text-sm">
|
||||||
|
Дополнительные возможности для бизнеса
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{(user?.organization?.type === 'FULFILLMENT' || user?.organization?.type === 'LOGIST' || user?.organization?.type === 'WHOLESALE') && (
|
{(user?.organization?.type === "FULFILLMENT" ||
|
||||||
|
user?.organization?.type === "LOGIST" ||
|
||||||
|
user?.organization?.type === "WHOLESALE") && (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div>
|
<div>
|
||||||
<h4 className="text-white font-medium mb-2">Партнерская программа</h4>
|
<h4 className="text-white font-medium mb-2">
|
||||||
|
Партнерская программа
|
||||||
|
</h4>
|
||||||
<p className="text-white/70 text-sm mb-4">
|
<p className="text-white/70 text-sm mb-4">
|
||||||
Приглашайте новых контрагентов по уникальной ссылке. При регистрации они автоматически становятся вашими партнерами.
|
Приглашайте новых контрагентов по уникальной ссылке.
|
||||||
|
При регистрации они автоматически становятся вашими
|
||||||
|
партнерами.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className="glass-secondary text-white hover:text-white cursor-pointer text-xs px-3 py-2"
|
className="glass-secondary text-white hover:text-white cursor-pointer text-xs px-3 py-2"
|
||||||
onClick={generatePartnerLink}
|
onClick={generatePartnerLink}
|
||||||
disabled={isGenerating}
|
disabled={isGenerating}
|
||||||
>
|
>
|
||||||
<RefreshCw className={`h-3 w-3 mr-1 ${isGenerating ? 'animate-spin' : ''}`} />
|
<RefreshCw
|
||||||
{isGenerating ? 'Генерируем...' : 'Сгенерировать ссылку'}
|
className={`h-3 w-3 mr-1 ${
|
||||||
|
isGenerating ? "animate-spin" : ""
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
{isGenerating
|
||||||
|
? "Генерируем..."
|
||||||
|
: "Сгенерировать ссылку"}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{partnerLink && (
|
{partnerLink && (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className="glass-secondary text-white hover:text-white cursor-pointer text-xs px-3 py-2"
|
className="glass-secondary text-white hover:text-white cursor-pointer text-xs px-3 py-2"
|
||||||
onClick={handleOpenLink}
|
onClick={handleOpenLink}
|
||||||
>
|
>
|
||||||
<ExternalLink className="h-3 w-3 mr-1" />
|
<ExternalLink className="h-3 w-3 mr-1" />
|
||||||
Открыть ссылку
|
Открыть ссылку
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className="glass-secondary text-white hover:text-white cursor-pointer px-2"
|
className="glass-secondary text-white hover:text-white cursor-pointer px-2"
|
||||||
onClick={handleCopyLink}
|
onClick={handleCopyLink}
|
||||||
>
|
>
|
||||||
@ -1445,7 +1797,8 @@ export function UserSettings() {
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-white/60 text-xs">
|
<p className="text-white/60 text-xs">
|
||||||
Ваша партнерская ссылка сгенерирована и готова к использованию
|
Ваша партнерская ссылка сгенерирована и готова к
|
||||||
|
использованию
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@ -1460,5 +1813,5 @@ export function UserSettings() {
|
|||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
@ -248,7 +248,7 @@ export function CreateFulfillmentConsumablesSupplyPage() {
|
|||||||
setProductSearchQuery("");
|
setProductSearchQuery("");
|
||||||
setSearchQuery("");
|
setSearchQuery("");
|
||||||
|
|
||||||
// Перенаправляем на страницу поставок фулфилмента с активной вкладкой "Наши расходники"
|
// Перенаправляем на страницу поставок фулфилмента с активной вкладкой "Расходники фулфилмента"
|
||||||
router.push("/fulfillment-supplies?tab=detailed-supplies");
|
router.push("/fulfillment-supplies?tab=detailed-supplies");
|
||||||
} else {
|
} else {
|
||||||
toast.error(
|
toast.error(
|
||||||
|
@ -96,7 +96,7 @@ export function FulfillmentConsumablesOrdersTab() {
|
|||||||
},
|
},
|
||||||
refetchQueries: [
|
refetchQueries: [
|
||||||
{ query: GET_SUPPLY_ORDERS }, // Обновляем заказы поставок
|
{ query: GET_SUPPLY_ORDERS }, // Обновляем заказы поставок
|
||||||
{ query: GET_MY_SUPPLIES }, // Обновляем склад фулфилмента (расходники фф)
|
{ query: GET_MY_SUPPLIES }, // Обновляем склад фулфилмента (расходники фулфилмента)
|
||||||
{ query: GET_WAREHOUSE_PRODUCTS }, // Обновляем товары склада
|
{ query: GET_WAREHOUSE_PRODUCTS }, // Обновляем товары склада
|
||||||
],
|
],
|
||||||
onError: (error) => {
|
onError: (error) => {
|
||||||
|
@ -126,7 +126,7 @@ const mockFulfillmentGoodsSupplies: FulfillmentSupply[] = [
|
|||||||
value: "12",
|
value: "12",
|
||||||
unit: "мес",
|
unit: "мес",
|
||||||
},
|
},
|
||||||
{ id: "ffparam4", name: "Расходники ФФ", value: "Усиленная" },
|
{ id: "ffparam4", name: "Расходники фулфилмента", value: "Усиленная" },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
@ -126,7 +126,7 @@ export function FulfillmentDetailedSuppliesTab() {
|
|||||||
const [updateSupplyOrderStatus] = useMutation(UPDATE_SUPPLY_ORDER_STATUS, {
|
const [updateSupplyOrderStatus] = useMutation(UPDATE_SUPPLY_ORDER_STATUS, {
|
||||||
refetchQueries: [
|
refetchQueries: [
|
||||||
{ query: GET_SUPPLY_ORDERS }, // Обновляем заказы поставок
|
{ query: GET_SUPPLY_ORDERS }, // Обновляем заказы поставок
|
||||||
{ query: GET_MY_SUPPLIES }, // Обновляем склад фулфилмента (расходники фф)
|
{ query: GET_MY_SUPPLIES }, // Обновляем склад фулфилмента (расходники фулфилмента)
|
||||||
{ query: GET_WAREHOUSE_PRODUCTS }, // Обновляем товары склада
|
{ query: GET_WAREHOUSE_PRODUCTS }, // Обновляем товары склада
|
||||||
],
|
],
|
||||||
onError: (error) => {
|
onError: (error) => {
|
||||||
@ -144,7 +144,7 @@ export function FulfillmentDetailedSuppliesTab() {
|
|||||||
// Получаем ID текущей организации (фулфилмент-центра)
|
// Получаем ID текущей организации (фулфилмент-центра)
|
||||||
const currentOrganizationId = user?.organization?.id;
|
const currentOrganizationId = user?.organization?.id;
|
||||||
|
|
||||||
// "Наши расходники" = расходники, которые МЫ (фулфилмент-центр) заказали для себя
|
// "Расходники фулфилмента" = расходники, которые МЫ (фулфилмент-центр) заказали для себя
|
||||||
// Критерии: создатель = мы И получатель = мы (ОБА условия)
|
// Критерии: создатель = мы И получатель = мы (ОБА условия)
|
||||||
const ourSupplyOrders: SupplyOrder[] = (data?.supplyOrders || []).filter(
|
const ourSupplyOrders: SupplyOrder[] = (data?.supplyOrders || []).filter(
|
||||||
(order: SupplyOrder) => {
|
(order: SupplyOrder) => {
|
||||||
@ -226,7 +226,7 @@ export function FulfillmentDetailedSuppliesTab() {
|
|||||||
{/* Заголовок с кнопкой создания поставки */}
|
{/* Заголовок с кнопкой создания поставки */}
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-xl font-bold text-white mb-1">Наши расходники</h2>
|
<h2 className="text-xl font-bold text-white mb-1">Расходники фулфилмента</h2>
|
||||||
<p className="text-white/60 text-sm">
|
<p className="text-white/60 text-sm">
|
||||||
Поставки расходников, поступающие на склад фулфилмент-центра
|
Поставки расходников, поступающие на склад фулфилмент-центра
|
||||||
</p>
|
</p>
|
||||||
|
@ -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"
|
className="data-[state=active]:bg-white/20 data-[state=active]:text-white text-white/70 flex items-center gap-1 text-[10px] xl:text-xs relative"
|
||||||
>
|
>
|
||||||
<Building2 className="h-2.5 w-2.5 xl:h-3 xl:w-3" />
|
<Building2 className="h-2.5 w-2.5 xl:h-3 xl:w-3" />
|
||||||
<span className="hidden md:inline">Наши расходники</span>
|
<span className="hidden md:inline">Расходники фулфилмента</span>
|
||||||
<span className="md:hidden hidden sm:inline">Наши</span>
|
<span className="md:hidden hidden sm:inline">Фулфилмент</span>
|
||||||
<span className="sm:hidden">Н</span>
|
<span className="sm:hidden">Ф</span>
|
||||||
<NotificationBadge count={ourSupplyOrdersCount} />
|
<NotificationBadge count={ourSupplyOrdersCount} />
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
<TabsTrigger
|
<TabsTrigger
|
||||||
|
@ -4,7 +4,7 @@ import React, { useState, useMemo, useCallback } from "react";
|
|||||||
import { Sidebar } from "@/components/dashboard/sidebar";
|
import { Sidebar } from "@/components/dashboard/sidebar";
|
||||||
import { useSidebar } from "@/hooks/useSidebar";
|
import { useSidebar } from "@/hooks/useSidebar";
|
||||||
import { useQuery } from "@apollo/client";
|
import { useQuery } from "@apollo/client";
|
||||||
import { GET_MY_SUPPLIES } from "@/graphql/queries";
|
import { GET_MY_FULFILLMENT_SUPPLIES } from "@/graphql/queries";
|
||||||
import {
|
import {
|
||||||
Package,
|
Package,
|
||||||
Wrench,
|
Wrench,
|
||||||
@ -87,14 +87,14 @@ export function FulfillmentSuppliesPage() {
|
|||||||
loading,
|
loading,
|
||||||
error,
|
error,
|
||||||
refetch,
|
refetch,
|
||||||
} = useQuery(GET_MY_SUPPLIES, {
|
} = useQuery(GET_MY_FULFILLMENT_SUPPLIES, {
|
||||||
fetchPolicy: "cache-and-network",
|
fetchPolicy: "cache-and-network",
|
||||||
onError: (error) => {
|
onError: (error) => {
|
||||||
toast.error("Ошибка загрузки расходников: " + error.message);
|
toast.error("Ошибка загрузки расходников фулфилмента: " + error.message);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const supplies: Supply[] = suppliesData?.mySupplies || [];
|
const supplies: Supply[] = suppliesData?.myFulfillmentSupplies || [];
|
||||||
|
|
||||||
// Логирование для отладки
|
// Логирование для отладки
|
||||||
console.log("🔥🔥🔥 FULFILLMENT SUPPLIES PAGE DATA 🔥🔥🔥", {
|
console.log("🔥🔥🔥 FULFILLMENT SUPPLIES PAGE DATA 🔥🔥🔥", {
|
||||||
@ -142,17 +142,17 @@ export function FulfillmentSuppliesPage() {
|
|||||||
|
|
||||||
// Суммируем поставленное количество (заказано = поставлено)
|
// Суммируем поставленное количество (заказано = поставлено)
|
||||||
acc[key].quantity += supply.quantity;
|
acc[key].quantity += supply.quantity;
|
||||||
|
|
||||||
// Суммируем отправленное количество
|
// Суммируем отправленное количество
|
||||||
acc[key].shippedQuantity += supply.shippedQuantity || 0;
|
acc[key].shippedQuantity += supply.shippedQuantity || 0;
|
||||||
|
|
||||||
// Остаток = Поставлено - Отправлено
|
// Остаток = Поставлено - Отправлено
|
||||||
// Если ничего не отправлено, то остаток = поставлено
|
// Если ничего не отправлено, то остаток = поставлено
|
||||||
acc[key].currentStock = acc[key].quantity - acc[key].shippedQuantity;
|
acc[key].currentStock = acc[key].quantity - acc[key].shippedQuantity;
|
||||||
|
|
||||||
// Рассчитываем общую стоимость (количество × цена)
|
// Рассчитываем общую стоимость (количество × цена)
|
||||||
acc[key].totalCost += supply.quantity * supply.price;
|
acc[key].totalCost += supply.quantity * supply.price;
|
||||||
|
|
||||||
// Средневзвешенная цена за единицу
|
// Средневзвешенная цена за единицу
|
||||||
if (acc[key].quantity > 0) {
|
if (acc[key].quantity > 0) {
|
||||||
acc[key].price = acc[key].totalCost / acc[key].quantity;
|
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 blob = new Blob([csv], { type: "text/csv;charset=utf-8;" });
|
||||||
const link = document.createElement("a");
|
const link = document.createElement("a");
|
||||||
link.href = URL.createObjectURL(blob);
|
link.href = URL.createObjectURL(blob);
|
||||||
link.download = `расходники_фф_${
|
link.download = `расходники_фулфилмента_${
|
||||||
new Date().toISOString().split("T")[0]
|
new Date().toISOString().split("T")[0]
|
||||||
}.csv`;
|
}.csv`;
|
||||||
link.click();
|
link.click();
|
||||||
|
@ -15,7 +15,8 @@ import {
|
|||||||
GET_MY_COUNTERPARTIES,
|
GET_MY_COUNTERPARTIES,
|
||||||
GET_SUPPLY_ORDERS,
|
GET_SUPPLY_ORDERS,
|
||||||
GET_WAREHOUSE_PRODUCTS,
|
GET_WAREHOUSE_PRODUCTS,
|
||||||
GET_MY_SUPPLIES, // Добавляем импорт для загрузки расходников
|
GET_MY_SUPPLIES, // Расходники селлеров
|
||||||
|
GET_MY_FULFILLMENT_SUPPLIES, // Расходники фулфилмента
|
||||||
} from "@/graphql/queries";
|
} from "@/graphql/queries";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import {
|
import {
|
||||||
@ -198,7 +199,7 @@ export function FulfillmentWarehouseDashboard() {
|
|||||||
fetchPolicy: "cache-and-network",
|
fetchPolicy: "cache-and-network",
|
||||||
});
|
});
|
||||||
|
|
||||||
// Загружаем расходники фулфилмента
|
// Загружаем расходники селлеров
|
||||||
const {
|
const {
|
||||||
data: suppliesData,
|
data: suppliesData,
|
||||||
loading: suppliesLoading,
|
loading: suppliesLoading,
|
||||||
@ -208,6 +209,16 @@ export function FulfillmentWarehouseDashboard() {
|
|||||||
fetchPolicy: "cache-and-network",
|
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 allCounterparties = counterpartiesData?.myCounterparties || [];
|
||||||
const sellerPartners = allCounterparties.filter(
|
const sellerPartners = allCounterparties.filter(
|
||||||
@ -215,7 +226,9 @@ export function FulfillmentWarehouseDashboard() {
|
|||||||
);
|
);
|
||||||
const supplyOrders: SupplyOrder[] = ordersData?.supplyOrders || [];
|
const supplyOrders: SupplyOrder[] = ordersData?.supplyOrders || [];
|
||||||
const allProducts = productsData?.warehouseProducts || [];
|
const allProducts = productsData?.warehouseProducts || [];
|
||||||
const mySupplies = suppliesData?.mySupplies || []; // Добавляем расходники
|
const mySupplies = suppliesData?.mySupplies || []; // Расходники селлеров
|
||||||
|
const myFulfillmentSupplies =
|
||||||
|
fulfillmentSuppliesData?.myFulfillmentSupplies || []; // Расходники фулфилмента
|
||||||
|
|
||||||
// Логирование для отладки
|
// Логирование для отладки
|
||||||
console.log("🏪 Данные склада фулфилмента:", {
|
console.log("🏪 Данные склада фулфилмента:", {
|
||||||
@ -391,37 +404,47 @@ export function FulfillmentWarehouseDashboard() {
|
|||||||
0
|
0
|
||||||
);
|
);
|
||||||
|
|
||||||
// Подсчитываем расходники ФФ (расходники, которые получил фулфилмент-центр)
|
// Подсчитываем расходники фулфилмента из нового резолвера
|
||||||
const fulfillmentConsumablesOrders = supplyOrders.filter((order) => {
|
// Основное значение = текущий остаток на складе
|
||||||
// Заказы где текущий фулфилмент-центр является получателем
|
const totalFulfillmentSupplies = myFulfillmentSupplies.reduce(
|
||||||
const isRecipient =
|
(sum: number, supply: any) => sum + (supply.currentStock || 0),
|
||||||
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),
|
|
||||||
0
|
0
|
||||||
);
|
);
|
||||||
|
|
||||||
// Подсчитываем изменения за сегодня (расходники ФФ, полученные сегодня)
|
// Дополнительные значения - динамика за сегодня
|
||||||
const today = new Date();
|
const today = new Date();
|
||||||
today.setHours(0, 0, 0, 0);
|
today.setHours(0, 0, 0, 0);
|
||||||
|
|
||||||
const fulfillmentSuppliesReceivedToday = fulfillmentConsumablesOrders
|
// Поставлено сегодня (дополнительное значение +)
|
||||||
.filter((order) => {
|
const fulfillmentSuppliesReceivedToday = myFulfillmentSupplies
|
||||||
const orderDate = new Date(order.updatedAt || order.createdAt);
|
.filter((supply: any) => {
|
||||||
orderDate.setHours(0, 0, 0, 0);
|
const supplyDate = new Date(supply.updatedAt || supply.createdAt);
|
||||||
return orderDate.getTime() === today.getTime();
|
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 {
|
return {
|
||||||
products: {
|
products: {
|
||||||
@ -441,8 +464,8 @@ export function FulfillmentWarehouseDashboard() {
|
|||||||
change: 0, // Нет реальных данных об изменениях возвратов
|
change: 0, // Нет реальных данных об изменениях возвратов
|
||||||
},
|
},
|
||||||
fulfillmentSupplies: {
|
fulfillmentSupplies: {
|
||||||
current: totalFulfillmentSupplies, // Реальное количество расходников ФФ
|
current: totalFulfillmentSupplies, // Основное значение: текущий остаток на складе
|
||||||
change: fulfillmentSuppliesReceivedToday, // Расходники ФФ, полученные сегодня
|
change: fulfillmentSuppliesChange, // Дополнительное значение: поставлено - использовано за сегодня
|
||||||
},
|
},
|
||||||
sellerSupplies: {
|
sellerSupplies: {
|
||||||
current: totalSellerSupplies, // Реальное количество расходников селлера из базы
|
current: totalSellerSupplies, // Реальное количество расходников селлера из базы
|
||||||
@ -1245,7 +1268,7 @@ export function FulfillmentWarehouseDashboard() {
|
|||||||
description="К обработке"
|
description="К обработке"
|
||||||
/>
|
/>
|
||||||
<StatCard
|
<StatCard
|
||||||
title="Расходники ФФ"
|
title="Расходники фулфилмента"
|
||||||
icon={Wrench}
|
icon={Wrench}
|
||||||
current={warehouseStats.fulfillmentSupplies.current}
|
current={warehouseStats.fulfillmentSupplies.current}
|
||||||
change={warehouseStats.fulfillmentSupplies.change}
|
change={warehouseStats.fulfillmentSupplies.change}
|
||||||
|
@ -1,68 +1,75 @@
|
|||||||
"use client"
|
"use client";
|
||||||
|
|
||||||
import { useState } from 'react'
|
import { useState } from "react";
|
||||||
import { useQuery, useMutation } from '@apollo/client'
|
import { useQuery, useMutation } from "@apollo/client";
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from '@/components/ui/input'
|
import { Input } from "@/components/ui/input";
|
||||||
import { Search, Boxes } from 'lucide-react'
|
import { Search, Boxes } from "lucide-react";
|
||||||
import { OrganizationCard } from './organization-card'
|
import { OrganizationCard } from "./organization-card";
|
||||||
import { SEARCH_ORGANIZATIONS, GET_INCOMING_REQUESTS, GET_OUTGOING_REQUESTS } from '@/graphql/queries'
|
import {
|
||||||
import { SEND_COUNTERPARTY_REQUEST } from '@/graphql/mutations'
|
SEARCH_ORGANIZATIONS,
|
||||||
|
GET_INCOMING_REQUESTS,
|
||||||
|
GET_OUTGOING_REQUESTS,
|
||||||
|
} from "@/graphql/queries";
|
||||||
|
import { SEND_COUNTERPARTY_REQUEST } from "@/graphql/mutations";
|
||||||
|
|
||||||
interface Organization {
|
interface Organization {
|
||||||
id: string
|
id: string;
|
||||||
inn: string
|
inn: string;
|
||||||
name?: string
|
name?: string;
|
||||||
fullName?: string
|
fullName?: string;
|
||||||
type: 'FULFILLMENT' | 'SELLER' | 'LOGIST' | 'WHOLESALE'
|
type: "FULFILLMENT" | "SELLER" | "LOGIST" | "WHOLESALE";
|
||||||
address?: string
|
address?: string;
|
||||||
phones?: Array<{ value: string }>
|
phones?: Array<{ value: string }>;
|
||||||
emails?: Array<{ value: string }>
|
emails?: Array<{ value: string }>;
|
||||||
createdAt: string
|
createdAt: string;
|
||||||
users?: Array<{ id: string, avatar?: string }>
|
users?: Array<{ id: string; avatar?: string }>;
|
||||||
isCounterparty?: boolean
|
isCounterparty?: boolean;
|
||||||
isCurrentUser?: boolean
|
isCurrentUser?: boolean;
|
||||||
hasOutgoingRequest?: boolean
|
hasOutgoingRequest?: boolean;
|
||||||
hasIncomingRequest?: boolean
|
hasIncomingRequest?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function MarketWholesale() {
|
export function MarketSuppliers() {
|
||||||
const [searchTerm, setSearchTerm] = useState('')
|
const [searchTerm, setSearchTerm] = useState("");
|
||||||
|
|
||||||
const { data, loading, refetch } = useQuery(SEARCH_ORGANIZATIONS, {
|
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, {
|
const [sendRequest, { loading: sendingRequest }] = useMutation(
|
||||||
refetchQueries: [
|
SEND_COUNTERPARTY_REQUEST,
|
||||||
{ query: SEARCH_ORGANIZATIONS, variables: { type: 'SELLER' } },
|
{
|
||||||
{ query: SEARCH_ORGANIZATIONS, variables: { type: 'FULFILLMENT' } },
|
refetchQueries: [
|
||||||
{ query: SEARCH_ORGANIZATIONS, variables: { type: 'LOGIST' } },
|
{ query: SEARCH_ORGANIZATIONS, variables: { type: "SELLER" } },
|
||||||
{ query: SEARCH_ORGANIZATIONS, variables: { type: 'WHOLESALE' } },
|
{ query: SEARCH_ORGANIZATIONS, variables: { type: "FULFILLMENT" } },
|
||||||
{ query: GET_OUTGOING_REQUESTS },
|
{ query: SEARCH_ORGANIZATIONS, variables: { type: "LOGIST" } },
|
||||||
{ query: GET_INCOMING_REQUESTS }
|
{ query: SEARCH_ORGANIZATIONS, variables: { type: "WHOLESALE" } },
|
||||||
],
|
{ query: GET_OUTGOING_REQUESTS },
|
||||||
awaitRefetchQueries: true
|
{ query: GET_INCOMING_REQUESTS },
|
||||||
})
|
],
|
||||||
|
awaitRefetchQueries: true,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
const handleSearch = () => {
|
const handleSearch = () => {
|
||||||
refetch({ type: 'WHOLESALE', search: searchTerm || null })
|
refetch({ type: "WHOLESALE", search: searchTerm || null });
|
||||||
}
|
};
|
||||||
|
|
||||||
const handleSendRequest = async (organizationId: string, message: string) => {
|
const handleSendRequest = async (organizationId: string, message: string) => {
|
||||||
try {
|
try {
|
||||||
await sendRequest({
|
await sendRequest({
|
||||||
variables: {
|
variables: {
|
||||||
organizationId: organizationId,
|
organizationId: organizationId,
|
||||||
message: message || 'Заявка на добавление в контрагенты'
|
message: message || "Заявка на добавление в контрагенты",
|
||||||
}
|
},
|
||||||
})
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Ошибка отправки заявки:', error)
|
console.error("Ошибка отправки заявки:", error);
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
const organizations = data?.searchOrganizations || []
|
const organizations = data?.searchOrganizations || [];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-full flex flex-col space-y-4 overflow-hidden">
|
<div className="h-full flex flex-col space-y-4 overflow-hidden">
|
||||||
@ -71,14 +78,14 @@ export function MarketWholesale() {
|
|||||||
<div className="relative flex-1">
|
<div className="relative flex-1">
|
||||||
<Search className="absolute left-3 top-3 h-4 w-4 text-white/40" />
|
<Search className="absolute left-3 top-3 h-4 w-4 text-white/40" />
|
||||||
<Input
|
<Input
|
||||||
placeholder="Поиск оптовых компаний по названию или ИНН..."
|
placeholder="Поиск поставщиков по названию или ИНН..."
|
||||||
value={searchTerm}
|
value={searchTerm}
|
||||||
onChange={(e) => setSearchTerm(e.target.value)}
|
onChange={(e) => setSearchTerm(e.target.value)}
|
||||||
onKeyDown={(e) => e.key === 'Enter' && handleSearch()}
|
onKeyDown={(e) => e.key === "Enter" && handleSearch()}
|
||||||
className="pl-10 glass-input text-white placeholder:text-white/40 h-10"
|
className="pl-10 glass-input text-white placeholder:text-white/40 h-10"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Button
|
||||||
onClick={handleSearch}
|
onClick={handleSearch}
|
||||||
className="bg-purple-500/20 hover:bg-purple-500/30 text-purple-300 border-purple-500/30 cursor-pointer"
|
className="bg-purple-500/20 hover:bg-purple-500/30 text-purple-300 border-purple-500/30 cursor-pointer"
|
||||||
>
|
>
|
||||||
@ -91,8 +98,10 @@ export function MarketWholesale() {
|
|||||||
<div className="flex items-center space-x-3 flex-shrink-0 mb-4">
|
<div className="flex items-center space-x-3 flex-shrink-0 mb-4">
|
||||||
<Boxes className="h-6 w-6 text-purple-400" />
|
<Boxes className="h-6 w-6 text-purple-400" />
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-lg font-semibold text-white">Поставщики</h3>
|
<h3 className="text-lg font-semibold text-white">Поставщики</h3>
|
||||||
<p className="text-white/60 text-sm">Найдите и добавьте оптовые компании в контрагенты</p>
|
<p className="text-white/60 text-sm">
|
||||||
|
Найдите и добавьте поставщиков в контрагенты
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -107,7 +116,9 @@ export function MarketWholesale() {
|
|||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<Boxes className="h-12 w-12 text-white/20 mx-auto mb-4" />
|
<Boxes className="h-12 w-12 text-white/20 mx-auto mb-4" />
|
||||||
<p className="text-white/60">
|
<p className="text-white/60">
|
||||||
{searchTerm ? 'Поставщики не найдены' : 'Введите запрос для поиска поставщиков'}
|
{searchTerm
|
||||||
|
? "Поставщики не найдены"
|
||||||
|
: "Введите запрос для поиска поставщиков"}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-white/40 text-sm mt-2">
|
<p className="text-white/40 text-sm mt-2">
|
||||||
Попробуйте изменить условия поиска
|
Попробуйте изменить условия поиска
|
||||||
@ -130,5 +141,5 @@ export function MarketWholesale() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
}
|
@ -1,101 +1,127 @@
|
|||||||
"use client"
|
"use client";
|
||||||
|
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
import { Card } from '@/components/ui/card'
|
import { Card } from "@/components/ui/card";
|
||||||
import { Sidebar } from '@/components/dashboard/sidebar'
|
import { Sidebar } from "@/components/dashboard/sidebar";
|
||||||
import { useSidebar } from '@/hooks/useSidebar'
|
import { useSidebar } from "@/hooks/useSidebar";
|
||||||
import { useQuery } from '@apollo/client'
|
import { useQuery } from "@apollo/client";
|
||||||
import { GET_INCOMING_REQUESTS } from '@/graphql/queries'
|
import { GET_INCOMING_REQUESTS } from "@/graphql/queries";
|
||||||
import { MarketCounterparties } from '../market/market-counterparties'
|
import { MarketCounterparties } from "../market/market-counterparties";
|
||||||
import { MarketFulfillment } from '../market/market-fulfillment'
|
import { MarketFulfillment } from "../market/market-fulfillment";
|
||||||
import { MarketSellers } from '../market/market-sellers'
|
import { MarketSellers } from "../market/market-sellers";
|
||||||
import { MarketLogistics } from '../market/market-logistics'
|
import { MarketLogistics } from "../market/market-logistics";
|
||||||
import { MarketWholesale } from '../market/market-wholesale'
|
import { MarketSuppliers } from "../market/market-suppliers";
|
||||||
|
|
||||||
export function PartnersDashboard() {
|
export function PartnersDashboard() {
|
||||||
const { getSidebarMargin } = useSidebar()
|
const { getSidebarMargin } = useSidebar();
|
||||||
|
|
||||||
// Загружаем входящие заявки для подсветки
|
// Загружаем входящие заявки для подсветки
|
||||||
const { data: incomingRequestsData } = useQuery(GET_INCOMING_REQUESTS, {
|
const { data: incomingRequestsData } = useQuery(GET_INCOMING_REQUESTS, {
|
||||||
pollInterval: 30000, // Обновляем каждые 30 секунд
|
pollInterval: 30000, // Обновляем каждые 30 секунд
|
||||||
fetchPolicy: 'cache-first',
|
fetchPolicy: "cache-first",
|
||||||
errorPolicy: 'ignore',
|
errorPolicy: "ignore",
|
||||||
})
|
});
|
||||||
|
|
||||||
const incomingRequests = incomingRequestsData?.incomingRequests || []
|
const incomingRequests = incomingRequestsData?.incomingRequests || [];
|
||||||
const hasIncomingRequests = incomingRequests.length > 0
|
const hasIncomingRequests = incomingRequests.length > 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-screen flex overflow-hidden">
|
<div className="h-screen flex overflow-hidden">
|
||||||
<Sidebar />
|
<Sidebar />
|
||||||
<main className={`flex-1 ${getSidebarMargin()} px-6 py-4 overflow-hidden transition-all duration-300`}>
|
<main
|
||||||
|
className={`flex-1 ${getSidebarMargin()} px-6 py-4 overflow-hidden transition-all duration-300`}
|
||||||
|
>
|
||||||
<div className="h-full w-full flex flex-col">
|
<div className="h-full w-full flex flex-col">
|
||||||
{/* Основной контент с табами */}
|
{/* Основной контент с табами */}
|
||||||
<div className="flex-1 overflow-hidden">
|
<div className="flex-1 overflow-hidden">
|
||||||
<Tabs defaultValue="counterparties" className="h-full flex flex-col">
|
<Tabs
|
||||||
<TabsList className={`grid w-full grid-cols-5 bg-white/5 backdrop-blur border-white/10 flex-shrink-0 ${hasIncomingRequests ? 'ring-2 ring-blue-400/50' : ''}`}>
|
defaultValue="counterparties"
|
||||||
<TabsTrigger
|
className="h-full flex flex-col"
|
||||||
value="counterparties"
|
>
|
||||||
className={`data-[state=active]:bg-white/20 data-[state=active]:text-white text-white/70 relative ${hasIncomingRequests ? 'animate-pulse' : ''}`}
|
<TabsList
|
||||||
|
className={`grid w-full grid-cols-5 bg-white/5 backdrop-blur border-white/10 flex-shrink-0 ${
|
||||||
|
hasIncomingRequests ? "ring-2 ring-blue-400/50" : ""
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<TabsTrigger
|
||||||
|
value="counterparties"
|
||||||
|
className={`data-[state=active]:bg-white/20 data-[state=active]:text-white text-white/70 relative ${
|
||||||
|
hasIncomingRequests ? "animate-pulse" : ""
|
||||||
|
}`}
|
||||||
>
|
>
|
||||||
Мои контрагенты
|
Мои контрагенты
|
||||||
{hasIncomingRequests && (
|
{hasIncomingRequests && (
|
||||||
<div className="absolute -top-1 -right-1 w-3 h-3 bg-blue-500 rounded-full"></div>
|
<div className="absolute -top-1 -right-1 w-3 h-3 bg-blue-500 rounded-full"></div>
|
||||||
)}
|
)}
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
<TabsTrigger
|
<TabsTrigger
|
||||||
value="fulfillment"
|
value="fulfillment"
|
||||||
className="data-[state=active]:bg-white/20 data-[state=active]:text-white text-white/70"
|
className="data-[state=active]:bg-white/20 data-[state=active]:text-white text-white/70"
|
||||||
>
|
>
|
||||||
Фулфилмент
|
Фулфилмент
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
<TabsTrigger
|
<TabsTrigger
|
||||||
value="sellers"
|
value="sellers"
|
||||||
className="data-[state=active]:bg-white/20 data-[state=active]:text-white text-white/70"
|
className="data-[state=active]:bg-white/20 data-[state=active]:text-white text-white/70"
|
||||||
>
|
>
|
||||||
Селлеры
|
Селлеры
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
<TabsTrigger
|
<TabsTrigger
|
||||||
value="logistics"
|
value="logistics"
|
||||||
className="data-[state=active]:bg-white/20 data-[state=active]:text-white text-white/70"
|
className="data-[state=active]:bg-white/20 data-[state=active]:text-white text-white/70"
|
||||||
>
|
>
|
||||||
Логистика
|
Логистика
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
<TabsTrigger
|
<TabsTrigger
|
||||||
value="wholesale"
|
value="suppliers"
|
||||||
className="data-[state=active]:bg-white/20 data-[state=active]:text-white text-white/70"
|
className="data-[state=active]:bg-white/20 data-[state=active]:text-white text-white/70"
|
||||||
>
|
>
|
||||||
Поставщик
|
Поставщик
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
</TabsList>
|
</TabsList>
|
||||||
|
|
||||||
<TabsContent value="counterparties" className="flex-1 overflow-hidden mt-6">
|
<TabsContent
|
||||||
|
value="counterparties"
|
||||||
|
className="flex-1 overflow-hidden mt-6"
|
||||||
|
>
|
||||||
<Card className="glass-card h-full overflow-hidden p-6">
|
<Card className="glass-card h-full overflow-hidden p-6">
|
||||||
<MarketCounterparties />
|
<MarketCounterparties />
|
||||||
</Card>
|
</Card>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
<TabsContent value="fulfillment" className="flex-1 overflow-hidden mt-6">
|
<TabsContent
|
||||||
|
value="fulfillment"
|
||||||
|
className="flex-1 overflow-hidden mt-6"
|
||||||
|
>
|
||||||
<Card className="glass-card h-full overflow-hidden p-6">
|
<Card className="glass-card h-full overflow-hidden p-6">
|
||||||
<MarketFulfillment />
|
<MarketFulfillment />
|
||||||
</Card>
|
</Card>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
<TabsContent value="sellers" className="flex-1 overflow-hidden mt-6">
|
<TabsContent
|
||||||
|
value="sellers"
|
||||||
|
className="flex-1 overflow-hidden mt-6"
|
||||||
|
>
|
||||||
<Card className="glass-card h-full overflow-hidden p-6">
|
<Card className="glass-card h-full overflow-hidden p-6">
|
||||||
<MarketSellers />
|
<MarketSellers />
|
||||||
</Card>
|
</Card>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
<TabsContent value="logistics" className="flex-1 overflow-hidden mt-6">
|
<TabsContent
|
||||||
|
value="logistics"
|
||||||
|
className="flex-1 overflow-hidden mt-6"
|
||||||
|
>
|
||||||
<Card className="glass-card h-full overflow-hidden p-6">
|
<Card className="glass-card h-full overflow-hidden p-6">
|
||||||
<MarketLogistics />
|
<MarketLogistics />
|
||||||
</Card>
|
</Card>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
<TabsContent value="wholesale" className="flex-1 overflow-hidden mt-6">
|
<TabsContent
|
||||||
|
value="suppliers"
|
||||||
|
className="flex-1 overflow-hidden mt-6"
|
||||||
|
>
|
||||||
<Card className="glass-card h-full overflow-hidden p-6">
|
<Card className="glass-card h-full overflow-hidden p-6">
|
||||||
<MarketWholesale />
|
<MarketSuppliers />
|
||||||
</Card>
|
</Card>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
@ -103,5 +129,5 @@ export function PartnersDashboard() {
|
|||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
@ -1,26 +1,24 @@
|
|||||||
"use client"
|
"use client";
|
||||||
|
|
||||||
import React from 'react'
|
import React from "react";
|
||||||
import { Card } from '@/components/ui/card'
|
import { Card } from "@/components/ui/card";
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from "@/components/ui/button";
|
||||||
import { Badge } from '@/components/ui/badge'
|
import { Badge } from "@/components/ui/badge";
|
||||||
import {
|
import { ShoppingCart, Building2, Plus, Minus, Eye } from "lucide-react";
|
||||||
ShoppingCart,
|
import { SelectedProduct } from "./types";
|
||||||
Building2,
|
|
||||||
Plus,
|
|
||||||
Minus,
|
|
||||||
Eye
|
|
||||||
} from 'lucide-react'
|
|
||||||
import { SelectedProduct } from './types'
|
|
||||||
|
|
||||||
interface CartSummaryProps {
|
interface CartSummaryProps {
|
||||||
selectedProducts: SelectedProduct[]
|
selectedProducts: SelectedProduct[];
|
||||||
onQuantityChange: (productId: string, wholesalerId: string, quantity: number) => void
|
onQuantityChange: (
|
||||||
onRemoveProduct: (productId: string, wholesalerId: string) => void
|
productId: string,
|
||||||
onCreateSupply: () => void
|
supplierId: string,
|
||||||
onToggleVisibility: () => void
|
quantity: number
|
||||||
formatCurrency: (amount: number) => string
|
) => void;
|
||||||
visible: boolean
|
onRemoveProduct: (productId: string, supplierId: string) => void;
|
||||||
|
onCreateSupply: () => void;
|
||||||
|
onToggleVisibility: () => void;
|
||||||
|
formatCurrency: (amount: number) => string;
|
||||||
|
visible: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function CartSummary({
|
export function CartSummary({
|
||||||
@ -30,36 +28,39 @@ export function CartSummary({
|
|||||||
onCreateSupply,
|
onCreateSupply,
|
||||||
onToggleVisibility,
|
onToggleVisibility,
|
||||||
formatCurrency,
|
formatCurrency,
|
||||||
visible
|
visible,
|
||||||
}: CartSummaryProps) {
|
}: CartSummaryProps) {
|
||||||
if (!visible || selectedProducts.length === 0) {
|
if (!visible || selectedProducts.length === 0) {
|
||||||
return null
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Группируем товары по поставщикам
|
// Группируем товары по поставщикам
|
||||||
const groupedProducts = selectedProducts.reduce((acc, product) => {
|
const groupedProducts = selectedProducts.reduce((acc, product) => {
|
||||||
if (!acc[product.wholesalerId]) {
|
if (!acc[product.supplierId]) {
|
||||||
acc[product.wholesalerId] = {
|
acc[product.supplierId] = {
|
||||||
wholesaler: product.wholesalerName,
|
supplier: product.supplierName,
|
||||||
products: []
|
products: [],
|
||||||
}
|
};
|
||||||
}
|
}
|
||||||
acc[product.wholesalerId].products.push(product)
|
acc[product.supplierId].products.push(product);
|
||||||
return acc
|
return acc;
|
||||||
}, {} as Record<string, { wholesaler: string; products: SelectedProduct[] }>)
|
}, {} as Record<string, { supplier: string; products: SelectedProduct[] }>);
|
||||||
|
|
||||||
const getTotalAmount = () => {
|
const getTotalAmount = () => {
|
||||||
return selectedProducts.reduce((sum, product) => {
|
return selectedProducts.reduce((sum, product) => {
|
||||||
const discountedPrice = product.discount
|
const discountedPrice = product.discount
|
||||||
? product.price * (1 - product.discount / 100)
|
? product.price * (1 - product.discount / 100)
|
||||||
: product.price
|
: product.price;
|
||||||
return sum + (discountedPrice * product.selectedQuantity)
|
return sum + discountedPrice * product.selectedQuantity;
|
||||||
}, 0)
|
}, 0);
|
||||||
}
|
};
|
||||||
|
|
||||||
const getTotalItems = () => {
|
const getTotalItems = () => {
|
||||||
return selectedProducts.reduce((sum, product) => sum + product.selectedQuantity, 0)
|
return selectedProducts.reduce(
|
||||||
}
|
(sum, product) => sum + product.selectedQuantity,
|
||||||
|
0
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className="bg-gradient-to-br from-purple-500/10 to-pink-500/10 backdrop-blur-xl border border-purple-500/20 mb-6 shadow-2xl">
|
<Card className="bg-gradient-to-br from-purple-500/10 to-pink-500/10 backdrop-blur-xl border border-purple-500/20 mb-6 shadow-2xl">
|
||||||
@ -72,7 +73,8 @@ export function CartSummary({
|
|||||||
<div>
|
<div>
|
||||||
<h3 className="text-white font-bold text-lg">Корзина</h3>
|
<h3 className="text-white font-bold text-lg">Корзина</h3>
|
||||||
<p className="text-purple-200 text-xs">
|
<p className="text-purple-200 text-xs">
|
||||||
{selectedProducts.length} товаров от {Object.keys(groupedProducts).length} поставщиков
|
{selectedProducts.length} товаров от{" "}
|
||||||
|
{Object.keys(groupedProducts).length} поставщиков
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -85,74 +87,99 @@ export function CartSummary({
|
|||||||
<Eye className="h-4 w-4" />
|
<Eye className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Группировка по поставщикам */}
|
{/* Группировка по поставщикам */}
|
||||||
{Object.entries(groupedProducts).map(([wholesalerId, group]) => (
|
{Object.entries(groupedProducts).map(([supplierId, group]) => (
|
||||||
<div key={wholesalerId} className="mb-4 last:mb-0">
|
<div key={supplierId} className="mb-4 last:mb-0">
|
||||||
<div className="flex items-center mb-2 pb-1 border-b border-white/10">
|
<div className="flex items-center mb-2 pb-1 border-b border-white/10">
|
||||||
<Building2 className="h-4 w-4 text-blue-400 mr-2" />
|
<Building2 className="h-4 w-4 text-blue-400 mr-2" />
|
||||||
<span className="text-white font-medium">{group.wholesaler}</span>
|
<span className="text-white font-medium">{group.supplier}</span>
|
||||||
<Badge className="ml-2 bg-blue-500/20 text-blue-300 border-blue-500/30 text-xs">
|
<Badge className="ml-2 bg-blue-500/20 text-blue-300 border-blue-500/30 text-xs">
|
||||||
{group.products.length} товар(ов)
|
{group.products.length} товар(ов)
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{group.products.map((product) => {
|
{group.products.map((product) => {
|
||||||
const discountedPrice = product.discount
|
const discountedPrice = product.discount
|
||||||
? product.price * (1 - product.discount / 100)
|
? product.price * (1 - product.discount / 100)
|
||||||
: product.price
|
: product.price;
|
||||||
const totalPrice = discountedPrice * product.selectedQuantity
|
const totalPrice = discountedPrice * product.selectedQuantity;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={`${product.wholesalerId}-${product.id}`} className="flex items-center space-x-3 bg-white/5 rounded-lg p-3">
|
<div
|
||||||
|
key={`${product.supplierId}-${product.id}`}
|
||||||
|
className="flex items-center space-x-3 bg-white/5 rounded-lg p-3"
|
||||||
|
>
|
||||||
<img
|
<img
|
||||||
src={product.mainImage || '/api/placeholder/50/50'}
|
src={product.mainImage || "/api/placeholder/50/50"}
|
||||||
alt={product.name}
|
alt={product.name}
|
||||||
className="w-12 h-12 rounded-lg object-cover"
|
className="w-12 h-12 rounded-lg object-cover"
|
||||||
/>
|
/>
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<h4 className="text-white font-medium text-xs mb-1 truncate">{product.name}</h4>
|
<h4 className="text-white font-medium text-xs mb-1 truncate">
|
||||||
<p className="text-white/60 text-xs mb-1">{product.article}</p>
|
{product.name}
|
||||||
|
</h4>
|
||||||
|
<p className="text-white/60 text-xs mb-1">
|
||||||
|
{product.article}
|
||||||
|
</p>
|
||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center space-x-2">
|
||||||
<div className="flex items-center space-x-1">
|
<div className="flex items-center space-x-1">
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
const newQuantity = Math.max(0, product.selectedQuantity - 1)
|
const newQuantity = Math.max(
|
||||||
|
0,
|
||||||
|
product.selectedQuantity - 1
|
||||||
|
);
|
||||||
if (newQuantity === 0) {
|
if (newQuantity === 0) {
|
||||||
onRemoveProduct(product.id, product.wholesalerId)
|
onRemoveProduct(product.id, product.supplierId);
|
||||||
} else {
|
} else {
|
||||||
onQuantityChange(product.id, product.wholesalerId, newQuantity)
|
onQuantityChange(
|
||||||
|
product.id,
|
||||||
|
product.supplierId,
|
||||||
|
newQuantity
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
className="h-6 w-6 p-0 text-white/60 hover:text-white hover:bg-white/10"
|
className="h-6 w-6 p-0 text-white/60 hover:text-white hover:bg-white/10"
|
||||||
>
|
>
|
||||||
<Minus className="h-3 w-3" />
|
<Minus className="h-3 w-3" />
|
||||||
</Button>
|
</Button>
|
||||||
<span className="text-white text-xs w-6 text-center">{product.selectedQuantity}</span>
|
<span className="text-white text-xs w-6 text-center">
|
||||||
|
{product.selectedQuantity}
|
||||||
|
</span>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
onQuantityChange(
|
onQuantityChange(
|
||||||
product.id,
|
product.id,
|
||||||
product.wholesalerId,
|
product.wholesalerId,
|
||||||
Math.min(product.quantity, product.selectedQuantity + 1)
|
Math.min(
|
||||||
)
|
product.quantity,
|
||||||
|
product.selectedQuantity + 1
|
||||||
|
)
|
||||||
|
);
|
||||||
}}
|
}}
|
||||||
disabled={product.selectedQuantity >= product.quantity}
|
disabled={
|
||||||
|
product.selectedQuantity >= product.quantity
|
||||||
|
}
|
||||||
className="h-6 w-6 p-0 text-white/60 hover:text-white hover:bg-white/10"
|
className="h-6 w-6 p-0 text-white/60 hover:text-white hover:bg-white/10"
|
||||||
>
|
>
|
||||||
<Plus className="h-3 w-3" />
|
<Plus className="h-3 w-3" />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-right">
|
<div className="text-right">
|
||||||
<div className="text-white font-semibold text-xs">{formatCurrency(totalPrice)}</div>
|
<div className="text-white font-semibold text-xs">
|
||||||
|
{formatCurrency(totalPrice)}
|
||||||
|
</div>
|
||||||
{product.discount && (
|
{product.discount && (
|
||||||
<div className="text-white/40 text-xs line-through">
|
<div className="text-white/40 text-xs line-through">
|
||||||
{formatCurrency(product.price * product.selectedQuantity)}
|
{formatCurrency(
|
||||||
|
product.price * product.selectedQuantity
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@ -161,18 +188,20 @@ export function CartSummary({
|
|||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => onRemoveProduct(product.id, product.wholesalerId)}
|
onClick={() =>
|
||||||
|
onRemoveProduct(product.id, product.supplierId)
|
||||||
|
}
|
||||||
className="text-red-400 hover:text-red-300 hover:bg-red-500/10"
|
className="text-red-400 hover:text-red-300 hover:bg-red-500/10"
|
||||||
>
|
>
|
||||||
✕
|
✕
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
{/* Итого */}
|
{/* Итого */}
|
||||||
<div className="border-t border-white/20 pt-3 mt-4">
|
<div className="border-t border-white/20 pt-3 mt-4">
|
||||||
<div className="flex justify-between items-center">
|
<div className="flex justify-between items-center">
|
||||||
@ -184,7 +213,7 @@ export function CartSummary({
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex space-x-2 mt-3">
|
<div className="flex space-x-2 mt-3">
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className="flex-1 border-purple-300/30 text-white hover:bg-white/10"
|
className="flex-1 border-purple-300/30 text-white hover:bg-white/10"
|
||||||
onClick={onToggleVisibility}
|
onClick={onToggleVisibility}
|
||||||
@ -192,7 +221,7 @@ export function CartSummary({
|
|||||||
<Plus className="h-4 w-4 mr-2" />
|
<Plus className="h-4 w-4 mr-2" />
|
||||||
Добавить еще
|
Добавить еще
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
className="flex-1 bg-gradient-to-r from-green-500 to-emerald-500 hover:from-green-600 hover:to-emerald-600 text-white"
|
className="flex-1 bg-gradient-to-r from-green-500 to-emerald-500 hover:from-green-600 hover:to-emerald-600 text-white"
|
||||||
onClick={onCreateSupply}
|
onClick={onCreateSupply}
|
||||||
>
|
>
|
||||||
@ -203,5 +232,5 @@ export function CartSummary({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
@ -1343,13 +1343,13 @@ export function DirectSupplyCreation({
|
|||||||
Цена
|
Цена
|
||||||
</div>
|
</div>
|
||||||
<div className="text-white/80 text-[9px] font-medium text-center">
|
<div className="text-white/80 text-[9px] font-medium text-center">
|
||||||
Услуги фф
|
Услуги фулфилмента
|
||||||
</div>
|
</div>
|
||||||
<div className="text-white/80 text-[9px] font-medium text-center">
|
<div className="text-white/80 text-[9px] font-medium text-center">
|
||||||
Поставщик
|
Поставщик
|
||||||
</div>
|
</div>
|
||||||
<div className="text-white/80 text-[9px] font-medium text-center">
|
<div className="text-white/80 text-[9px] font-medium text-center">
|
||||||
Расходники фф
|
Расходники фулфилмента
|
||||||
</div>
|
</div>
|
||||||
<div className="text-white/80 text-[9px] font-medium text-center">
|
<div className="text-white/80 text-[9px] font-medium text-center">
|
||||||
Расходники
|
Расходники
|
||||||
@ -1654,7 +1654,7 @@ export function DirectSupplyCreation({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Блок 7: Расходники фф */}
|
{/* Блок 7: Расходники фулфилмента */}
|
||||||
<div className="bg-white/10 rounded-lg p-2 flex flex-col justify-center h-20">
|
<div className="bg-white/10 rounded-lg p-2 flex flex-col justify-center h-20">
|
||||||
<div className="space-y-1 max-h-16 overflow-y-auto">
|
<div className="space-y-1 max-h-16 overflow-y-auto">
|
||||||
{/* DEBUG для расходников */}
|
{/* DEBUG для расходников */}
|
||||||
|
@ -21,7 +21,7 @@ import {
|
|||||||
Tags,
|
Tags,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
|
||||||
// Типы данных для расходников ФФ
|
// Типы данных для расходников фулфилмента
|
||||||
interface ConsumableParameter {
|
interface ConsumableParameter {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
@ -79,7 +79,7 @@ interface FulfillmentConsumableSupply {
|
|||||||
status: "planned" | "in-transit" | "delivered" | "completed";
|
status: "planned" | "in-transit" | "delivered" | "completed";
|
||||||
}
|
}
|
||||||
|
|
||||||
// Моковые данные для расходников ФФ
|
// Моковые данные для расходников фулфилмента
|
||||||
const mockFulfillmentConsumables: FulfillmentConsumableSupply[] = [
|
const mockFulfillmentConsumables: FulfillmentConsumableSupply[] = [
|
||||||
{
|
{
|
||||||
id: "ffc1",
|
id: "ffc1",
|
||||||
@ -116,7 +116,7 @@ const mockFulfillmentConsumables: FulfillmentConsumableSupply[] = [
|
|||||||
id: "ffcons1",
|
id: "ffcons1",
|
||||||
name: "Коробки для ФФ 40x30x15",
|
name: "Коробки для ФФ 40x30x15",
|
||||||
sku: "BOX-FF-403015",
|
sku: "BOX-FF-403015",
|
||||||
category: "Расходники ФФ",
|
category: "Расходники фулфилмента",
|
||||||
type: "packaging",
|
type: "packaging",
|
||||||
plannedQty: 2000,
|
plannedQty: 2000,
|
||||||
actualQty: 1980,
|
actualQty: 1980,
|
||||||
@ -269,10 +269,10 @@ export function FulfillmentSuppliesTab() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* Статистика расходников ФФ */}
|
{/* Статистика расходников фулфилмента */}
|
||||||
<StatsGrid>
|
<StatsGrid>
|
||||||
<StatsCard
|
<StatsCard
|
||||||
title="Расходники ФФ"
|
title="Расходники фулфилмента"
|
||||||
value={mockFulfillmentConsumables.length}
|
value={mockFulfillmentConsumables.length}
|
||||||
icon={Package2}
|
icon={Package2}
|
||||||
iconColor="text-orange-400"
|
iconColor="text-orange-400"
|
||||||
@ -282,7 +282,7 @@ export function FulfillmentSuppliesTab() {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<StatsCard
|
<StatsCard
|
||||||
title="Сумма расходников ФФ"
|
title="Сумма расходников фулфилмента"
|
||||||
value={formatCurrency(
|
value={formatCurrency(
|
||||||
mockFulfillmentConsumables.reduce(
|
mockFulfillmentConsumables.reduce(
|
||||||
(sum, supply) => sum + supply.grandTotal,
|
(sum, supply) => sum + supply.grandTotal,
|
||||||
@ -324,7 +324,7 @@ export function FulfillmentSuppliesTab() {
|
|||||||
/>
|
/>
|
||||||
</StatsGrid>
|
</StatsGrid>
|
||||||
|
|
||||||
{/* Таблица поставок расходников ФФ */}
|
{/* Таблица поставок расходников фулфилмента */}
|
||||||
<Card className="bg-white/10 backdrop-blur border-white/20 overflow-hidden">
|
<Card className="bg-white/10 backdrop-blur border-white/20 overflow-hidden">
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
<table className="w-full">
|
<table className="w-full">
|
||||||
@ -359,7 +359,7 @@ export function FulfillmentSuppliesTab() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<React.Fragment key={supply.id}>
|
<React.Fragment key={supply.id}>
|
||||||
{/* Основная строка поставки расходников ФФ */}
|
{/* Основная строка поставки расходников фулфилмента */}
|
||||||
<tr
|
<tr
|
||||||
className="border-b border-white/10 hover:bg-white/5 transition-colors bg-orange-500/10 cursor-pointer"
|
className="border-b border-white/10 hover:bg-white/5 transition-colors bg-orange-500/10 cursor-pointer"
|
||||||
onClick={() => toggleSupplyExpansion(supply.id)}
|
onClick={() => toggleSupplyExpansion(supply.id)}
|
||||||
|
@ -171,6 +171,7 @@ export function RealSupplyOrdersTab() {
|
|||||||
const { data, loading, error, refetch } = useQuery(GET_SUPPLY_ORDERS, {
|
const { data, loading, error, refetch } = useQuery(GET_SUPPLY_ORDERS, {
|
||||||
fetchPolicy: "cache-and-network",
|
fetchPolicy: "cache-and-network",
|
||||||
notifyOnNetworkStatusChange: true,
|
notifyOnNetworkStatusChange: true,
|
||||||
|
pollInterval: 30000, // 🔔 Опрашиваем каждые 30 секунд для получения новых заказов
|
||||||
});
|
});
|
||||||
|
|
||||||
// Мутация для обновления статуса заказа
|
// Мутация для обновления статуса заказа
|
||||||
@ -189,15 +190,22 @@ export function RealSupplyOrdersTab() {
|
|||||||
toast.error("Ошибка при обновлении статуса заказа");
|
toast.error("Ошибка при обновлении статуса заказа");
|
||||||
},
|
},
|
||||||
update: (cache, { data }) => {
|
update: (cache, { data }) => {
|
||||||
if (data?.updateSupplyOrderStatus?.success && data?.updateSupplyOrderStatus?.order) {
|
if (
|
||||||
console.log(`✅ Обновляем кэш для заказа ${data.updateSupplyOrderStatus.order.id} на статус ${data.updateSupplyOrderStatus.order.status}`);
|
data?.updateSupplyOrderStatus?.success &&
|
||||||
|
data?.updateSupplyOrderStatus?.order
|
||||||
|
) {
|
||||||
|
console.log(
|
||||||
|
`✅ Обновляем кэш для заказа ${data.updateSupplyOrderStatus.order.id} на статус ${data.updateSupplyOrderStatus.order.status}`
|
||||||
|
);
|
||||||
|
|
||||||
// Точечно обновляем кэш для конкретного заказа
|
// Точечно обновляем кэш для конкретного заказа
|
||||||
cache.modify({
|
cache.modify({
|
||||||
id: cache.identify(data.updateSupplyOrderStatus.order),
|
id: cache.identify(data.updateSupplyOrderStatus.order),
|
||||||
fields: {
|
fields: {
|
||||||
status() {
|
status() {
|
||||||
console.log(`📝 Обновляем поле status для заказа ${data.updateSupplyOrderStatus.order.id}`);
|
console.log(
|
||||||
|
`📝 Обновляем поле status для заказа ${data.updateSupplyOrderStatus.order.id}`
|
||||||
|
);
|
||||||
return data.updateSupplyOrderStatus.order.status;
|
return data.updateSupplyOrderStatus.order.status;
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@ -205,9 +213,13 @@ export function RealSupplyOrdersTab() {
|
|||||||
|
|
||||||
// Также обновляем данные в запросе GET_SUPPLY_ORDERS если нужно
|
// Также обновляем данные в запросе GET_SUPPLY_ORDERS если нужно
|
||||||
try {
|
try {
|
||||||
const existingData = cache.readQuery({ query: GET_SUPPLY_ORDERS }) as any;
|
const existingData = cache.readQuery({
|
||||||
|
query: GET_SUPPLY_ORDERS,
|
||||||
|
}) as any;
|
||||||
if (existingData?.supplyOrders) {
|
if (existingData?.supplyOrders) {
|
||||||
console.log(`📋 Обновляем список заказов в кэше, всего заказов: ${existingData.supplyOrders.length}`);
|
console.log(
|
||||||
|
`📋 Обновляем список заказов в кэше, всего заказов: ${existingData.supplyOrders.length}`
|
||||||
|
);
|
||||||
cache.writeQuery({
|
cache.writeQuery({
|
||||||
query: GET_SUPPLY_ORDERS,
|
query: GET_SUPPLY_ORDERS,
|
||||||
data: {
|
data: {
|
||||||
@ -215,7 +227,10 @@ export function RealSupplyOrdersTab() {
|
|||||||
supplyOrders: existingData.supplyOrders.map((order: any) => {
|
supplyOrders: existingData.supplyOrders.map((order: any) => {
|
||||||
if (order.id === data.updateSupplyOrderStatus.order.id) {
|
if (order.id === data.updateSupplyOrderStatus.order.id) {
|
||||||
console.log(`🎯 Найден и обновлен заказ ${order.id}`);
|
console.log(`🎯 Найден и обновлен заказ ${order.id}`);
|
||||||
return { ...order, status: data.updateSupplyOrderStatus.order.status };
|
return {
|
||||||
|
...order,
|
||||||
|
status: data.updateSupplyOrderStatus.order.status,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
return order;
|
return order;
|
||||||
}),
|
}),
|
||||||
@ -243,11 +258,16 @@ export function RealSupplyOrdersTab() {
|
|||||||
// Отладочное логирование для проверки дублирующихся ID
|
// Отладочное логирование для проверки дублирующихся ID
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (incomingSupplyOrders.length > 0) {
|
if (incomingSupplyOrders.length > 0) {
|
||||||
const ids = incomingSupplyOrders.map(order => order.id);
|
const ids = incomingSupplyOrders.map((order) => order.id);
|
||||||
const uniqueIds = new Set(ids);
|
const uniqueIds = new Set(ids);
|
||||||
if (ids.length !== uniqueIds.size) {
|
if (ids.length !== uniqueIds.size) {
|
||||||
console.warn(`⚠️ Обнаружены дублирующиеся ID заказов! Всего: ${ids.length}, уникальных: ${uniqueIds.size}`);
|
console.warn(
|
||||||
console.warn('Дублирующиеся ID:', ids.filter((id, index) => ids.indexOf(id) !== index));
|
`⚠️ Обнаружены дублирующиеся ID заказов! Всего: ${ids.length}, уникальных: ${uniqueIds.size}`
|
||||||
|
);
|
||||||
|
console.warn(
|
||||||
|
"Дублирующиеся ID:",
|
||||||
|
ids.filter((id, index) => ids.indexOf(id) !== index)
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
console.log(`✅ Все ID заказов уникальны: ${ids.length} заказов`);
|
console.log(`✅ Все ID заказов уникальны: ${ids.length} заказов`);
|
||||||
}
|
}
|
||||||
@ -276,7 +296,7 @@ export function RealSupplyOrdersTab() {
|
|||||||
|
|
||||||
const handleStatusUpdate = async (orderId: string, status: string) => {
|
const handleStatusUpdate = async (orderId: string, status: string) => {
|
||||||
console.log(`🔄 Обновляем статус заказа ${orderId} на ${status}`);
|
console.log(`🔄 Обновляем статус заказа ${orderId} на ${status}`);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await updateSupplyOrderStatus({
|
await updateSupplyOrderStatus({
|
||||||
variables: {
|
variables: {
|
||||||
|
@ -36,7 +36,7 @@ interface Product {
|
|||||||
parameters: ProductParameter[];
|
parameters: ProductParameter[];
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Wholesaler {
|
interface Supplier {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
inn: string;
|
inn: string;
|
||||||
|
@ -1,24 +1,19 @@
|
|||||||
"use client"
|
"use client";
|
||||||
|
|
||||||
import React from 'react'
|
import React from "react";
|
||||||
import { Card } from '@/components/ui/card'
|
import { Card } from "@/components/ui/card";
|
||||||
import { Badge } from '@/components/ui/badge'
|
import { Badge } from "@/components/ui/badge";
|
||||||
import {
|
import { Building2, MapPin, Phone, Mail } from "lucide-react";
|
||||||
Building2,
|
import { SupplierForCreation } from "./types";
|
||||||
MapPin,
|
|
||||||
Phone,
|
|
||||||
Mail
|
|
||||||
} from 'lucide-react'
|
|
||||||
import { WholesalerForCreation } from './types'
|
|
||||||
|
|
||||||
interface WholesalerCardProps {
|
interface SupplierCardProps {
|
||||||
wholesaler: WholesalerForCreation
|
supplier: SupplierForCreation;
|
||||||
onClick: () => void
|
onClick: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function WholesalerCard({ wholesaler, onClick }: WholesalerCardProps) {
|
export function SupplierCard({ supplier, onClick }: SupplierCardProps) {
|
||||||
return (
|
return (
|
||||||
<Card
|
<Card
|
||||||
className="bg-white/10 backdrop-blur border-white/20 p-4 cursor-pointer transition-all hover:bg-white/15 hover:border-white/30 hover:scale-[1.02]"
|
className="bg-white/10 backdrop-blur border-white/20 p-4 cursor-pointer transition-all hover:bg-white/15 hover:border-white/30 hover:scale-[1.02]"
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
>
|
>
|
||||||
@ -29,41 +24,46 @@ export function WholesalerCard({ wholesaler, onClick }: WholesalerCardProps) {
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<h3 className="text-white font-semibold text-sm mb-1 truncate">
|
<h3 className="text-white font-semibold text-sm mb-1 truncate">
|
||||||
{wholesaler.name}
|
{supplier.name}
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-white/60 text-xs mb-1 truncate">
|
<p className="text-white/60 text-xs mb-1 truncate">
|
||||||
{wholesaler.fullName}
|
{supplier.fullName}
|
||||||
</p>
|
|
||||||
<p className="text-white/40 text-xs">
|
|
||||||
ИНН: {wholesaler.inn}
|
|
||||||
</p>
|
</p>
|
||||||
|
<p className="text-white/40 text-xs">ИНН: {supplier.inn}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<div className="flex items-center space-x-1">
|
<div className="flex items-center space-x-1">
|
||||||
<MapPin className="h-3 w-3 text-gray-400" />
|
<MapPin className="h-3 w-3 text-gray-400" />
|
||||||
<span className="text-white/80 text-xs truncate">{wholesaler.address}</span>
|
<span className="text-white/80 text-xs truncate">
|
||||||
|
{supplier.address}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{wholesaler.phone && (
|
{supplier.phone && (
|
||||||
<div className="flex items-center space-x-1">
|
<div className="flex items-center space-x-1">
|
||||||
<Phone className="h-3 w-3 text-gray-400" />
|
<Phone className="h-3 w-3 text-gray-400" />
|
||||||
<span className="text-white/80 text-xs">{wholesaler.phone}</span>
|
<span className="text-white/80 text-xs">{supplier.phone}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{wholesaler.email && (
|
{supplier.email && (
|
||||||
<div className="flex items-center space-x-1">
|
<div className="flex items-center space-x-1">
|
||||||
<Mail className="h-3 w-3 text-gray-400" />
|
<Mail className="h-3 w-3 text-gray-400" />
|
||||||
<span className="text-white/80 text-xs truncate">{wholesaler.email}</span>
|
<span className="text-white/80 text-xs truncate">
|
||||||
|
{supplier.email}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-wrap gap-1">
|
<div className="flex flex-wrap gap-1">
|
||||||
{wholesaler.specialization.map((spec, index) => (
|
{supplier.specialization.map((spec, index) => (
|
||||||
<Badge key={index} className="bg-purple-500/20 text-purple-300 border-purple-500/30 text-xs">
|
<Badge
|
||||||
|
key={index}
|
||||||
|
className="bg-purple-500/20 text-purple-300 border-purple-500/30 text-xs"
|
||||||
|
>
|
||||||
{spec}
|
{spec}
|
||||||
</Badge>
|
</Badge>
|
||||||
))}
|
))}
|
||||||
@ -71,8 +71,12 @@ export function WholesalerCard({ wholesaler, onClick }: WholesalerCardProps) {
|
|||||||
|
|
||||||
<div className="pt-2 border-t border-white/10 flex items-center justify-between">
|
<div className="pt-2 border-t border-white/10 flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-white/60 text-xs">Товаров: {wholesaler.productCount}</p>
|
<p className="text-white/60 text-xs">
|
||||||
<p className="text-white/60 text-xs">Рейтинг: {wholesaler.rating}/5</p>
|
Товаров: {supplier.productCount}
|
||||||
|
</p>
|
||||||
|
<p className="text-white/60 text-xs">
|
||||||
|
Рейтинг: {supplier.rating}/5
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Badge className="bg-green-500/20 text-green-300 border-green-500/30 text-xs">
|
<Badge className="bg-green-500/20 text-green-300 border-green-500/30 text-xs">
|
||||||
Контрагент
|
Контрагент
|
||||||
@ -80,5 +84,5 @@ export function WholesalerCard({ wholesaler, onClick }: WholesalerCardProps) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
)
|
);
|
||||||
}
|
}
|
118
src/components/supplies/supplier-grid.tsx
Normal file
118
src/components/supplies/supplier-grid.tsx
Normal file
@ -0,0 +1,118 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React from "react";
|
||||||
|
import { SupplierCard } from "./supplier-card";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Users, Search } from "lucide-react";
|
||||||
|
import { SupplierForCreation, CounterpartySupplier } from "./types";
|
||||||
|
|
||||||
|
interface SupplierGridProps {
|
||||||
|
suppliers: CounterpartySupplier[];
|
||||||
|
onSupplierSelect: (supplier: SupplierForCreation) => void;
|
||||||
|
searchQuery: string;
|
||||||
|
onSearchChange: (query: string) => void;
|
||||||
|
loading?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SupplierGrid({
|
||||||
|
suppliers,
|
||||||
|
onSupplierSelect,
|
||||||
|
searchQuery,
|
||||||
|
onSearchChange,
|
||||||
|
loading = false,
|
||||||
|
}: SupplierGridProps) {
|
||||||
|
// Фильтруем поставщиков по поисковому запросу
|
||||||
|
const filteredSuppliers = suppliers.filter(
|
||||||
|
(supplier) =>
|
||||||
|
supplier.name?.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||||
|
supplier.fullName?.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||||
|
supplier.inn?.toLowerCase().includes(searchQuery.toLowerCase())
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleSupplierClick = (supplier: CounterpartySupplier) => {
|
||||||
|
// Адаптируем данные под существующий интерфейс
|
||||||
|
const adaptedSupplier: SupplierForCreation = {
|
||||||
|
id: supplier.id,
|
||||||
|
inn: supplier.inn || "",
|
||||||
|
name: supplier.name || "Неизвестная организация",
|
||||||
|
fullName: supplier.fullName || supplier.name || "Неизвестная организация",
|
||||||
|
address: supplier.address || "Адрес не указан",
|
||||||
|
phone: supplier.phones?.[0]?.value,
|
||||||
|
email: supplier.emails?.[0]?.value,
|
||||||
|
rating: 4.5, // Временное значение
|
||||||
|
productCount: 0, // Временное значение
|
||||||
|
specialization: ["Оптовая торговля"], // Временное значение
|
||||||
|
};
|
||||||
|
onSupplierSelect(adaptedSupplier);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center p-8">
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="animate-spin rounded-full h-12 w-12 border-4 border-white border-t-transparent mx-auto mb-4"></div>
|
||||||
|
<p className="text-white/60">Загружаем поставщиков...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{/* Поиск */}
|
||||||
|
<div className="mb-4">
|
||||||
|
<div className="relative max-w-md">
|
||||||
|
<Search className="absolute left-3 top-3 h-4 w-4 text-white/40" />
|
||||||
|
<Input
|
||||||
|
placeholder="Поиск поставщиков..."
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(e) => onSearchChange(e.target.value)}
|
||||||
|
className="pl-10 glass-input text-white placeholder:text-white/40 h-10"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{filteredSuppliers.length === 0 ? (
|
||||||
|
<div className="text-center p-8">
|
||||||
|
<Users className="h-12 w-12 text-white/20 mx-auto mb-4" />
|
||||||
|
<p className="text-white/60">
|
||||||
|
{searchQuery
|
||||||
|
? "Поставщики не найдены"
|
||||||
|
: "У вас нет контрагентов-поставщиков"}
|
||||||
|
</p>
|
||||||
|
<p className="text-white/40 text-sm mt-2">
|
||||||
|
{searchQuery
|
||||||
|
? "Попробуйте изменить условия поиска"
|
||||||
|
: 'Добавьте поставщиков в разделе "Партнеры"'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
|
||||||
|
{filteredSuppliers.map((supplier) => {
|
||||||
|
const adaptedSupplier: SupplierForCreation = {
|
||||||
|
id: supplier.id,
|
||||||
|
inn: supplier.inn || "",
|
||||||
|
name: supplier.name || "Неизвестная организация",
|
||||||
|
fullName:
|
||||||
|
supplier.fullName || supplier.name || "Неизвестная организация",
|
||||||
|
address: supplier.address || "Адрес не указан",
|
||||||
|
phone: supplier.phones?.[0]?.value,
|
||||||
|
email: supplier.emails?.[0]?.value,
|
||||||
|
rating: 4.5,
|
||||||
|
productCount: 0,
|
||||||
|
specialization: ["Оптовая торговля"],
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SupplierCard
|
||||||
|
key={supplier.id}
|
||||||
|
supplier={adaptedSupplier}
|
||||||
|
onClick={() => handleSupplierClick(supplier)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
@ -1,30 +1,30 @@
|
|||||||
"use client"
|
"use client";
|
||||||
|
|
||||||
import React from 'react'
|
import React from "react";
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from "@/components/ui/button";
|
||||||
import { ProductGrid } from './product-grid'
|
import { ProductGrid } from "./product-grid";
|
||||||
import { CartSummary } from './cart-summary'
|
import { CartSummary } from "./cart-summary";
|
||||||
import { FloatingCart } from './floating-cart'
|
import { FloatingCart } from "./floating-cart";
|
||||||
import { Sidebar } from '@/components/dashboard/sidebar'
|
import { Sidebar } from "@/components/dashboard/sidebar";
|
||||||
import { useSidebar } from '@/hooks/useSidebar'
|
import { useSidebar } from "@/hooks/useSidebar";
|
||||||
import { ArrowLeft, Info } from 'lucide-react'
|
import { ArrowLeft, Info } from "lucide-react";
|
||||||
import { WholesalerForCreation, WholesalerProduct, SelectedProduct } from './types'
|
import { SupplierForCreation, SupplierProduct, SelectedProduct } from "./types";
|
||||||
|
|
||||||
interface WholesalerProductsPageProps {
|
interface SupplierProductsPageProps {
|
||||||
selectedWholesaler: WholesalerForCreation
|
selectedSupplier: SupplierForCreation;
|
||||||
products: WholesalerProduct[]
|
products: SupplierProduct[];
|
||||||
selectedProducts: SelectedProduct[]
|
selectedProducts: SelectedProduct[];
|
||||||
onQuantityChange: (productId: string, quantity: number) => void
|
onQuantityChange: (productId: string, quantity: number) => void;
|
||||||
onBack: () => void
|
onBack: () => void;
|
||||||
onCreateSupply: () => void
|
onCreateSupply: () => void;
|
||||||
formatCurrency: (amount: number) => string
|
formatCurrency: (amount: number) => string;
|
||||||
showSummary: boolean
|
showSummary: boolean;
|
||||||
setShowSummary: (show: boolean) => void
|
setShowSummary: (show: boolean) => void;
|
||||||
loading: boolean
|
loading: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function WholesalerProductsPage({
|
export function SupplierProductsPage({
|
||||||
selectedWholesaler,
|
selectedSupplier,
|
||||||
products,
|
products,
|
||||||
selectedProducts,
|
selectedProducts,
|
||||||
onQuantityChange,
|
onQuantityChange,
|
||||||
@ -33,50 +33,61 @@ export function WholesalerProductsPage({
|
|||||||
formatCurrency,
|
formatCurrency,
|
||||||
showSummary,
|
showSummary,
|
||||||
setShowSummary,
|
setShowSummary,
|
||||||
loading
|
loading,
|
||||||
}: WholesalerProductsPageProps) {
|
}: SupplierProductsPageProps) {
|
||||||
const { getSidebarMargin } = useSidebar()
|
const { getSidebarMargin } = useSidebar();
|
||||||
|
|
||||||
const getSelectedQuantity = (productId: string): number => {
|
const getSelectedQuantity = (productId: string): number => {
|
||||||
const selected = selectedProducts.find(p => p.id === productId && p.wholesalerId === selectedWholesaler.id)
|
const selected = selectedProducts.find(
|
||||||
return selected ? selected.selectedQuantity : 0
|
(p) => p.id === productId && p.supplierId === selectedSupplier.id
|
||||||
}
|
);
|
||||||
|
return selected ? selected.selectedQuantity : 0;
|
||||||
|
};
|
||||||
|
|
||||||
const selectedProductsMap = products.reduce((acc, product) => {
|
const selectedProductsMap = products.reduce((acc, product) => {
|
||||||
acc[product.id] = getSelectedQuantity(product.id)
|
acc[product.id] = getSelectedQuantity(product.id);
|
||||||
return acc
|
return acc;
|
||||||
}, {} as Record<string, number>)
|
}, {} as Record<string, number>);
|
||||||
|
|
||||||
const getTotalAmount = () => {
|
const getTotalAmount = () => {
|
||||||
return selectedProducts.reduce((sum, product) => {
|
return selectedProducts.reduce((sum, product) => {
|
||||||
const discountedPrice = product.discount
|
const discountedPrice = product.discount
|
||||||
? product.price * (1 - product.discount / 100)
|
? product.price * (1 - product.discount / 100)
|
||||||
: product.price
|
: product.price;
|
||||||
return sum + (discountedPrice * product.selectedQuantity)
|
return sum + discountedPrice * product.selectedQuantity;
|
||||||
}, 0)
|
}, 0);
|
||||||
}
|
};
|
||||||
|
|
||||||
const getTotalItems = () => {
|
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) => {
|
const handleRemoveProduct = (productId: string, supplierId: string) => {
|
||||||
onQuantityChange(productId, 0)
|
onQuantityChange(productId, 0);
|
||||||
}
|
};
|
||||||
|
|
||||||
const handleCartQuantityChange = (productId: string, wholesalerId: string, quantity: number) => {
|
const handleCartQuantityChange = (
|
||||||
onQuantityChange(productId, quantity)
|
productId: string,
|
||||||
}
|
supplierId: string,
|
||||||
|
quantity: number
|
||||||
|
) => {
|
||||||
|
onQuantityChange(productId, quantity);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-screen flex overflow-hidden">
|
<div className="h-screen flex overflow-hidden">
|
||||||
<Sidebar />
|
<Sidebar />
|
||||||
<main className={`flex-1 ${getSidebarMargin()} px-6 py-4 overflow-hidden transition-all duration-300`}>
|
<main
|
||||||
|
className={`flex-1 ${getSidebarMargin()} px-6 py-4 overflow-hidden transition-all duration-300`}
|
||||||
|
>
|
||||||
<div className="p-8">
|
<div className="p-8">
|
||||||
<div className="flex items-center justify-between mb-8">
|
<div className="flex items-center justify-between mb-8">
|
||||||
<div className="flex items-center space-x-4">
|
<div className="flex items-center space-x-4">
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={onBack}
|
onClick={onBack}
|
||||||
className="text-white/60 hover:text-white hover:bg-white/10"
|
className="text-white/60 hover:text-white hover:bg-white/10"
|
||||||
@ -85,13 +96,17 @@ export function WholesalerProductsPage({
|
|||||||
Назад
|
Назад
|
||||||
</Button>
|
</Button>
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-3xl font-bold text-white mb-2">Товары поставщика</h1>
|
<h1 className="text-3xl font-bold text-white mb-2">
|
||||||
<p className="text-white/60">{selectedWholesaler.name} • {products.length} товаров</p>
|
Товары поставщика
|
||||||
|
</h1>
|
||||||
|
<p className="text-white/60">
|
||||||
|
{selectedSupplier.name} • {products.length} товаров
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center space-x-3">
|
<div className="flex items-center space-x-3">
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => setShowSummary(!showSummary)}
|
onClick={() => setShowSummary(!showSummary)}
|
||||||
className="text-white/60 hover:text-white hover:bg-white/10"
|
className="text-white/60 hover:text-white hover:bg-white/10"
|
||||||
@ -130,5 +145,5 @@ export function WholesalerProductsPage({
|
|||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
}
|
@ -1,230 +1,241 @@
|
|||||||
"use client"
|
"use client";
|
||||||
|
|
||||||
import React, { useState } from 'react'
|
import React, { useState } from "react";
|
||||||
import { Card } from '@/components/ui/card'
|
import { Card } from "@/components/ui/card";
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from "@/components/ui/button";
|
||||||
import { Badge } from '@/components/ui/badge'
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Input } from '@/components/ui/input'
|
import { Input } from "@/components/ui/input";
|
||||||
import {
|
import {
|
||||||
ArrowLeft,
|
ArrowLeft,
|
||||||
Package,
|
Package,
|
||||||
Plus,
|
Plus,
|
||||||
Minus,
|
Minus,
|
||||||
ShoppingCart,
|
ShoppingCart,
|
||||||
Eye,
|
Eye,
|
||||||
Info
|
Info,
|
||||||
} from 'lucide-react'
|
} from "lucide-react";
|
||||||
import Image from 'next/image'
|
import Image from "next/image";
|
||||||
|
|
||||||
interface Wholesaler {
|
interface Supplier {
|
||||||
id: string
|
id: string;
|
||||||
inn: string
|
inn: string;
|
||||||
name: string
|
name: string;
|
||||||
fullName: string
|
fullName: string;
|
||||||
address: string
|
address: string;
|
||||||
phone?: string
|
phone?: string;
|
||||||
email?: string
|
email?: string;
|
||||||
rating: number
|
rating: number;
|
||||||
productCount: number
|
productCount: number;
|
||||||
avatar?: string
|
avatar?: string;
|
||||||
specialization: string[]
|
specialization: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Product {
|
interface Product {
|
||||||
id: string
|
id: string;
|
||||||
name: string
|
name: string;
|
||||||
article: string
|
article: string;
|
||||||
description: string
|
description: string;
|
||||||
price: number
|
price: number;
|
||||||
quantity: number
|
quantity: number;
|
||||||
category: string
|
category: string;
|
||||||
brand?: string
|
brand?: string;
|
||||||
color?: string
|
color?: string;
|
||||||
size?: string
|
size?: string;
|
||||||
weight?: number
|
weight?: number;
|
||||||
dimensions?: string
|
dimensions?: string;
|
||||||
material?: string
|
material?: string;
|
||||||
images: string[]
|
images: string[];
|
||||||
mainImage?: string
|
mainImage?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface SelectedProduct extends Product {
|
interface SelectedProduct extends Product {
|
||||||
selectedQuantity: number
|
selectedQuantity: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface WholesalerProductsProps {
|
interface SupplierProductsProps {
|
||||||
wholesaler: Wholesaler
|
supplier: Supplier;
|
||||||
onBack: () => void
|
onBack: () => void;
|
||||||
onClose: () => void
|
onClose: () => void;
|
||||||
onSupplyCreated: () => void
|
onSupplyCreated: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Моковые данные товаров
|
// Моковые данные товаров
|
||||||
const mockProducts: Product[] = [
|
const mockProducts: Product[] = [
|
||||||
{
|
{
|
||||||
id: '1',
|
id: "1",
|
||||||
name: 'Смартфон Samsung Galaxy A54',
|
name: "Смартфон Samsung Galaxy A54",
|
||||||
article: 'SGX-A54-128',
|
article: "SGX-A54-128",
|
||||||
description: 'Смартфон с экраном 6.4", камерой 50 МП, 128 ГБ памяти',
|
description: 'Смартфон с экраном 6.4", камерой 50 МП, 128 ГБ памяти',
|
||||||
price: 28900,
|
price: 28900,
|
||||||
quantity: 150,
|
quantity: 150,
|
||||||
category: 'Смартфоны',
|
category: "Смартфоны",
|
||||||
brand: 'Samsung',
|
brand: "Samsung",
|
||||||
color: 'Черный',
|
color: "Черный",
|
||||||
size: '6.4"',
|
size: '6.4"',
|
||||||
weight: 202,
|
weight: 202,
|
||||||
dimensions: '158.2 x 76.7 x 8.2 мм',
|
dimensions: "158.2 x 76.7 x 8.2 мм",
|
||||||
material: 'Алюминий, стекло',
|
material: "Алюминий, стекло",
|
||||||
images: ['/api/placeholder/300/300?text=Samsung+A54'],
|
images: ["/api/placeholder/300/300?text=Samsung+A54"],
|
||||||
mainImage: '/api/placeholder/300/300?text=Samsung+A54'
|
mainImage: "/api/placeholder/300/300?text=Samsung+A54",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: '2',
|
id: "2",
|
||||||
name: 'Наушники Sony WH-1000XM4',
|
name: "Наушники Sony WH-1000XM4",
|
||||||
article: 'SNY-WH1000XM4',
|
article: "SNY-WH1000XM4",
|
||||||
description: 'Беспроводные наушники с шумоподавлением',
|
description: "Беспроводные наушники с шумоподавлением",
|
||||||
price: 24900,
|
price: 24900,
|
||||||
quantity: 85,
|
quantity: 85,
|
||||||
category: 'Наушники',
|
category: "Наушники",
|
||||||
brand: 'Sony',
|
brand: "Sony",
|
||||||
color: 'Черный',
|
color: "Черный",
|
||||||
weight: 254,
|
weight: 254,
|
||||||
material: 'Пластик, кожа',
|
material: "Пластик, кожа",
|
||||||
images: ['/api/placeholder/300/300?text=Sony+WH1000XM4'],
|
images: ["/api/placeholder/300/300?text=Sony+WH1000XM4"],
|
||||||
mainImage: '/api/placeholder/300/300?text=Sony+WH1000XM4'
|
mainImage: "/api/placeholder/300/300?text=Sony+WH1000XM4",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: '3',
|
id: "3",
|
||||||
name: 'Планшет iPad Air 10.9"',
|
name: 'Планшет iPad Air 10.9"',
|
||||||
article: 'APL-IPADAIR-64',
|
article: "APL-IPADAIR-64",
|
||||||
description: 'Планшет Apple iPad Air с чипом M1, 64 ГБ',
|
description: "Планшет Apple iPad Air с чипом M1, 64 ГБ",
|
||||||
price: 54900,
|
price: 54900,
|
||||||
quantity: 45,
|
quantity: 45,
|
||||||
category: 'Планшеты',
|
category: "Планшеты",
|
||||||
brand: 'Apple',
|
brand: "Apple",
|
||||||
color: 'Серый космос',
|
color: "Серый космос",
|
||||||
size: '10.9"',
|
size: '10.9"',
|
||||||
weight: 461,
|
weight: 461,
|
||||||
dimensions: '247.6 x 178.5 x 6.1 мм',
|
dimensions: "247.6 x 178.5 x 6.1 мм",
|
||||||
material: 'Алюминий',
|
material: "Алюминий",
|
||||||
images: ['/api/placeholder/300/300?text=iPad+Air'],
|
images: ["/api/placeholder/300/300?text=iPad+Air"],
|
||||||
mainImage: '/api/placeholder/300/300?text=iPad+Air'
|
mainImage: "/api/placeholder/300/300?text=iPad+Air",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: '4',
|
id: "4",
|
||||||
name: 'Ноутбук Lenovo ThinkPad E15',
|
name: "Ноутбук Lenovo ThinkPad E15",
|
||||||
article: 'LNV-TE15-I5',
|
article: "LNV-TE15-I5",
|
||||||
description: 'Ноутбук 15.6" Intel Core i5, 8 ГБ ОЗУ, 256 ГБ SSD',
|
description: 'Ноутбук 15.6" Intel Core i5, 8 ГБ ОЗУ, 256 ГБ SSD',
|
||||||
price: 45900,
|
price: 45900,
|
||||||
quantity: 25,
|
quantity: 25,
|
||||||
category: 'Ноутбуки',
|
category: "Ноутбуки",
|
||||||
brand: 'Lenovo',
|
brand: "Lenovo",
|
||||||
color: 'Черный',
|
color: "Черный",
|
||||||
size: '15.6"',
|
size: '15.6"',
|
||||||
weight: 1700,
|
weight: 1700,
|
||||||
dimensions: '365 x 240 x 19.9 мм',
|
dimensions: "365 x 240 x 19.9 мм",
|
||||||
material: 'Пластик',
|
material: "Пластик",
|
||||||
images: ['/api/placeholder/300/300?text=ThinkPad+E15'],
|
images: ["/api/placeholder/300/300?text=ThinkPad+E15"],
|
||||||
mainImage: '/api/placeholder/300/300?text=ThinkPad+E15'
|
mainImage: "/api/placeholder/300/300?text=ThinkPad+E15",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: '5',
|
id: "5",
|
||||||
name: 'Умные часы Apple Watch SE',
|
name: "Умные часы Apple Watch SE",
|
||||||
article: 'APL-AWSE-40',
|
article: "APL-AWSE-40",
|
||||||
description: 'Умные часы Apple Watch SE 40 мм',
|
description: "Умные часы Apple Watch SE 40 мм",
|
||||||
price: 21900,
|
price: 21900,
|
||||||
quantity: 120,
|
quantity: 120,
|
||||||
category: 'Умные часы',
|
category: "Умные часы",
|
||||||
brand: 'Apple',
|
brand: "Apple",
|
||||||
color: 'Белый',
|
color: "Белый",
|
||||||
size: '40 мм',
|
size: "40 мм",
|
||||||
weight: 30,
|
weight: 30,
|
||||||
dimensions: '40 x 34 x 10.7 мм',
|
dimensions: "40 x 34 x 10.7 мм",
|
||||||
material: 'Алюминий',
|
material: "Алюминий",
|
||||||
images: ['/api/placeholder/300/300?text=Apple+Watch+SE'],
|
images: ["/api/placeholder/300/300?text=Apple+Watch+SE"],
|
||||||
mainImage: '/api/placeholder/300/300?text=Apple+Watch+SE'
|
mainImage: "/api/placeholder/300/300?text=Apple+Watch+SE",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: '6',
|
id: "6",
|
||||||
name: 'Клавиатура Logitech MX Keys',
|
name: "Клавиатура Logitech MX Keys",
|
||||||
article: 'LGT-MXKEYS',
|
article: "LGT-MXKEYS",
|
||||||
description: 'Беспроводная клавиатура для продуктивной работы',
|
description: "Беспроводная клавиатура для продуктивной работы",
|
||||||
price: 8900,
|
price: 8900,
|
||||||
quantity: 75,
|
quantity: 75,
|
||||||
category: 'Клавиатуры',
|
category: "Клавиатуры",
|
||||||
brand: 'Logitech',
|
brand: "Logitech",
|
||||||
color: 'Графит',
|
color: "Графит",
|
||||||
weight: 810,
|
weight: 810,
|
||||||
dimensions: '430.2 x 20.5 x 131.6 мм',
|
dimensions: "430.2 x 20.5 x 131.6 мм",
|
||||||
material: 'Пластик, металл',
|
material: "Пластик, металл",
|
||||||
images: ['/api/placeholder/300/300?text=MX+Keys'],
|
images: ["/api/placeholder/300/300?text=MX+Keys"],
|
||||||
mainImage: '/api/placeholder/300/300?text=MX+Keys'
|
mainImage: "/api/placeholder/300/300?text=MX+Keys",
|
||||||
}
|
},
|
||||||
]
|
];
|
||||||
|
|
||||||
export function WholesalerProducts({ wholesaler, onBack, onClose, onSupplyCreated }: WholesalerProductsProps) {
|
export function SupplierProducts({
|
||||||
const [selectedProducts, setSelectedProducts] = useState<SelectedProduct[]>([])
|
supplier,
|
||||||
const [showSummary, setShowSummary] = useState(false)
|
onBack,
|
||||||
|
onClose,
|
||||||
|
onSupplyCreated,
|
||||||
|
}: SupplierProductsProps) {
|
||||||
|
const [selectedProducts, setSelectedProducts] = useState<SelectedProduct[]>(
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
const [showSummary, setShowSummary] = useState(false);
|
||||||
|
|
||||||
const formatCurrency = (amount: number) => {
|
const formatCurrency = (amount: number) => {
|
||||||
return new Intl.NumberFormat('ru-RU', {
|
return new Intl.NumberFormat("ru-RU", {
|
||||||
style: 'currency',
|
style: "currency",
|
||||||
currency: 'RUB',
|
currency: "RUB",
|
||||||
minimumFractionDigits: 0
|
minimumFractionDigits: 0,
|
||||||
}).format(amount)
|
}).format(amount);
|
||||||
}
|
};
|
||||||
|
|
||||||
const updateProductQuantity = (productId: string, quantity: number) => {
|
const updateProductQuantity = (productId: string, quantity: number) => {
|
||||||
const product = mockProducts.find(p => p.id === productId)
|
const product = mockProducts.find((p) => p.id === productId);
|
||||||
if (!product) return
|
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) {
|
if (quantity === 0) {
|
||||||
// Удаляем продукт если количество 0
|
// Удаляем продукт если количество 0
|
||||||
return prev.filter(p => p.id !== productId)
|
return prev.filter((p) => p.id !== productId);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (existing) {
|
if (existing) {
|
||||||
// Обновляем количество существующего продукта
|
// Обновляем количество существующего продукта
|
||||||
return prev.map(p =>
|
return prev.map((p) =>
|
||||||
p.id === productId ? { ...p, selectedQuantity: quantity } : p
|
p.id === productId ? { ...p, selectedQuantity: quantity } : p
|
||||||
)
|
);
|
||||||
} else {
|
} else {
|
||||||
// Добавляем новый продукт
|
// Добавляем новый продукт
|
||||||
return [...prev, { ...product, selectedQuantity: quantity }]
|
return [...prev, { ...product, selectedQuantity: quantity }];
|
||||||
}
|
}
|
||||||
})
|
});
|
||||||
}
|
};
|
||||||
|
|
||||||
const getSelectedQuantity = (productId: string): number => {
|
const getSelectedQuantity = (productId: string): number => {
|
||||||
const selected = selectedProducts.find(p => p.id === productId)
|
const selected = selectedProducts.find((p) => p.id === productId);
|
||||||
return selected ? selected.selectedQuantity : 0
|
return selected ? selected.selectedQuantity : 0;
|
||||||
}
|
};
|
||||||
|
|
||||||
const getTotalAmount = () => {
|
const getTotalAmount = () => {
|
||||||
return selectedProducts.reduce((sum, product) =>
|
return selectedProducts.reduce(
|
||||||
sum + (product.price * product.selectedQuantity), 0
|
(sum, product) => sum + product.price * product.selectedQuantity,
|
||||||
)
|
0
|
||||||
}
|
);
|
||||||
|
};
|
||||||
|
|
||||||
const getTotalItems = () => {
|
const getTotalItems = () => {
|
||||||
return selectedProducts.reduce((sum, product) => sum + product.selectedQuantity, 0)
|
return selectedProducts.reduce(
|
||||||
}
|
(sum, product) => sum + product.selectedQuantity,
|
||||||
|
0
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
const handleCreateSupply = () => {
|
const handleCreateSupply = () => {
|
||||||
console.log('Создание поставки с товарами:', selectedProducts)
|
console.log("Создание поставки с товарами:", selectedProducts);
|
||||||
// TODO: Здесь будет реальное создание поставки
|
// TODO: Здесь будет реальное создание поставки
|
||||||
onSupplyCreated()
|
onSupplyCreated();
|
||||||
}
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="flex items-center space-x-3">
|
<div className="flex items-center space-x-3">
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={onBack}
|
onClick={onBack}
|
||||||
className="text-white/60 hover:text-white hover:bg-white/10"
|
className="text-white/60 hover:text-white hover:bg-white/10"
|
||||||
@ -233,13 +244,17 @@ export function WholesalerProducts({ wholesaler, onBack, onClose, onSupplyCreate
|
|||||||
Назад
|
Назад
|
||||||
</Button>
|
</Button>
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-2xl font-bold text-white mb-1">Товары поставщика</h2>
|
<h2 className="text-2xl font-bold text-white mb-1">
|
||||||
<p className="text-white/60">{wholesaler.name} • {mockProducts.length} товаров</p>
|
Товары поставщика
|
||||||
|
</h2>
|
||||||
|
<p className="text-white/60">
|
||||||
|
{supplier.name} • {mockProducts.length} товаров
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center space-x-3">
|
<div className="flex items-center space-x-3">
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => setShowSummary(!showSummary)}
|
onClick={() => setShowSummary(!showSummary)}
|
||||||
className="text-white/60 hover:text-white hover:bg-white/10"
|
className="text-white/60 hover:text-white hover:bg-white/10"
|
||||||
@ -247,8 +262,8 @@ export function WholesalerProducts({ wholesaler, onBack, onClose, onSupplyCreate
|
|||||||
<Info className="h-4 w-4 mr-2" />
|
<Info className="h-4 w-4 mr-2" />
|
||||||
Резюме ({selectedProducts.length})
|
Резюме ({selectedProducts.length})
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
className="text-white/60 hover:text-white hover:bg-white/10"
|
className="text-white/60 hover:text-white hover:bg-white/10"
|
||||||
@ -260,13 +275,20 @@ export function WholesalerProducts({ wholesaler, onBack, onClose, onSupplyCreate
|
|||||||
|
|
||||||
{showSummary && selectedProducts.length > 0 && (
|
{showSummary && selectedProducts.length > 0 && (
|
||||||
<Card className="bg-purple-500/10 backdrop-blur border-purple-500/30 p-6">
|
<Card className="bg-purple-500/10 backdrop-blur border-purple-500/30 p-6">
|
||||||
<h3 className="text-white font-semibold text-lg mb-4">Резюме заказа</h3>
|
<h3 className="text-white font-semibold text-lg mb-4">
|
||||||
|
Резюме заказа
|
||||||
|
</h3>
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{selectedProducts.map((product) => (
|
{selectedProducts.map((product) => (
|
||||||
<div key={product.id} className="flex justify-between items-center">
|
<div
|
||||||
|
key={product.id}
|
||||||
|
className="flex justify-between items-center"
|
||||||
|
>
|
||||||
<div>
|
<div>
|
||||||
<span className="text-white">{product.name}</span>
|
<span className="text-white">{product.name}</span>
|
||||||
<span className="text-white/60 text-sm ml-2">× {product.selectedQuantity}</span>
|
<span className="text-white/60 text-sm ml-2">
|
||||||
|
× {product.selectedQuantity}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<span className="text-white font-medium">
|
<span className="text-white font-medium">
|
||||||
{formatCurrency(product.price * product.selectedQuantity)}
|
{formatCurrency(product.price * product.selectedQuantity)}
|
||||||
@ -281,7 +303,7 @@ export function WholesalerProducts({ wholesaler, onBack, onClose, onSupplyCreate
|
|||||||
{formatCurrency(getTotalAmount())}
|
{formatCurrency(getTotalAmount())}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Button
|
||||||
className="w-full bg-gradient-to-r from-green-500 to-emerald-500 hover:from-green-600 hover:to-emerald-600 text-white"
|
className="w-full bg-gradient-to-r from-green-500 to-emerald-500 hover:from-green-600 hover:to-emerald-600 text-white"
|
||||||
onClick={handleCreateSupply}
|
onClick={handleCreateSupply}
|
||||||
disabled={selectedProducts.length === 0}
|
disabled={selectedProducts.length === 0}
|
||||||
@ -295,13 +317,16 @@ export function WholesalerProducts({ wholesaler, onBack, onClose, onSupplyCreate
|
|||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||||
{mockProducts.map((product) => {
|
{mockProducts.map((product) => {
|
||||||
const selectedQuantity = getSelectedQuantity(product.id)
|
const selectedQuantity = getSelectedQuantity(product.id);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card key={product.id} className="bg-white/10 backdrop-blur border-white/20 overflow-hidden">
|
<Card
|
||||||
|
key={product.id}
|
||||||
|
className="bg-white/10 backdrop-blur border-white/20 overflow-hidden"
|
||||||
|
>
|
||||||
<div className="aspect-square relative bg-white/5">
|
<div className="aspect-square relative bg-white/5">
|
||||||
<Image
|
<Image
|
||||||
src={product.mainImage || '/api/placeholder/300/300'}
|
src={product.mainImage || "/api/placeholder/300/300"}
|
||||||
alt={product.name}
|
alt={product.name}
|
||||||
fill
|
fill
|
||||||
className="object-cover"
|
className="object-cover"
|
||||||
@ -312,7 +337,7 @@ export function WholesalerProducts({ wholesaler, onBack, onClose, onSupplyCreate
|
|||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="p-4 space-y-3">
|
<div className="p-4 space-y-3">
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-white font-semibold mb-1 line-clamp-2">
|
<h3 className="text-white font-semibold mb-1 line-clamp-2">
|
||||||
@ -350,7 +375,8 @@ export function WholesalerProducts({ wholesaler, onBack, onClose, onSupplyCreate
|
|||||||
)}
|
)}
|
||||||
{product.weight && (
|
{product.weight && (
|
||||||
<div className="text-white/60 text-xs">
|
<div className="text-white/60 text-xs">
|
||||||
Вес: <span className="text-white">{product.weight} г</span>
|
Вес:{" "}
|
||||||
|
<span className="text-white">{product.weight} г</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@ -368,7 +394,12 @@ export function WholesalerProducts({ wholesaler, onBack, onClose, onSupplyCreate
|
|||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => updateProductQuantity(product.id, Math.max(0, selectedQuantity - 1))}
|
onClick={() =>
|
||||||
|
updateProductQuantity(
|
||||||
|
product.id,
|
||||||
|
Math.max(0, selectedQuantity - 1)
|
||||||
|
)
|
||||||
|
}
|
||||||
disabled={selectedQuantity === 0}
|
disabled={selectedQuantity === 0}
|
||||||
className="h-8 w-8 p-0 text-white/60 hover:text-white hover:bg-white/10"
|
className="h-8 w-8 p-0 text-white/60 hover:text-white hover:bg-white/10"
|
||||||
>
|
>
|
||||||
@ -378,8 +409,14 @@ export function WholesalerProducts({ wholesaler, onBack, onClose, onSupplyCreate
|
|||||||
type="number"
|
type="number"
|
||||||
value={selectedQuantity}
|
value={selectedQuantity}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
const value = Math.max(0, Math.min(product.quantity, parseInt(e.target.value) || 0))
|
const value = Math.max(
|
||||||
updateProductQuantity(product.id, value)
|
0,
|
||||||
|
Math.min(
|
||||||
|
product.quantity,
|
||||||
|
parseInt(e.target.value) || 0
|
||||||
|
)
|
||||||
|
);
|
||||||
|
updateProductQuantity(product.id, value);
|
||||||
}}
|
}}
|
||||||
className="h-8 w-16 text-center bg-white/10 border-white/20 text-white"
|
className="h-8 w-16 text-center bg-white/10 border-white/20 text-white"
|
||||||
min={0}
|
min={0}
|
||||||
@ -388,7 +425,12 @@ export function WholesalerProducts({ wholesaler, onBack, onClose, onSupplyCreate
|
|||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => updateProductQuantity(product.id, Math.min(product.quantity, selectedQuantity + 1))}
|
onClick={() =>
|
||||||
|
updateProductQuantity(
|
||||||
|
product.id,
|
||||||
|
Math.min(product.quantity, selectedQuantity + 1)
|
||||||
|
)
|
||||||
|
}
|
||||||
disabled={selectedQuantity >= product.quantity}
|
disabled={selectedQuantity >= product.quantity}
|
||||||
className="h-8 w-8 p-0 text-white/60 hover:text-white hover:bg-white/10"
|
className="h-8 w-8 p-0 text-white/60 hover:text-white hover:bg-white/10"
|
||||||
>
|
>
|
||||||
@ -405,21 +447,22 @@ export function WholesalerProducts({ wholesaler, onBack, onClose, onSupplyCreate
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
)
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{selectedProducts.length > 0 && (
|
{selectedProducts.length > 0 && (
|
||||||
<div className="fixed bottom-6 right-6">
|
<div className="fixed bottom-6 right-6">
|
||||||
<Button
|
<Button
|
||||||
className="bg-gradient-to-r from-purple-500 to-pink-500 hover:from-purple-600 hover:to-pink-600 text-white shadow-lg"
|
className="bg-gradient-to-r from-purple-500 to-pink-500 hover:from-purple-600 hover:to-pink-600 text-white shadow-lg"
|
||||||
onClick={() => setShowSummary(!showSummary)}
|
onClick={() => setShowSummary(!showSummary)}
|
||||||
>
|
>
|
||||||
<ShoppingCart className="h-4 w-4 mr-2" />
|
<ShoppingCart className="h-4 w-4 mr-2" />
|
||||||
Корзина ({selectedProducts.length}) • {formatCurrency(getTotalAmount())}
|
Корзина ({selectedProducts.length}) •{" "}
|
||||||
|
{formatCurrency(getTotalAmount())}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
}
|
@ -1,136 +1,144 @@
|
|||||||
"use client"
|
"use client";
|
||||||
|
|
||||||
import React, { useState } from 'react'
|
import React, { useState } from "react";
|
||||||
import { Card } from '@/components/ui/card'
|
import { Card } from "@/components/ui/card";
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from "@/components/ui/button";
|
||||||
import { Badge } from '@/components/ui/badge'
|
import { Badge } from "@/components/ui/badge";
|
||||||
import {
|
import {
|
||||||
ArrowLeft,
|
ArrowLeft,
|
||||||
Building2,
|
Building2,
|
||||||
MapPin,
|
MapPin,
|
||||||
Phone,
|
Phone,
|
||||||
Mail,
|
Mail,
|
||||||
Package,
|
Package,
|
||||||
Star
|
Star,
|
||||||
} from 'lucide-react'
|
} from "lucide-react";
|
||||||
// import { WholesalerProducts } from './wholesaler-products'
|
// import { SupplierProducts } from './supplier-products'
|
||||||
|
|
||||||
interface Wholesaler {
|
interface Supplier {
|
||||||
id: string
|
id: string;
|
||||||
inn: string
|
inn: string;
|
||||||
name: string
|
name: string;
|
||||||
fullName: string
|
fullName: string;
|
||||||
address: string
|
address: string;
|
||||||
phone?: string
|
phone?: string;
|
||||||
email?: string
|
email?: string;
|
||||||
rating: number
|
rating: number;
|
||||||
productCount: number
|
productCount: number;
|
||||||
avatar?: string
|
avatar?: string;
|
||||||
specialization: string[]
|
specialization: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
interface WholesalerSelectionProps {
|
interface SupplierSelectionProps {
|
||||||
onBack: () => void
|
onBack: () => void;
|
||||||
onClose: () => void
|
onClose: () => void;
|
||||||
onSupplyCreated: () => void
|
onSupplyCreated: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Моковые данные поставщиков
|
// Моковые данные поставщиков
|
||||||
const mockWholesalers: Wholesaler[] = [
|
const mockSuppliers: Supplier[] = [
|
||||||
{
|
{
|
||||||
id: '1',
|
id: "1",
|
||||||
inn: '7707083893',
|
inn: "7707083893",
|
||||||
name: 'ОПТ-Электроника',
|
name: "ОПТ-Электроника",
|
||||||
fullName: 'ООО "ОПТ-Электроника"',
|
fullName: 'ООО "ОПТ-Электроника"',
|
||||||
address: 'г. Москва, ул. Садовая, д. 15',
|
address: "г. Москва, ул. Садовая, д. 15",
|
||||||
phone: '+7 (495) 123-45-67',
|
phone: "+7 (495) 123-45-67",
|
||||||
email: 'opt@electronics.ru',
|
email: "opt@electronics.ru",
|
||||||
rating: 4.8,
|
rating: 4.8,
|
||||||
productCount: 1250,
|
productCount: 1250,
|
||||||
specialization: ['Электроника', 'Бытовая техника']
|
specialization: ["Электроника", "Бытовая техника"],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: '2',
|
id: "2",
|
||||||
inn: '7707083894',
|
inn: "7707083894",
|
||||||
name: 'ТекстильМастер',
|
name: "ТекстильМастер",
|
||||||
fullName: 'ООО "ТекстильМастер"',
|
fullName: 'ООО "ТекстильМастер"',
|
||||||
address: 'г. Иваново, пр. Ленина, д. 42',
|
address: "г. Иваново, пр. Ленина, д. 42",
|
||||||
phone: '+7 (4932) 55-66-77',
|
phone: "+7 (4932) 55-66-77",
|
||||||
email: 'sales@textilmaster.ru',
|
email: "sales@textilmaster.ru",
|
||||||
rating: 4.6,
|
rating: 4.6,
|
||||||
productCount: 850,
|
productCount: 850,
|
||||||
specialization: ['Текстиль', 'Одежда', 'Домашний текстиль']
|
specialization: ["Текстиль", "Одежда", "Домашний текстиль"],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: '3',
|
id: "3",
|
||||||
inn: '7707083895',
|
inn: "7707083895",
|
||||||
name: 'МетизКомплект',
|
name: "МетизКомплект",
|
||||||
fullName: 'ООО "МетизКомплект"',
|
fullName: 'ООО "МетизКомплект"',
|
||||||
address: 'г. Тула, ул. Металлургов, д. 8',
|
address: "г. Тула, ул. Металлургов, д. 8",
|
||||||
phone: '+7 (4872) 33-44-55',
|
phone: "+7 (4872) 33-44-55",
|
||||||
email: 'info@metiz.ru',
|
email: "info@metiz.ru",
|
||||||
rating: 4.9,
|
rating: 4.9,
|
||||||
productCount: 2100,
|
productCount: 2100,
|
||||||
specialization: ['Крепеж', 'Метизы', 'Инструменты']
|
specialization: ["Крепеж", "Метизы", "Инструменты"],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: '4',
|
id: "4",
|
||||||
inn: '7707083896',
|
inn: "7707083896",
|
||||||
name: 'ПродОпт',
|
name: "ПродОпт",
|
||||||
fullName: 'ООО "ПродОпт"',
|
fullName: 'ООО "ПродОпт"',
|
||||||
address: 'г. Краснодар, ул. Красная, д. 123',
|
address: "г. Краснодар, ул. Красная, д. 123",
|
||||||
phone: '+7 (861) 777-88-99',
|
phone: "+7 (861) 777-88-99",
|
||||||
email: 'order@prodopt.ru',
|
email: "order@prodopt.ru",
|
||||||
rating: 4.7,
|
rating: 4.7,
|
||||||
productCount: 560,
|
productCount: 560,
|
||||||
specialization: ['Продукты питания', 'Напитки']
|
specialization: ["Продукты питания", "Напитки"],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: '5',
|
id: "5",
|
||||||
inn: '7707083897',
|
inn: "7707083897",
|
||||||
name: 'СтройМатериалы+',
|
name: "СтройМатериалы+",
|
||||||
fullName: 'ООО "СтройМатериалы+"',
|
fullName: 'ООО "СтройМатериалы+"',
|
||||||
address: 'г. Воронеж, пр. Революции, д. 67',
|
address: "г. Воронеж, пр. Революции, д. 67",
|
||||||
phone: '+7 (473) 222-33-44',
|
phone: "+7 (473) 222-33-44",
|
||||||
email: 'stroim@materials.ru',
|
email: "stroim@materials.ru",
|
||||||
rating: 4.5,
|
rating: 4.5,
|
||||||
productCount: 1800,
|
productCount: 1800,
|
||||||
specialization: ['Стройматериалы', 'Сантехника']
|
specialization: ["Стройматериалы", "Сантехника"],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: '6',
|
id: "6",
|
||||||
inn: '7707083898',
|
inn: "7707083898",
|
||||||
name: 'КосметикОпт',
|
name: "КосметикОпт",
|
||||||
fullName: 'ООО "КосметикОпт"',
|
fullName: 'ООО "КосметикОпт"',
|
||||||
address: 'г. Санкт-Петербург, Невский пр., д. 45',
|
address: "г. Санкт-Петербург, Невский пр., д. 45",
|
||||||
phone: '+7 (812) 111-22-33',
|
phone: "+7 (812) 111-22-33",
|
||||||
email: 'beauty@cosmeticopt.ru',
|
email: "beauty@cosmeticopt.ru",
|
||||||
rating: 4.4,
|
rating: 4.4,
|
||||||
productCount: 920,
|
productCount: 920,
|
||||||
specialization: ['Косметика', 'Парфюмерия', 'Уход']
|
specialization: ["Косметика", "Парфюмерия", "Уход"],
|
||||||
}
|
},
|
||||||
]
|
];
|
||||||
|
|
||||||
export function WholesalerSelection({ onBack, onClose, onSupplyCreated }: WholesalerSelectionProps) {
|
export function SupplierSelection({
|
||||||
const [selectedWholesaler, setSelectedWholesaler] = useState<Wholesaler | null>(null)
|
onBack,
|
||||||
|
onClose,
|
||||||
|
onSupplyCreated,
|
||||||
|
}: SupplierSelectionProps) {
|
||||||
|
const [selectedSupplier, setSelectedSupplier] = useState<Supplier | null>(
|
||||||
|
null
|
||||||
|
);
|
||||||
|
|
||||||
if (selectedWholesaler) {
|
if (selectedSupplier) {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="flex items-center space-x-3">
|
<div className="flex items-center space-x-3">
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => setSelectedWholesaler(null)}
|
onClick={() => setSelectedSupplier(null)}
|
||||||
className="text-white/60 hover:text-white hover:bg-white/10"
|
className="text-white/60 hover:text-white hover:bg-white/10"
|
||||||
>
|
>
|
||||||
<ArrowLeft className="h-4 w-4 mr-2" />
|
<ArrowLeft className="h-4 w-4 mr-2" />
|
||||||
Назад
|
Назад
|
||||||
</Button>
|
</Button>
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-2xl font-bold text-white mb-1">Товары поставщика</h2>
|
<h2 className="text-2xl font-bold text-white mb-1">
|
||||||
<p className="text-white/60">{selectedWholesaler.name}</p>
|
Товары поставщика
|
||||||
|
</h2>
|
||||||
|
<p className="text-white/60">{selectedSupplier.name}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -138,24 +146,28 @@ export function WholesalerSelection({ onBack, onClose, onSupplyCreated }: Wholes
|
|||||||
<p className="text-white/60">Компонент товаров в разработке...</p>
|
<p className="text-white/60">Компонент товаров в разработке...</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const renderStars = (rating: number) => {
|
const renderStars = (rating: number) => {
|
||||||
return Array.from({ length: 5 }, (_, i) => (
|
return Array.from({ length: 5 }, (_, i) => (
|
||||||
<Star
|
<Star
|
||||||
key={i}
|
key={i}
|
||||||
className={`h-4 w-4 ${i < Math.floor(rating) ? 'text-yellow-400 fill-current' : 'text-gray-400'}`}
|
className={`h-4 w-4 ${
|
||||||
|
i < Math.floor(rating)
|
||||||
|
? "text-yellow-400 fill-current"
|
||||||
|
: "text-gray-400"
|
||||||
|
}`}
|
||||||
/>
|
/>
|
||||||
))
|
));
|
||||||
}
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="flex items-center space-x-3">
|
<div className="flex items-center space-x-3">
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={onBack}
|
onClick={onBack}
|
||||||
className="text-white/60 hover:text-white hover:bg-white/10"
|
className="text-white/60 hover:text-white hover:bg-white/10"
|
||||||
@ -164,12 +176,16 @@ export function WholesalerSelection({ onBack, onClose, onSupplyCreated }: Wholes
|
|||||||
Назад
|
Назад
|
||||||
</Button>
|
</Button>
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-2xl font-bold text-white mb-1">Выбор поставщика</h2>
|
<h2 className="text-2xl font-bold text-white mb-1">
|
||||||
<p className="text-white/60">Выберите поставщика для создания поставки</p>
|
Выбор поставщика
|
||||||
|
</h2>
|
||||||
|
<p className="text-white/60">
|
||||||
|
Выберите поставщика для создания поставки
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
className="text-white/60 hover:text-white hover:bg-white/10"
|
className="text-white/60 hover:text-white hover:bg-white/10"
|
||||||
@ -179,11 +195,11 @@ export function WholesalerSelection({ onBack, onClose, onSupplyCreated }: Wholes
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||||
{mockWholesalers.map((wholesaler) => (
|
{mockSuppliers.map((supplier) => (
|
||||||
<Card
|
<Card
|
||||||
key={wholesaler.id}
|
key={supplier.id}
|
||||||
className="bg-white/10 backdrop-blur border-white/20 p-6 cursor-pointer transition-all hover:bg-white/15 hover:border-white/30 hover:scale-105"
|
className="bg-white/10 backdrop-blur border-white/20 p-6 cursor-pointer transition-all hover:bg-white/15 hover:border-white/30 hover:scale-105"
|
||||||
onClick={() => setSelectedWholesaler(wholesaler)}
|
onClick={() => setSelectedSupplier(supplier)}
|
||||||
>
|
>
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{/* Заголовок карточки */}
|
{/* Заголовок карточки */}
|
||||||
@ -193,14 +209,16 @@ export function WholesalerSelection({ onBack, onClose, onSupplyCreated }: Wholes
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<h3 className="text-white font-semibold text-lg mb-1 truncate">
|
<h3 className="text-white font-semibold text-lg mb-1 truncate">
|
||||||
{wholesaler.name}
|
{supplier.name}
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-white/60 text-xs mb-2 truncate">
|
<p className="text-white/60 text-xs mb-2 truncate">
|
||||||
{wholesaler.fullName}
|
{supplier.fullName}
|
||||||
</p>
|
</p>
|
||||||
<div className="flex items-center space-x-1 mb-2">
|
<div className="flex items-center space-x-1 mb-2">
|
||||||
{renderStars(wholesaler.rating)}
|
{renderStars(supplier.rating)}
|
||||||
<span className="text-white/60 text-sm ml-2">{wholesaler.rating}</span>
|
<span className="text-white/60 text-sm ml-2">
|
||||||
|
{supplier.rating}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -209,26 +227,34 @@ export function WholesalerSelection({ onBack, onClose, onSupplyCreated }: Wholes
|
|||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center space-x-2">
|
||||||
<MapPin className="h-4 w-4 text-gray-400" />
|
<MapPin className="h-4 w-4 text-gray-400" />
|
||||||
<span className="text-white/80 text-sm truncate">{wholesaler.address}</span>
|
<span className="text-white/80 text-sm truncate">
|
||||||
|
{supplier.address}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{wholesaler.phone && (
|
{supplier.phone && (
|
||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center space-x-2">
|
||||||
<Phone className="h-4 w-4 text-gray-400" />
|
<Phone className="h-4 w-4 text-gray-400" />
|
||||||
<span className="text-white/80 text-sm">{wholesaler.phone}</span>
|
<span className="text-white/80 text-sm">
|
||||||
|
{supplier.phone}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{wholesaler.email && (
|
{supplier.email && (
|
||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center space-x-2">
|
||||||
<Mail className="h-4 w-4 text-gray-400" />
|
<Mail className="h-4 w-4 text-gray-400" />
|
||||||
<span className="text-white/80 text-sm truncate">{wholesaler.email}</span>
|
<span className="text-white/80 text-sm truncate">
|
||||||
|
{supplier.email}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center space-x-2">
|
||||||
<Package className="h-4 w-4 text-gray-400" />
|
<Package className="h-4 w-4 text-gray-400" />
|
||||||
<span className="text-white/80 text-sm">{wholesaler.productCount} товаров</span>
|
<span className="text-white/80 text-sm">
|
||||||
|
{supplier.productCount} товаров
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -236,8 +262,8 @@ export function WholesalerSelection({ onBack, onClose, onSupplyCreated }: Wholes
|
|||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<p className="text-white/60 text-xs">Специализация:</p>
|
<p className="text-white/60 text-xs">Специализация:</p>
|
||||||
<div className="flex flex-wrap gap-1">
|
<div className="flex flex-wrap gap-1">
|
||||||
{wholesaler.specialization.map((spec, index) => (
|
{supplier.specialization.map((spec, index) => (
|
||||||
<Badge
|
<Badge
|
||||||
key={index}
|
key={index}
|
||||||
className="bg-purple-500/20 text-purple-300 border-purple-500/30 text-xs"
|
className="bg-purple-500/20 text-purple-300 border-purple-500/30 text-xs"
|
||||||
>
|
>
|
||||||
@ -249,12 +275,12 @@ export function WholesalerSelection({ onBack, onClose, onSupplyCreated }: Wholes
|
|||||||
|
|
||||||
{/* ИНН */}
|
{/* ИНН */}
|
||||||
<div className="pt-2 border-t border-white/10">
|
<div className="pt-2 border-t border-white/10">
|
||||||
<p className="text-white/60 text-xs">ИНН: {wholesaler.inn}</p>
|
<p className="text-white/60 text-xs">ИНН: {supplier.inn}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
}
|
@ -1,44 +1,39 @@
|
|||||||
"use client"
|
"use client";
|
||||||
|
|
||||||
import React from 'react'
|
import React from "react";
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
import { ArrowLeft, ShoppingCart, Users, Check } from "lucide-react";
|
||||||
ArrowLeft,
|
|
||||||
ShoppingCart,
|
|
||||||
Users,
|
|
||||||
Check
|
|
||||||
} from 'lucide-react'
|
|
||||||
|
|
||||||
interface TabsHeaderProps {
|
interface TabsHeaderProps {
|
||||||
activeTab: 'cards' | 'wholesaler'
|
activeTab: "cards" | "wholesaler";
|
||||||
onTabChange: (tab: 'cards' | 'wholesaler') => void
|
onTabChange: (tab: "cards" | "wholesaler") => void;
|
||||||
onBack: () => void
|
onBack: () => void;
|
||||||
cartInfo?: {
|
cartInfo?: {
|
||||||
itemCount: number
|
itemCount: number;
|
||||||
totalAmount: number
|
totalAmount: number;
|
||||||
formatCurrency: (amount: number) => string
|
formatCurrency: (amount: number) => string;
|
||||||
}
|
};
|
||||||
onCartClick?: () => void
|
onCartClick?: () => void;
|
||||||
onCreateSupply?: () => void
|
onCreateSupply?: () => void;
|
||||||
canCreateSupply?: boolean
|
canCreateSupply?: boolean;
|
||||||
isCreatingSupply?: boolean
|
isCreatingSupply?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function TabsHeader({
|
export function TabsHeader({
|
||||||
activeTab,
|
activeTab,
|
||||||
onTabChange,
|
onTabChange,
|
||||||
onBack,
|
onBack,
|
||||||
cartInfo,
|
cartInfo,
|
||||||
onCartClick,
|
onCartClick,
|
||||||
onCreateSupply,
|
onCreateSupply,
|
||||||
canCreateSupply = false,
|
canCreateSupply = false,
|
||||||
isCreatingSupply = false
|
isCreatingSupply = false,
|
||||||
}: TabsHeaderProps) {
|
}: TabsHeaderProps) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-between mb-4">
|
<div className="flex items-center justify-between mb-4">
|
||||||
<div className="flex items-center space-x-4">
|
<div className="flex items-center space-x-4">
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={onBack}
|
onClick={onBack}
|
||||||
className="text-white/60 hover:text-white hover:bg-white/10"
|
className="text-white/60 hover:text-white hover:bg-white/10"
|
||||||
@ -47,58 +42,59 @@ export function TabsHeader({
|
|||||||
Назад
|
Назад
|
||||||
</Button>
|
</Button>
|
||||||
<h1 className="text-2xl font-bold text-white">Создание поставки</h1>
|
<h1 className="text-2xl font-bold text-white">Создание поставки</h1>
|
||||||
|
|
||||||
{/* Кнопка корзины */}
|
{/* Кнопка корзины */}
|
||||||
{cartInfo && cartInfo.itemCount > 0 && onCartClick && (
|
{cartInfo && cartInfo.itemCount > 0 && onCartClick && (
|
||||||
<Button
|
<Button
|
||||||
onClick={onCartClick}
|
onClick={onCartClick}
|
||||||
className="bg-gradient-to-r from-purple-500 to-pink-500 hover:from-purple-600 hover:to-pink-600 text-white"
|
className="bg-gradient-to-r from-purple-500 to-pink-500 hover:from-purple-600 hover:to-pink-600 text-white"
|
||||||
>
|
>
|
||||||
<ShoppingCart className="h-4 w-4 mr-2" />
|
<ShoppingCart className="h-4 w-4 mr-2" />
|
||||||
Корзина ({cartInfo.itemCount})
|
Корзина ({cartInfo.itemCount})
|
||||||
{activeTab === 'wholesaler' && ` • ${cartInfo.formatCurrency(cartInfo.totalAmount)}`}
|
{activeTab === "supplier" &&
|
||||||
|
` • ${cartInfo.formatCurrency(cartInfo.totalAmount)}`}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Кнопка создания поставки для таба карточек */}
|
{/* Кнопка создания поставки для таба карточек */}
|
||||||
{activeTab === 'cards' && onCreateSupply && (
|
{activeTab === "cards" && onCreateSupply && (
|
||||||
<Button
|
<Button
|
||||||
onClick={onCreateSupply}
|
onClick={onCreateSupply}
|
||||||
disabled={!canCreateSupply || isCreatingSupply}
|
disabled={!canCreateSupply || isCreatingSupply}
|
||||||
className="bg-white/20 hover:bg-white/30 text-white border-0"
|
className="bg-white/20 hover:bg-white/30 text-white border-0"
|
||||||
>
|
>
|
||||||
<Check className="h-4 w-4 mr-2" />
|
<Check className="h-4 w-4 mr-2" />
|
||||||
{isCreatingSupply ? 'Создание...' : 'Создать поставку'}
|
{isCreatingSupply ? "Создание..." : "Создать поставку"}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<div className="grid grid-cols-2 bg-white/10 backdrop-blur border border-white/20 rounded-lg p-1">
|
<div className="grid grid-cols-2 bg-white/10 backdrop-blur border border-white/20 rounded-lg p-1">
|
||||||
<button
|
<button
|
||||||
onClick={() => onTabChange('cards')}
|
onClick={() => onTabChange("cards")}
|
||||||
className={`px-4 py-2 text-sm rounded transition-all ${
|
className={`px-4 py-2 text-sm rounded transition-all ${
|
||||||
activeTab === 'cards'
|
activeTab === "cards"
|
||||||
? 'bg-white/20 text-white'
|
? "bg-white/20 text-white"
|
||||||
: 'text-white/60 hover:text-white hover:bg-white/10'
|
: "text-white/60 hover:text-white hover:bg-white/10"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<ShoppingCart className="h-4 w-4 mr-1 inline" />
|
<ShoppingCart className="h-4 w-4 mr-1 inline" />
|
||||||
Карточки
|
Карточки
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => onTabChange('wholesaler')}
|
onClick={() => onTabChange("wholesaler")}
|
||||||
className={`px-4 py-2 text-sm rounded transition-all ${
|
className={`px-4 py-2 text-sm rounded transition-all ${
|
||||||
activeTab === 'wholesaler'
|
activeTab === "wholesaler"
|
||||||
? 'bg-white/20 text-white'
|
? "bg-white/20 text-white"
|
||||||
: 'text-white/60 hover:text-white hover:bg-white/10'
|
: "text-white/60 hover:text-white hover:bg-white/10"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<Users className="h-4 w-4 mr-1 inline" />
|
<Users className="h-4 w-4 mr-1 inline" />
|
||||||
Поставщики
|
Поставщики
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
@ -1,50 +1,50 @@
|
|||||||
export interface WholesalerForCreation {
|
export interface SupplierForCreation {
|
||||||
id: string
|
id: string;
|
||||||
inn: string
|
inn: string;
|
||||||
name: string
|
name: string;
|
||||||
fullName: string
|
fullName: string;
|
||||||
address: string
|
address: string;
|
||||||
phone?: string
|
phone?: string;
|
||||||
email?: string
|
email?: string;
|
||||||
rating: number
|
rating: number;
|
||||||
productCount: number
|
productCount: number;
|
||||||
avatar?: string
|
avatar?: string;
|
||||||
specialization: string[]
|
specialization: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface WholesalerProduct {
|
export interface SupplierProduct {
|
||||||
id: string
|
id: string;
|
||||||
name: string
|
name: string;
|
||||||
article: string
|
article: string;
|
||||||
description: string
|
description: string;
|
||||||
price: number
|
price: number;
|
||||||
quantity: number
|
quantity: number;
|
||||||
category: string
|
category: string;
|
||||||
brand?: string
|
brand?: string;
|
||||||
color?: string
|
color?: string;
|
||||||
size?: string
|
size?: string;
|
||||||
weight?: number
|
weight?: number;
|
||||||
dimensions?: string
|
dimensions?: string;
|
||||||
material?: string
|
material?: string;
|
||||||
images: string[]
|
images: string[];
|
||||||
mainImage?: string
|
mainImage?: string;
|
||||||
discount?: number
|
discount?: number;
|
||||||
isNew?: boolean
|
isNew?: boolean;
|
||||||
isBestseller?: boolean
|
isBestseller?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SelectedProduct extends WholesalerProduct {
|
export interface SelectedProduct extends SupplierProduct {
|
||||||
selectedQuantity: number
|
selectedQuantity: number;
|
||||||
wholesalerId: string
|
supplierId: string;
|
||||||
wholesalerName: string
|
supplierName: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CounterpartyWholesaler {
|
export interface CounterpartySupplier {
|
||||||
id: string
|
id: string;
|
||||||
inn?: string
|
inn?: string;
|
||||||
name?: string
|
name?: string;
|
||||||
fullName?: string
|
fullName?: string;
|
||||||
address?: string
|
address?: string;
|
||||||
phones?: { value: string }[]
|
phones?: { value: string }[];
|
||||||
emails?: { value: string }[]
|
emails?: { value: string }[];
|
||||||
}
|
}
|
||||||
|
@ -1,112 +0,0 @@
|
|||||||
"use client"
|
|
||||||
|
|
||||||
import React from 'react'
|
|
||||||
import { WholesalerCard } from './wholesaler-card'
|
|
||||||
import { Input } from '@/components/ui/input'
|
|
||||||
import { Users, Search } from 'lucide-react'
|
|
||||||
import { WholesalerForCreation, CounterpartyWholesaler } from './types'
|
|
||||||
|
|
||||||
interface WholesalerGridProps {
|
|
||||||
wholesalers: CounterpartyWholesaler[]
|
|
||||||
onWholesalerSelect: (wholesaler: WholesalerForCreation) => void
|
|
||||||
searchQuery: string
|
|
||||||
onSearchChange: (query: string) => void
|
|
||||||
loading?: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
export function WholesalerGrid({
|
|
||||||
wholesalers,
|
|
||||||
onWholesalerSelect,
|
|
||||||
searchQuery,
|
|
||||||
onSearchChange,
|
|
||||||
loading = false
|
|
||||||
}: WholesalerGridProps) {
|
|
||||||
// Фильтруем поставщиков по поисковому запросу
|
|
||||||
const filteredWholesalers = wholesalers.filter((wholesaler) =>
|
|
||||||
wholesaler.name?.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
|
||||||
wholesaler.fullName?.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
|
||||||
wholesaler.inn?.toLowerCase().includes(searchQuery.toLowerCase())
|
|
||||||
)
|
|
||||||
|
|
||||||
const handleWholesalerClick = (wholesaler: CounterpartyWholesaler) => {
|
|
||||||
// Адаптируем данные под существующий интерфейс
|
|
||||||
const adaptedWholesaler: WholesalerForCreation = {
|
|
||||||
id: wholesaler.id,
|
|
||||||
inn: wholesaler.inn || '',
|
|
||||||
name: wholesaler.name || 'Неизвестная организация',
|
|
||||||
fullName: wholesaler.fullName || wholesaler.name || 'Неизвестная организация',
|
|
||||||
address: wholesaler.address || 'Адрес не указан',
|
|
||||||
phone: wholesaler.phones?.[0]?.value,
|
|
||||||
email: wholesaler.emails?.[0]?.value,
|
|
||||||
rating: 4.5, // Временное значение
|
|
||||||
productCount: 0, // Временное значение
|
|
||||||
specialization: ['Оптовая торговля'] // Временное значение
|
|
||||||
}
|
|
||||||
onWholesalerSelect(adaptedWholesaler)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (loading) {
|
|
||||||
return (
|
|
||||||
<div className="flex items-center justify-center p-8">
|
|
||||||
<div className="text-center">
|
|
||||||
<div className="animate-spin rounded-full h-12 w-12 border-4 border-white border-t-transparent mx-auto mb-4"></div>
|
|
||||||
<p className="text-white/60">Загружаем поставщиков...</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
{/* Поиск */}
|
|
||||||
<div className="mb-4">
|
|
||||||
<div className="relative max-w-md">
|
|
||||||
<Search className="absolute left-3 top-3 h-4 w-4 text-white/40" />
|
|
||||||
<Input
|
|
||||||
placeholder="Поиск поставщиков..."
|
|
||||||
value={searchQuery}
|
|
||||||
onChange={(e) => onSearchChange(e.target.value)}
|
|
||||||
className="pl-10 glass-input text-white placeholder:text-white/40 h-10"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{filteredWholesalers.length === 0 ? (
|
|
||||||
<div className="text-center p-8">
|
|
||||||
<Users className="h-12 w-12 text-white/20 mx-auto mb-4" />
|
|
||||||
<p className="text-white/60">
|
|
||||||
{searchQuery ? 'Поставщики не найдены' : 'У вас нет контрагентов-поставщиков'}
|
|
||||||
</p>
|
|
||||||
<p className="text-white/40 text-sm mt-2">
|
|
||||||
{searchQuery ? 'Попробуйте изменить условия поиска' : 'Добавьте поставщиков в разделе "Партнеры"'}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
|
|
||||||
{filteredWholesalers.map((wholesaler) => {
|
|
||||||
const adaptedWholesaler: WholesalerForCreation = {
|
|
||||||
id: wholesaler.id,
|
|
||||||
inn: wholesaler.inn || '',
|
|
||||||
name: wholesaler.name || 'Неизвестная организация',
|
|
||||||
fullName: wholesaler.fullName || wholesaler.name || 'Неизвестная организация',
|
|
||||||
address: wholesaler.address || 'Адрес не указан',
|
|
||||||
phone: wholesaler.phones?.[0]?.value,
|
|
||||||
email: wholesaler.emails?.[0]?.value,
|
|
||||||
rating: 4.5,
|
|
||||||
productCount: 0,
|
|
||||||
specialization: ['Оптовая торговля']
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<WholesalerCard
|
|
||||||
key={wholesaler.id}
|
|
||||||
wholesaler={adaptedWholesaler}
|
|
||||||
onClick={() => handleWholesalerClick(wholesaler)}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
@ -80,6 +80,30 @@ export const GET_MY_SUPPLIES = gql`
|
|||||||
supplier
|
supplier
|
||||||
minStock
|
minStock
|
||||||
currentStock
|
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
|
imageUrl
|
||||||
createdAt
|
createdAt
|
||||||
updatedAt
|
updatedAt
|
||||||
@ -848,7 +872,7 @@ export const GET_EXTERNAL_ADS = gql`
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
`
|
`;
|
||||||
|
|
||||||
// Админ запросы
|
// Админ запросы
|
||||||
export const ADMIN_ME = gql`
|
export const ADMIN_ME = gql`
|
||||||
@ -950,6 +974,7 @@ export const GET_PENDING_SUPPLIES_COUNT = gql`
|
|||||||
supplyOrders
|
supplyOrders
|
||||||
ourSupplyOrders
|
ourSupplyOrders
|
||||||
sellerSupplyOrders
|
sellerSupplyOrders
|
||||||
|
incomingSupplierOrders
|
||||||
incomingRequests
|
incomingRequests
|
||||||
total
|
total
|
||||||
}
|
}
|
||||||
|
@ -692,7 +692,7 @@ export const resolvers = {
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
// Мои расходники (объединенные данные из supply и supplyOrder)
|
// Расходники селлеров (материалы клиентов на складе фулфилмента)
|
||||||
mySupplies: async (_: unknown, __: unknown, context: Context) => {
|
mySupplies: async (_: unknown, __: unknown, context: Context) => {
|
||||||
if (!context.user) {
|
if (!context.user) {
|
||||||
throw new GraphQLError("Требуется авторизация", {
|
throw new GraphQLError("Требуется авторизация", {
|
||||||
@ -709,21 +709,86 @@ export const resolvers = {
|
|||||||
throw new GraphQLError("У пользователя нет организации");
|
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({
|
const existingSupplies = await prisma.supply.findMany({
|
||||||
where: { organizationId: currentUser.organization.id },
|
where: { organizationId: currentUser.organization.id },
|
||||||
include: { organization: true },
|
include: { organization: true },
|
||||||
orderBy: { createdAt: "desc" },
|
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 fulfillmentSupplyOrders = await prisma.supplyOrder.findMany({
|
||||||
const ourSupplyOrders = await prisma.supplyOrder.findMany({
|
|
||||||
where: {
|
where: {
|
||||||
organizationId: currentUser.organization.id, // Создали мы
|
organizationId: currentUser.organization.id, // Создали мы
|
||||||
fulfillmentCenterId: currentUser.organization.id, // Получатель - мы
|
fulfillmentCenterId: currentUser.organization.id, // Получатель - мы
|
||||||
status: {
|
status: {
|
||||||
in: ["PENDING", "CONFIRMED", "IN_TRANSIT"], // Только не доставленные заказы
|
in: ["PENDING", "CONFIRMED", "IN_TRANSIT", "DELIVERED"], // Все статусы
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
include: {
|
include: {
|
||||||
@ -742,16 +807,16 @@ export const resolvers = {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Преобразуем заказы поставок в формат supply для единообразия
|
// Преобразуем заказы поставок в формат supply для единообразия
|
||||||
const suppliesFromOrders = ourSupplyOrders.flatMap((order) =>
|
const fulfillmentSupplies = fulfillmentSupplyOrders.flatMap((order) =>
|
||||||
order.items.map((item) => ({
|
order.items.map((item) => ({
|
||||||
id: `order-${order.id}-${item.id}`,
|
id: `fulfillment-order-${order.id}-${item.id}`,
|
||||||
name: item.product.name,
|
name: item.product.name,
|
||||||
description:
|
description:
|
||||||
item.product.description || `Заказ от ${order.partner.name}`,
|
item.product.description || `Расходники от ${order.partner.name}`,
|
||||||
price: item.price,
|
price: item.price,
|
||||||
quantity: item.quantity,
|
quantity: item.quantity,
|
||||||
unit: "шт",
|
unit: "шт",
|
||||||
category: item.product.category?.name || "Расходники",
|
category: item.product.category?.name || "Расходники фулфилмента",
|
||||||
status:
|
status:
|
||||||
order.status === "PENDING"
|
order.status === "PENDING"
|
||||||
? "in-transit"
|
? "in-transit"
|
||||||
@ -764,6 +829,7 @@ export const resolvers = {
|
|||||||
supplier: order.partner.name || order.partner.fullName || "Не указан",
|
supplier: order.partner.name || order.partner.fullName || "Не указан",
|
||||||
minStock: Math.round(item.quantity * 0.1),
|
minStock: Math.round(item.quantity * 0.1),
|
||||||
currentStock: order.status === "DELIVERED" ? item.quantity : 0,
|
currentStock: order.status === "DELIVERED" ? item.quantity : 0,
|
||||||
|
usedStock: 0, // TODO: Подсчитывать реальное использование
|
||||||
imageUrl: null,
|
imageUrl: null,
|
||||||
createdAt: order.createdAt,
|
createdAt: order.createdAt,
|
||||||
updatedAt: order.updatedAt,
|
updatedAt: order.updatedAt,
|
||||||
@ -773,37 +839,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("🔥🔥🔥 FULFILLMENT SUPPLIES RESOLVER CALLED 🔥🔥🔥");
|
||||||
console.log("📊 mySupplies resolver debug:", {
|
console.log("📊 Расходники фулфилмента:", {
|
||||||
organizationId: currentUser.organization.id,
|
organizationId: currentUser.organization.id,
|
||||||
existingSuppliesCount: existingSupplies.length,
|
organizationType: currentUser.organization.type,
|
||||||
ourSupplyOrdersCount: ourSupplyOrders.length,
|
fulfillmentOrdersCount: fulfillmentSupplyOrders.length,
|
||||||
suppliesFromOrdersCount: suppliesFromOrders.length,
|
fulfillmentSuppliesCount: fulfillmentSupplies.length,
|
||||||
allOrdersCount: allOurOrders.length,
|
fulfillmentOrders: fulfillmentSupplyOrders.map((o) => ({
|
||||||
allOrdersStatuses: allOurOrders.map((o) => ({
|
|
||||||
id: o.id,
|
|
||||||
status: o.status,
|
|
||||||
createdAt: o.createdAt,
|
|
||||||
})),
|
|
||||||
filteredOrdersStatuses: ourSupplyOrders.map((o) => ({
|
|
||||||
id: o.id,
|
id: o.id,
|
||||||
|
supplierName: o.partner.name,
|
||||||
status: o.status,
|
status: o.status,
|
||||||
|
itemsCount: o.items.length,
|
||||||
})),
|
})),
|
||||||
});
|
});
|
||||||
console.log("🔥🔥🔥 END MY_SUPPLIES RESOLVER 🔥🔥🔥");
|
|
||||||
|
|
||||||
// Объединяем существующие расходники и расходники из заказов
|
return fulfillmentSupplies;
|
||||||
return [...existingSupplies, ...suppliesFromOrders];
|
|
||||||
},
|
},
|
||||||
|
|
||||||
// Заказы поставок расходников
|
// Заказы поставок расходников
|
||||||
@ -882,7 +933,7 @@ export const resolvers = {
|
|||||||
|
|
||||||
// Считаем заказы поставок, требующие действий
|
// Считаем заказы поставок, требующие действий
|
||||||
|
|
||||||
// Наши расходники (созданные нами для себя) - требуют действий по статусам
|
// Расходники фулфилмента (созданные нами для себя) - требуют действий по статусам
|
||||||
const ourSupplyOrders = await prisma.supplyOrder.count({
|
const ourSupplyOrders = await prisma.supplyOrder.count({
|
||||||
where: {
|
where: {
|
||||||
organizationId: currentUser.organization.id, // Создали мы
|
organizationId: currentUser.organization.id, // Создали мы
|
||||||
@ -900,8 +951,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
|
// Считаем входящие заявки на партнерство со статусом PENDING
|
||||||
const pendingIncomingRequests = await prisma.counterpartyRequest.count({
|
const pendingIncomingRequests = await prisma.counterpartyRequest.count({
|
||||||
@ -913,8 +973,9 @@ export const resolvers = {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
supplyOrders: pendingSupplyOrders,
|
supplyOrders: pendingSupplyOrders,
|
||||||
ourSupplyOrders: ourSupplyOrders, // Наши расходники
|
ourSupplyOrders: ourSupplyOrders, // Расходники фулфилмента
|
||||||
sellerSupplyOrders: sellerSupplyOrders, // Расходники селлеров
|
sellerSupplyOrders: sellerSupplyOrders, // Расходники селлеров
|
||||||
|
incomingSupplierOrders: incomingSupplierOrders, // 🔔 Входящие заказы для поставщиков
|
||||||
incomingRequests: pendingIncomingRequests,
|
incomingRequests: pendingIncomingRequests,
|
||||||
total: pendingSupplyOrders + pendingIncomingRequests,
|
total: pendingSupplyOrders + pendingIncomingRequests,
|
||||||
};
|
};
|
||||||
@ -997,14 +1058,30 @@ export const resolvers = {
|
|||||||
throw new GraphQLError("Товары доступны только для поставщиков");
|
throw new GraphQLError("Товары доступны только для поставщиков");
|
||||||
}
|
}
|
||||||
|
|
||||||
return await prisma.product.findMany({
|
const products = await prisma.product.findMany({
|
||||||
where: { organizationId: currentUser.organization.id },
|
where: {
|
||||||
|
organizationId: currentUser.organization.id,
|
||||||
|
type: "PRODUCT", // Показываем только товары, исключаем расходники
|
||||||
|
},
|
||||||
include: {
|
include: {
|
||||||
category: true,
|
category: true,
|
||||||
organization: true,
|
organization: true,
|
||||||
},
|
},
|
||||||
orderBy: { createdAt: "desc" },
|
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;
|
||||||
},
|
},
|
||||||
|
|
||||||
// Товары на складе фулфилмента (из доставленных заказов поставок)
|
// Товары на складе фулфилмента (из доставленных заказов поставок)
|
||||||
@ -1086,19 +1163,30 @@ export const resolvers = {
|
|||||||
);
|
);
|
||||||
|
|
||||||
for (const item of order.items) {
|
for (const item of order.items) {
|
||||||
// Добавляем товар на склад с информацией о заказе
|
// Добавляем только товары типа PRODUCT, исключаем расходники
|
||||||
allProducts.push({
|
if (item.product.type === "PRODUCT") {
|
||||||
...item.product,
|
allProducts.push({
|
||||||
// Дополнительная информация о заказе
|
...item.product,
|
||||||
orderedQuantity: item.quantity,
|
// Дополнительная информация о заказе
|
||||||
orderedPrice: item.price,
|
orderedQuantity: item.quantity,
|
||||||
orderId: order.id,
|
orderedPrice: item.price,
|
||||||
orderDate: order.deliveryDate,
|
orderId: order.id,
|
||||||
seller: order.organization, // Селлер, который заказал
|
orderDate: order.deliveryDate,
|
||||||
supplier: order.partner, // Поставщик товара
|
seller: order.organization, // Селлер, который заказал
|
||||||
// Для совместимости с существующим интерфейсом
|
supplier: order.partner, // Поставщик товара
|
||||||
organization: order.organization, // Указываем селлера как владельца
|
// Для совместимости с существующим интерфейсом
|
||||||
});
|
organization: order.organization, // Указываем селлера как владельца
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
console.log(
|
||||||
|
`🚫 Исключен расходник из основного склада фулфилмента:`,
|
||||||
|
{
|
||||||
|
name: item.product.name,
|
||||||
|
type: item.product.type,
|
||||||
|
orderId: order.id,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1123,6 +1211,7 @@ export const resolvers = {
|
|||||||
|
|
||||||
const where: Record<string, unknown> = {
|
const where: Record<string, unknown> = {
|
||||||
isActive: true, // Показываем только активные товары
|
isActive: true, // Показываем только активные товары
|
||||||
|
type: "PRODUCT", // Показываем только товары, исключаем расходники
|
||||||
organization: {
|
organization: {
|
||||||
type: "WHOLESALE", // Только товары поставщиков
|
type: "WHOLESALE", // Только товары поставщиков
|
||||||
},
|
},
|
||||||
@ -1141,7 +1230,7 @@ export const resolvers = {
|
|||||||
where.categoryId = args.category;
|
where.categoryId = args.category;
|
||||||
}
|
}
|
||||||
|
|
||||||
return await prisma.product.findMany({
|
const products = await prisma.product.findMany({
|
||||||
where,
|
where,
|
||||||
include: {
|
include: {
|
||||||
category: true,
|
category: true,
|
||||||
@ -1154,6 +1243,20 @@ export const resolvers = {
|
|||||||
orderBy: { createdAt: "desc" },
|
orderBy: { createdAt: "desc" },
|
||||||
take: 100, // Ограничиваем количество результатов
|
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;
|
||||||
},
|
},
|
||||||
|
|
||||||
// Все категории
|
// Все категории
|
||||||
@ -3376,6 +3479,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. Селлер → Поставщик → Фулфилмент (селлер заказывает для фулфилмент-центра)
|
// 1. Селлер → Поставщик → Фулфилмент (селлер заказывает для фулфилмент-центра)
|
||||||
@ -3614,6 +3805,41 @@ export const resolvers = {
|
|||||||
data: suppliesData,
|
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 = "";
|
let successMessage = "";
|
||||||
if (organizationRole === "SELLER") {
|
if (organizationRole === "SELLER") {
|
||||||
@ -5166,7 +5392,8 @@ export const resolvers = {
|
|||||||
console.error("Error updating external ad clicks:", error);
|
console.error("Error updating external ad clicks:", error);
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
message: error instanceof Error ? error.message : "Ошибка обновления кликов",
|
message:
|
||||||
|
error instanceof Error ? error.message : "Ошибка обновления кликов",
|
||||||
externalAd: null,
|
externalAd: null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@ -6201,21 +6428,21 @@ const externalAdQueries = {
|
|||||||
organizationId: user.organization.id,
|
organizationId: user.organization.id,
|
||||||
date: {
|
date: {
|
||||||
gte: new Date(dateFrom),
|
gte: new Date(dateFrom),
|
||||||
lte: new Date(dateTo + 'T23:59:59.999Z'),
|
lte: new Date(dateTo + "T23:59:59.999Z"),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
orderBy: {
|
orderBy: {
|
||||||
date: 'desc',
|
date: "desc",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
message: null,
|
message: null,
|
||||||
externalAds: externalAds.map(ad => ({
|
externalAds: externalAds.map((ad) => ({
|
||||||
...ad,
|
...ad,
|
||||||
cost: parseFloat(ad.cost.toString()),
|
cost: parseFloat(ad.cost.toString()),
|
||||||
date: ad.date.toISOString().split('T')[0],
|
date: ad.date.toISOString().split("T")[0],
|
||||||
createdAt: ad.createdAt.toISOString(),
|
createdAt: ad.createdAt.toISOString(),
|
||||||
updatedAt: ad.updatedAt.toISOString(),
|
updatedAt: ad.updatedAt.toISOString(),
|
||||||
})),
|
})),
|
||||||
@ -6224,7 +6451,10 @@ const externalAdQueries = {
|
|||||||
console.error("Error fetching external ads:", error);
|
console.error("Error fetching external ads:", error);
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
message: error instanceof Error ? error.message : "Ошибка получения внешней рекламы",
|
message:
|
||||||
|
error instanceof Error
|
||||||
|
? error.message
|
||||||
|
: "Ошибка получения внешней рекламы",
|
||||||
externalAds: [],
|
externalAds: [],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@ -6234,7 +6464,17 @@ const externalAdQueries = {
|
|||||||
const externalAdMutations = {
|
const externalAdMutations = {
|
||||||
createExternalAd: async (
|
createExternalAd: async (
|
||||||
_: unknown,
|
_: 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
|
context: Context
|
||||||
) => {
|
) => {
|
||||||
if (!context.user) {
|
if (!context.user) {
|
||||||
@ -6270,7 +6510,7 @@ const externalAdMutations = {
|
|||||||
externalAd: {
|
externalAd: {
|
||||||
...externalAd,
|
...externalAd,
|
||||||
cost: parseFloat(externalAd.cost.toString()),
|
cost: parseFloat(externalAd.cost.toString()),
|
||||||
date: externalAd.date.toISOString().split('T')[0],
|
date: externalAd.date.toISOString().split("T")[0],
|
||||||
createdAt: externalAd.createdAt.toISOString(),
|
createdAt: externalAd.createdAt.toISOString(),
|
||||||
updatedAt: externalAd.updatedAt.toISOString(),
|
updatedAt: externalAd.updatedAt.toISOString(),
|
||||||
},
|
},
|
||||||
@ -6279,7 +6519,10 @@ const externalAdMutations = {
|
|||||||
console.error("Error creating external ad:", error);
|
console.error("Error creating external ad:", error);
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
message: error instanceof Error ? error.message : "Ошибка создания внешней рекламы",
|
message:
|
||||||
|
error instanceof Error
|
||||||
|
? error.message
|
||||||
|
: "Ошибка создания внешней рекламы",
|
||||||
externalAd: null,
|
externalAd: null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@ -6287,7 +6530,19 @@ const externalAdMutations = {
|
|||||||
|
|
||||||
updateExternalAd: async (
|
updateExternalAd: async (
|
||||||
_: unknown,
|
_: 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
|
context: Context
|
||||||
) => {
|
) => {
|
||||||
if (!context.user) {
|
if (!context.user) {
|
||||||
@ -6335,7 +6590,7 @@ const externalAdMutations = {
|
|||||||
externalAd: {
|
externalAd: {
|
||||||
...externalAd,
|
...externalAd,
|
||||||
cost: parseFloat(externalAd.cost.toString()),
|
cost: parseFloat(externalAd.cost.toString()),
|
||||||
date: externalAd.date.toISOString().split('T')[0],
|
date: externalAd.date.toISOString().split("T")[0],
|
||||||
createdAt: externalAd.createdAt.toISOString(),
|
createdAt: externalAd.createdAt.toISOString(),
|
||||||
updatedAt: externalAd.updatedAt.toISOString(),
|
updatedAt: externalAd.updatedAt.toISOString(),
|
||||||
},
|
},
|
||||||
@ -6344,7 +6599,10 @@ const externalAdMutations = {
|
|||||||
console.error("Error updating external ad:", error);
|
console.error("Error updating external ad:", error);
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
message: error instanceof Error ? error.message : "Ошибка обновления внешней рекламы",
|
message:
|
||||||
|
error instanceof Error
|
||||||
|
? error.message
|
||||||
|
: "Ошибка обновления внешней рекламы",
|
||||||
externalAd: null,
|
externalAd: null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@ -6396,21 +6654,19 @@ const externalAdMutations = {
|
|||||||
console.error("Error deleting external ad:", error);
|
console.error("Error deleting external ad:", error);
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
message: error instanceof Error ? error.message : "Ошибка удаления внешней рекламы",
|
message:
|
||||||
|
error instanceof Error
|
||||||
|
? error.message
|
||||||
|
: "Ошибка удаления внешней рекламы",
|
||||||
externalAd: null,
|
externalAd: null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Резолверы для кеша склада WB
|
// Резолверы для кеша склада WB
|
||||||
const wbWarehouseCacheQueries = {
|
const wbWarehouseCacheQueries = {
|
||||||
getWBWarehouseData: async (
|
getWBWarehouseData: async (_: unknown, __: unknown, context: Context) => {
|
||||||
_: unknown,
|
|
||||||
__: unknown,
|
|
||||||
context: Context
|
|
||||||
) => {
|
|
||||||
if (!context.user) {
|
if (!context.user) {
|
||||||
throw new GraphQLError("Требуется авторизация", {
|
throw new GraphQLError("Требуется авторизация", {
|
||||||
extensions: { code: "UNAUTHENTICATED" },
|
extensions: { code: "UNAUTHENTICATED" },
|
||||||
@ -6438,7 +6694,7 @@ const wbWarehouseCacheQueries = {
|
|||||||
cacheDate: today,
|
cacheDate: today,
|
||||||
},
|
},
|
||||||
orderBy: {
|
orderBy: {
|
||||||
createdAt: 'desc',
|
createdAt: "desc",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -6449,7 +6705,7 @@ const wbWarehouseCacheQueries = {
|
|||||||
message: "Данные получены из кеша",
|
message: "Данные получены из кеша",
|
||||||
cache: {
|
cache: {
|
||||||
...cache,
|
...cache,
|
||||||
cacheDate: cache.cacheDate.toISOString().split('T')[0],
|
cacheDate: cache.cacheDate.toISOString().split("T")[0],
|
||||||
createdAt: cache.createdAt.toISOString(),
|
createdAt: cache.createdAt.toISOString(),
|
||||||
updatedAt: cache.updatedAt.toISOString(),
|
updatedAt: cache.updatedAt.toISOString(),
|
||||||
},
|
},
|
||||||
@ -6468,7 +6724,10 @@ const wbWarehouseCacheQueries = {
|
|||||||
console.error("Error getting WB warehouse cache:", error);
|
console.error("Error getting WB warehouse cache:", error);
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
message: error instanceof Error ? error.message : "Ошибка получения кеша склада WB",
|
message:
|
||||||
|
error instanceof Error
|
||||||
|
? error.message
|
||||||
|
: "Ошибка получения кеша склада WB",
|
||||||
cache: null,
|
cache: null,
|
||||||
fromCache: false,
|
fromCache: false,
|
||||||
};
|
};
|
||||||
@ -6479,7 +6738,16 @@ const wbWarehouseCacheQueries = {
|
|||||||
const wbWarehouseCacheMutations = {
|
const wbWarehouseCacheMutations = {
|
||||||
saveWBWarehouseCache: async (
|
saveWBWarehouseCache: async (
|
||||||
_: unknown,
|
_: unknown,
|
||||||
{ input }: { input: { data: string; totalProducts: number; totalStocks: number; totalReserved: number } },
|
{
|
||||||
|
input,
|
||||||
|
}: {
|
||||||
|
input: {
|
||||||
|
data: string;
|
||||||
|
totalProducts: number;
|
||||||
|
totalStocks: number;
|
||||||
|
totalReserved: number;
|
||||||
|
};
|
||||||
|
},
|
||||||
context: Context
|
context: Context
|
||||||
) => {
|
) => {
|
||||||
if (!context.user) {
|
if (!context.user) {
|
||||||
@ -6531,7 +6799,7 @@ const wbWarehouseCacheMutations = {
|
|||||||
message: "Кеш склада WB успешно сохранен",
|
message: "Кеш склада WB успешно сохранен",
|
||||||
cache: {
|
cache: {
|
||||||
...cache,
|
...cache,
|
||||||
cacheDate: cache.cacheDate.toISOString().split('T')[0],
|
cacheDate: cache.cacheDate.toISOString().split("T")[0],
|
||||||
createdAt: cache.createdAt.toISOString(),
|
createdAt: cache.createdAt.toISOString(),
|
||||||
updatedAt: cache.updatedAt.toISOString(),
|
updatedAt: cache.updatedAt.toISOString(),
|
||||||
},
|
},
|
||||||
@ -6541,7 +6809,10 @@ const wbWarehouseCacheMutations = {
|
|||||||
console.error("Error saving WB warehouse cache:", error);
|
console.error("Error saving WB warehouse cache:", error);
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
message: error instanceof Error ? error.message : "Ошибка сохранения кеша склада WB",
|
message:
|
||||||
|
error instanceof Error
|
||||||
|
? error.message
|
||||||
|
: "Ошибка сохранения кеша склада WB",
|
||||||
cache: null,
|
cache: null,
|
||||||
fromCache: false,
|
fromCache: false,
|
||||||
};
|
};
|
||||||
|
@ -37,9 +37,12 @@ export const typeDefs = gql`
|
|||||||
# Услуги организации
|
# Услуги организации
|
||||||
myServices: [Service!]!
|
myServices: [Service!]!
|
||||||
|
|
||||||
# Расходники организации
|
# Расходники селлеров (материалы клиентов)
|
||||||
mySupplies: [Supply!]!
|
mySupplies: [Supply!]!
|
||||||
|
|
||||||
|
# Расходники фулфилмента (материалы для работы фулфилмента)
|
||||||
|
myFulfillmentSupplies: [Supply!]!
|
||||||
|
|
||||||
# Заказы поставок расходников
|
# Заказы поставок расходников
|
||||||
supplyOrders: [SupplyOrder!]!
|
supplyOrders: [SupplyOrder!]!
|
||||||
|
|
||||||
@ -194,6 +197,9 @@ export const typeDefs = gql`
|
|||||||
updateSupply(id: ID!, input: SupplyInput!): SupplyResponse!
|
updateSupply(id: ID!, input: SupplyInput!): SupplyResponse!
|
||||||
deleteSupply(id: ID!): Boolean!
|
deleteSupply(id: ID!): Boolean!
|
||||||
|
|
||||||
|
# Использование расходников фулфилмента
|
||||||
|
useFulfillmentSupplies(input: UseFulfillmentSuppliesInput!): SupplyResponse!
|
||||||
|
|
||||||
# Заказы поставок расходников
|
# Заказы поставок расходников
|
||||||
createSupplyOrder(input: SupplyOrderInput!): SupplyOrderResponse!
|
createSupplyOrder(input: SupplyOrderInput!): SupplyOrderResponse!
|
||||||
updateSupplyOrderStatus(
|
updateSupplyOrderStatus(
|
||||||
@ -524,6 +530,7 @@ export const typeDefs = gql`
|
|||||||
supplier: String
|
supplier: String
|
||||||
minStock: Int
|
minStock: Int
|
||||||
currentStock: Int
|
currentStock: Int
|
||||||
|
usedStock: Int
|
||||||
imageUrl: String
|
imageUrl: String
|
||||||
createdAt: DateTime!
|
createdAt: DateTime!
|
||||||
updatedAt: DateTime!
|
updatedAt: DateTime!
|
||||||
@ -545,6 +552,12 @@ export const typeDefs = gql`
|
|||||||
imageUrl: String
|
imageUrl: String
|
||||||
}
|
}
|
||||||
|
|
||||||
|
input UseFulfillmentSuppliesInput {
|
||||||
|
supplyId: ID!
|
||||||
|
quantityUsed: Int!
|
||||||
|
description: String # Описание использования (например, "Подготовка 300 продуктов")
|
||||||
|
}
|
||||||
|
|
||||||
type SupplyResponse {
|
type SupplyResponse {
|
||||||
success: Boolean!
|
success: Boolean!
|
||||||
message: String!
|
message: String!
|
||||||
@ -602,8 +615,9 @@ export const typeDefs = gql`
|
|||||||
|
|
||||||
type PendingSuppliesCount {
|
type PendingSuppliesCount {
|
||||||
supplyOrders: Int!
|
supplyOrders: Int!
|
||||||
ourSupplyOrders: Int! # Наши расходники
|
ourSupplyOrders: Int! # Расходники фулфилмента
|
||||||
sellerSupplyOrders: Int! # Расходники селлеров
|
sellerSupplyOrders: Int! # Расходники селлеров
|
||||||
|
incomingSupplierOrders: Int! # 🔔 Входящие заказы для поставщиков
|
||||||
incomingRequests: Int!
|
incomingRequests: Int!
|
||||||
total: Int!
|
total: Int!
|
||||||
}
|
}
|
||||||
@ -1241,6 +1255,8 @@ export const typeDefs = gql`
|
|||||||
}
|
}
|
||||||
|
|
||||||
extend type Mutation {
|
extend type Mutation {
|
||||||
saveWBWarehouseCache(input: WBWarehouseCacheInput!): WBWarehouseCacheResponse!
|
saveWBWarehouseCache(
|
||||||
|
input: WBWarehouseCacheInput!
|
||||||
|
): WBWarehouseCacheResponse!
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
153
логика поставки расходников фулфилмента.md
Normal file
153
логика поставки расходников фулфилмента.md
Normal file
@ -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 основного файла логики системы_
|
||||||
|
_Статус: СПЕЦИАЛИЗИРОВАННАЯ ЛОГИКА ПРОЦЕССА_
|
788
логика.md
Normal file
788
логика.md
Normal file
@ -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_
|
||||||
|
_Статус: ЗАЩИЩЕН ОТ ИЗМЕНЕНИЙ БЕЗ РАЗРЕШЕНИЯ_
|
Reference in New Issue
Block a user