Обновлены модели и компоненты для управления поставками и расходниками. Добавлены новые поля в модели SupplyOrder и соответствующие резолверы для поддержки логистики. Реализованы компоненты уведомлений для отображения статуса логистических заявок и поставок. Оптимизирован интерфейс для улучшения пользовательского опыта, добавлены логи для диагностики запросов. Обновлены GraphQL схемы и мутации для поддержки новых функциональных возможностей.
This commit is contained in:
@ -29,14 +29,14 @@ model User {
|
||||
}
|
||||
|
||||
model Admin {
|
||||
id String @id @default(cuid())
|
||||
username String @unique
|
||||
password String // Хеш пароля
|
||||
email String? @unique
|
||||
isActive Boolean @default(true)
|
||||
id String @id @default(cuid())
|
||||
username String @unique
|
||||
password String // Хеш пароля
|
||||
email String? @unique
|
||||
isActive Boolean @default(true)
|
||||
lastLogin DateTime?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@map("admins")
|
||||
}
|
||||
@ -104,14 +104,15 @@ model Organization {
|
||||
users User[]
|
||||
logistics Logistics[]
|
||||
supplyOrders SupplyOrder[]
|
||||
partnerSupplyOrders SupplyOrder[] @relation("SupplyOrderPartner")
|
||||
fulfillmentSupplyOrders SupplyOrder[] @relation("SupplyOrderFulfillmentCenter")
|
||||
logisticsSupplyOrders SupplyOrder[] @relation("SupplyOrderLogistics")
|
||||
partnerSupplyOrders SupplyOrder[] @relation("SupplyOrderPartner")
|
||||
fulfillmentSupplyOrders SupplyOrder[] @relation("SupplyOrderFulfillmentCenter")
|
||||
logisticsSupplyOrders SupplyOrder[] @relation("SupplyOrderLogistics")
|
||||
wildberriesSupplies WildberriesSupply[]
|
||||
supplySuppliers SupplySupplier[] @relation("SupplySuppliers")
|
||||
externalAds ExternalAd[] @relation("ExternalAds")
|
||||
wbWarehouseCaches WBWarehouseCache[] @relation("WBWarehouseCaches")
|
||||
sellerStatsCaches SellerStatsCache[] @relation("SellerStatsCaches")
|
||||
supplySuppliers SupplySupplier[] @relation("SupplySuppliers")
|
||||
externalAds ExternalAd[] @relation("ExternalAds")
|
||||
wbWarehouseCaches WBWarehouseCache[] @relation("WBWarehouseCaches")
|
||||
sellerStatsCaches SellerStatsCache[] @relation("SellerStatsCaches")
|
||||
sellerSupplies Supply[] @relation("SellerSupplies")
|
||||
|
||||
@@map("organizations")
|
||||
}
|
||||
@ -198,24 +199,28 @@ model Service {
|
||||
}
|
||||
|
||||
model Supply {
|
||||
id String @id @default(cuid())
|
||||
id String @id @default(cuid())
|
||||
name String
|
||||
description String?
|
||||
price Decimal @db.Decimal(10, 2)
|
||||
quantity Int @default(0)
|
||||
unit String @default("шт")
|
||||
category String @default("Расходники")
|
||||
status String @default("planned") // planned, in-transit, delivered, in-stock
|
||||
date DateTime @default(now())
|
||||
supplier String @default("Не указан")
|
||||
minStock Int @default(0)
|
||||
currentStock Int @default(0)
|
||||
usedStock Int @default(0) // Количество использованных расходников
|
||||
price Decimal @db.Decimal(10, 2)
|
||||
quantity Int @default(0)
|
||||
unit String @default("шт")
|
||||
category String @default("Расходники")
|
||||
status String @default("planned") // planned, in-transit, delivered, in-stock
|
||||
date DateTime @default(now())
|
||||
supplier String @default("Не указан")
|
||||
minStock Int @default(0)
|
||||
currentStock Int @default(0)
|
||||
usedStock Int @default(0) // Количество использованных расходников
|
||||
imageUrl String?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
type SupplyType @default(FULFILLMENT_CONSUMABLES) // Тип расходников
|
||||
sellerOwnerId String? // ID селлера-владельца (для расходников селлеров)
|
||||
sellerOwner Organization? @relation("SellerSupplies", fields: [sellerOwnerId], references: [id], onDelete: SetNull)
|
||||
shopLocation String? // Местоположение в магазине фулфилмента
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
organizationId String
|
||||
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
|
||||
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@map("supplies")
|
||||
}
|
||||
@ -231,37 +236,37 @@ model Category {
|
||||
}
|
||||
|
||||
model Product {
|
||||
id String @id @default(cuid())
|
||||
name String
|
||||
article String
|
||||
description String?
|
||||
price Decimal @db.Decimal(12, 2)
|
||||
pricePerSet Decimal? @db.Decimal(12, 2)
|
||||
quantity Int @default(0)
|
||||
setQuantity Int?
|
||||
ordered Int?
|
||||
inTransit Int?
|
||||
stock Int?
|
||||
sold Int?
|
||||
type ProductType @default(PRODUCT)
|
||||
categoryId String?
|
||||
brand String?
|
||||
color String?
|
||||
size String?
|
||||
weight Decimal? @db.Decimal(8, 3)
|
||||
dimensions String?
|
||||
material String?
|
||||
images Json @default("[]")
|
||||
mainImage String?
|
||||
isActive Boolean @default(true)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
organizationId String
|
||||
cartItems CartItem[]
|
||||
favorites Favorites[]
|
||||
id String @id @default(cuid())
|
||||
name String
|
||||
article String
|
||||
description String?
|
||||
price Decimal @db.Decimal(12, 2)
|
||||
pricePerSet Decimal? @db.Decimal(12, 2)
|
||||
quantity Int @default(0)
|
||||
setQuantity Int?
|
||||
ordered Int?
|
||||
inTransit Int?
|
||||
stock Int?
|
||||
sold Int?
|
||||
type ProductType @default(PRODUCT)
|
||||
categoryId String?
|
||||
brand String?
|
||||
color String?
|
||||
size String?
|
||||
weight Decimal? @db.Decimal(8, 3)
|
||||
dimensions String?
|
||||
material String?
|
||||
images Json @default("[]")
|
||||
mainImage String?
|
||||
isActive Boolean @default(true)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
organizationId String
|
||||
cartItems CartItem[]
|
||||
favorites Favorites[]
|
||||
supplyOrderItems SupplyOrderItem[]
|
||||
category Category? @relation(fields: [categoryId], references: [id])
|
||||
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
|
||||
category Category? @relation(fields: [categoryId], references: [id])
|
||||
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([organizationId, article])
|
||||
@@map("products")
|
||||
@ -355,41 +360,41 @@ model EmployeeSchedule {
|
||||
}
|
||||
|
||||
model WildberriesSupply {
|
||||
id String @id @default(cuid())
|
||||
organizationId String
|
||||
deliveryDate DateTime?
|
||||
status WildberriesSupplyStatus @default(DRAFT)
|
||||
totalAmount Decimal @db.Decimal(12, 2)
|
||||
totalItems Int
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
|
||||
cards WildberriesSupplyCard[]
|
||||
id String @id @default(cuid())
|
||||
organizationId String
|
||||
deliveryDate DateTime?
|
||||
status WildberriesSupplyStatus @default(DRAFT)
|
||||
totalAmount Decimal @db.Decimal(12, 2)
|
||||
totalItems Int
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
|
||||
cards WildberriesSupplyCard[]
|
||||
|
||||
@@map("wildberries_supplies")
|
||||
}
|
||||
|
||||
model WildberriesSupplyCard {
|
||||
id String @id @default(cuid())
|
||||
supplyId String
|
||||
nmId String
|
||||
vendorCode String
|
||||
title String
|
||||
brand String?
|
||||
price Decimal @db.Decimal(12, 2)
|
||||
discountedPrice Decimal? @db.Decimal(12, 2)
|
||||
quantity Int
|
||||
selectedQuantity Int
|
||||
selectedMarket String?
|
||||
selectedPlace String?
|
||||
sellerName String?
|
||||
sellerPhone String?
|
||||
deliveryDate DateTime?
|
||||
mediaFiles Json @default("[]")
|
||||
selectedServices Json @default("[]")
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
supply WildberriesSupply @relation(fields: [supplyId], references: [id], onDelete: Cascade)
|
||||
id String @id @default(cuid())
|
||||
supplyId String
|
||||
nmId String
|
||||
vendorCode String
|
||||
title String
|
||||
brand String?
|
||||
price Decimal @db.Decimal(12, 2)
|
||||
discountedPrice Decimal? @db.Decimal(12, 2)
|
||||
quantity Int
|
||||
selectedQuantity Int
|
||||
selectedMarket String?
|
||||
selectedPlace String?
|
||||
sellerName String?
|
||||
sellerPhone String?
|
||||
deliveryDate DateTime?
|
||||
mediaFiles Json @default("[]")
|
||||
selectedServices Json @default("[]")
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
supply WildberriesSupply @relation(fields: [supplyId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@map("wildberries_supply_cards")
|
||||
}
|
||||
@ -459,6 +464,11 @@ enum ProductType {
|
||||
CONSUMABLE
|
||||
}
|
||||
|
||||
enum SupplyType {
|
||||
FULFILLMENT_CONSUMABLES // Расходники фулфилмента (купленные фулфилментом для себя)
|
||||
SELLER_CONSUMABLES // Расходники селлеров (принятые от селлеров для хранения)
|
||||
}
|
||||
|
||||
model Logistics {
|
||||
id String @id @default(cuid())
|
||||
fromLocation String
|
||||
@ -475,23 +485,23 @@ model Logistics {
|
||||
}
|
||||
|
||||
model SupplyOrder {
|
||||
id String @id @default(cuid())
|
||||
partnerId String
|
||||
deliveryDate DateTime
|
||||
status SupplyOrderStatus @default(PENDING)
|
||||
totalAmount Decimal @db.Decimal(12, 2)
|
||||
totalItems Int
|
||||
fulfillmentCenterId String?
|
||||
logisticsPartnerId String
|
||||
consumableType String? // Классификация расходников: FULFILLMENT_CONSUMABLES, SELLER_CONSUMABLES
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
organizationId String
|
||||
items SupplyOrderItem[]
|
||||
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
|
||||
partner Organization @relation("SupplyOrderPartner", fields: [partnerId], references: [id])
|
||||
fulfillmentCenter Organization? @relation("SupplyOrderFulfillmentCenter", fields: [fulfillmentCenterId], references: [id])
|
||||
logisticsPartner Organization @relation("SupplyOrderLogistics", fields: [logisticsPartnerId], references: [id])
|
||||
id String @id @default(cuid())
|
||||
partnerId String
|
||||
deliveryDate DateTime
|
||||
status SupplyOrderStatus @default(PENDING)
|
||||
totalAmount Decimal @db.Decimal(12, 2)
|
||||
totalItems Int
|
||||
fulfillmentCenterId String?
|
||||
logisticsPartnerId String? // Опциональная логистика - может назначить фулфилмент
|
||||
consumableType String? // Классификация расходников: FULFILLMENT_CONSUMABLES, SELLER_CONSUMABLES
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
organizationId String
|
||||
items SupplyOrderItem[]
|
||||
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
|
||||
partner Organization @relation("SupplyOrderPartner", fields: [partnerId], references: [id])
|
||||
fulfillmentCenter Organization? @relation("SupplyOrderFulfillmentCenter", fields: [fulfillmentCenterId], references: [id])
|
||||
logisticsPartner Organization? @relation("SupplyOrderLogistics", fields: [logisticsPartnerId], references: [id])
|
||||
|
||||
@@map("supply_orders")
|
||||
}
|
||||
@ -531,13 +541,13 @@ model SupplySupplier {
|
||||
|
||||
model ExternalAd {
|
||||
id String @id @default(cuid())
|
||||
name String // Название рекламы
|
||||
url String // URL рекламы
|
||||
name String // Название рекламы
|
||||
url String // URL рекламы
|
||||
cost Decimal @db.Decimal(12, 2) // Стоимость
|
||||
date DateTime // Дата рекламы
|
||||
nmId String // ID товара Wildberries
|
||||
date DateTime // Дата рекламы
|
||||
nmId String // ID товара Wildberries
|
||||
clicks Int @default(0) // Количество кликов
|
||||
organizationId String // ID организации
|
||||
organizationId String // ID организации
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
organization Organization @relation("ExternalAds", fields: [organizationId], references: [id], onDelete: Cascade)
|
||||
@ -548,9 +558,9 @@ model ExternalAd {
|
||||
|
||||
model WBWarehouseCache {
|
||||
id String @id @default(cuid())
|
||||
organizationId String // ID организации
|
||||
cacheDate DateTime // Дата кеширования (только дата, без времени)
|
||||
data Json // Кешированные данные склада WB
|
||||
organizationId String // ID организации
|
||||
cacheDate DateTime // Дата кеширования (только дата, без времени)
|
||||
data Json // Кешированные данные склада WB
|
||||
totalProducts Int @default(0) // Общее количество товаров
|
||||
totalStocks Int @default(0) // Общее количество остатков
|
||||
totalReserved Int @default(0) // Общее количество в резерве
|
||||
@ -564,30 +574,30 @@ model WBWarehouseCache {
|
||||
}
|
||||
|
||||
model SellerStatsCache {
|
||||
id String @id @default(cuid())
|
||||
organizationId String // ID организации
|
||||
cacheDate DateTime // Дата кеширования (только дата, без времени)
|
||||
period String // Период статистики (week, month, quarter, custom)
|
||||
dateFrom DateTime? // Дата начала периода (для custom)
|
||||
dateTo DateTime? // Дата окончания периода (для custom)
|
||||
id String @id @default(cuid())
|
||||
organizationId String // ID организации
|
||||
cacheDate DateTime // Дата кеширования (только дата, без времени)
|
||||
period String // Период статистики (week, month, quarter, custom)
|
||||
dateFrom DateTime? // Дата начала периода (для custom)
|
||||
dateTo DateTime? // Дата окончания периода (для custom)
|
||||
|
||||
// Данные товаров
|
||||
productsData Json? // Кешированные данные товаров
|
||||
productsTotalSales Decimal? @db.Decimal(15, 2) // Общая сумма продаж товаров
|
||||
productsTotalOrders Int? // Общее количество заказов товаров
|
||||
productsCount Int? // Количество товаров
|
||||
productsData Json? // Кешированные данные товаров
|
||||
productsTotalSales Decimal? @db.Decimal(15, 2) // Общая сумма продаж товаров
|
||||
productsTotalOrders Int? // Общее количество заказов товаров
|
||||
productsCount Int? // Количество товаров
|
||||
|
||||
// Данные рекламы
|
||||
advertisingData Json? // Кешированные данные рекламы
|
||||
advertisingTotalCost Decimal? @db.Decimal(15, 2) // Общие расходы на рекламу
|
||||
advertisingTotalViews Int? // Общие показы рекламы
|
||||
advertisingTotalClicks Int? // Общие клики рекламы
|
||||
advertisingData Json? // Кешированные данные рекламы
|
||||
advertisingTotalCost Decimal? @db.Decimal(15, 2) // Общие расходы на рекламу
|
||||
advertisingTotalViews Int? // Общие показы рекламы
|
||||
advertisingTotalClicks Int? // Общие клики рекламы
|
||||
|
||||
// Метаданные
|
||||
expiresAt DateTime // Время истечения кеша (24 часа)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
organization Organization @relation("SellerStatsCaches", fields: [organizationId], references: [id], onDelete: Cascade)
|
||||
expiresAt DateTime // Время истечения кеша (24 часа)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
organization Organization @relation("SellerStatsCaches", fields: [organizationId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([organizationId, cacheDate, period, dateFrom, dateTo])
|
||||
@@index([organizationId, cacheDate])
|
||||
|
@ -1,84 +1,82 @@
|
||||
import { ApolloServer } from '@apollo/server'
|
||||
import { startServerAndCreateNextHandler } from '@as-integrations/next'
|
||||
import { NextRequest } from 'next/server'
|
||||
import jwt from 'jsonwebtoken'
|
||||
import { typeDefs } from '@/graphql/typedefs'
|
||||
import { resolvers } from '@/graphql/resolvers'
|
||||
|
||||
// Интерфейс для контекста
|
||||
interface Context {
|
||||
user?: {
|
||||
id: string
|
||||
phone: string
|
||||
}
|
||||
admin?: {
|
||||
id: string
|
||||
username: string
|
||||
}
|
||||
}
|
||||
import { ApolloServer } from "@apollo/server";
|
||||
import { startServerAndCreateNextHandler } from "@as-integrations/next";
|
||||
import { NextRequest } from "next/server";
|
||||
import jwt from "jsonwebtoken";
|
||||
import { typeDefs } from "@/graphql/typedefs";
|
||||
import { resolvers } from "@/graphql/resolvers";
|
||||
import { Context } from "@/graphql/context";
|
||||
|
||||
// Создаем Apollo Server
|
||||
const server = new ApolloServer<Context>({
|
||||
typeDefs,
|
||||
resolvers,
|
||||
})
|
||||
});
|
||||
|
||||
// Создаем Next.js handler
|
||||
const handler = startServerAndCreateNextHandler<NextRequest>(server, {
|
||||
context: async (req: NextRequest) => {
|
||||
// Извлекаем токен из заголовка Authorization
|
||||
const authHeader = req.headers.get('authorization')
|
||||
const token = authHeader?.replace('Bearer ', '')
|
||||
const authHeader = req.headers.get("authorization");
|
||||
const token = authHeader?.replace("Bearer ", "");
|
||||
|
||||
console.log('GraphQL Context - Auth header:', authHeader)
|
||||
console.log('GraphQL Context - Token:', token ? `${token.substring(0, 20)}...` : 'No token')
|
||||
console.log("GraphQL Context - Auth header:", authHeader);
|
||||
console.log(
|
||||
"GraphQL Context - Token:",
|
||||
token ? `${token.substring(0, 20)}...` : "No token"
|
||||
);
|
||||
|
||||
if (!token) {
|
||||
console.log('GraphQL Context - No token provided')
|
||||
return { user: undefined, admin: undefined }
|
||||
console.log("GraphQL Context - No token provided");
|
||||
return { user: undefined, admin: undefined };
|
||||
}
|
||||
|
||||
try {
|
||||
// Верифицируем JWT токен
|
||||
const decoded = jwt.verify(token, process.env.JWT_SECRET!) as {
|
||||
userId?: string
|
||||
phone?: string
|
||||
adminId?: string
|
||||
username?: string
|
||||
type?: string
|
||||
}
|
||||
userId?: string;
|
||||
phone?: string;
|
||||
adminId?: string;
|
||||
username?: string;
|
||||
type?: string;
|
||||
};
|
||||
|
||||
// Проверяем тип токена
|
||||
if (decoded.type === 'admin' && decoded.adminId && decoded.username) {
|
||||
console.log('GraphQL Context - Decoded admin:', { id: decoded.adminId, username: decoded.username })
|
||||
if (decoded.type === "admin" && decoded.adminId && decoded.username) {
|
||||
console.log("GraphQL Context - Decoded admin:", {
|
||||
id: decoded.adminId,
|
||||
username: decoded.username,
|
||||
});
|
||||
return {
|
||||
admin: {
|
||||
id: decoded.adminId,
|
||||
username: decoded.username
|
||||
}
|
||||
}
|
||||
username: decoded.username,
|
||||
},
|
||||
};
|
||||
} else if (decoded.userId && decoded.phone) {
|
||||
console.log('GraphQL Context - Decoded user:', { id: decoded.userId, phone: decoded.phone })
|
||||
console.log("GraphQL Context - Decoded user:", {
|
||||
id: decoded.userId,
|
||||
phone: decoded.phone,
|
||||
});
|
||||
return {
|
||||
user: {
|
||||
id: decoded.userId,
|
||||
phone: decoded.phone
|
||||
}
|
||||
}
|
||||
phone: decoded.phone,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return { user: undefined, admin: undefined }
|
||||
return { user: undefined, admin: undefined };
|
||||
} catch (error) {
|
||||
console.error('GraphQL Context - Invalid token:', error)
|
||||
return { user: undefined, admin: undefined }
|
||||
console.error("GraphQL Context - Invalid token:", error);
|
||||
return { user: undefined, admin: undefined };
|
||||
}
|
||||
}
|
||||
})
|
||||
},
|
||||
});
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
return handler(request)
|
||||
return handler(request);
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
return handler(request)
|
||||
return handler(request);
|
||||
}
|
@ -48,6 +48,65 @@ function PendingSuppliesNotification() {
|
||||
);
|
||||
}
|
||||
|
||||
// Компонент для отображения логистических заявок (только для логистики)
|
||||
function LogisticsOrdersNotification() {
|
||||
const { data: pendingData } = useQuery(GET_PENDING_SUPPLIES_COUNT, {
|
||||
pollInterval: 30000, // Обновляем каждые 30 секунд
|
||||
fetchPolicy: "cache-first",
|
||||
errorPolicy: "ignore",
|
||||
});
|
||||
|
||||
const logisticsCount =
|
||||
pendingData?.pendingSuppliesCount?.logisticsOrders || 0;
|
||||
|
||||
if (logisticsCount === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="absolute -top-1 -right-1 bg-red-500 text-white text-xs rounded-full min-w-[18px] h-[18px] flex items-center justify-center font-bold animate-pulse">
|
||||
{logisticsCount > 99 ? "99+" : logisticsCount}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Компонент для отображения поставок фулфилмента (только поставки, не заявки на партнерство)
|
||||
function FulfillmentSuppliesNotification() {
|
||||
const { data: pendingData } = useQuery(GET_PENDING_SUPPLIES_COUNT, {
|
||||
pollInterval: 30000, // Обновляем каждые 30 секунд
|
||||
fetchPolicy: "cache-first",
|
||||
errorPolicy: "ignore",
|
||||
});
|
||||
|
||||
const suppliesCount = pendingData?.pendingSuppliesCount?.supplyOrders || 0;
|
||||
|
||||
if (suppliesCount === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="absolute -top-1 -right-1 bg-red-500 text-white text-xs rounded-full min-w-[18px] h-[18px] flex items-center justify-center font-bold animate-pulse">
|
||||
{suppliesCount > 99 ? "99+" : suppliesCount}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Компонент для отображения входящих заказов поставщика (только входящие заказы, не заявки на партнерство)
|
||||
function WholesaleOrdersNotification() {
|
||||
const { data: pendingData } = useQuery(GET_PENDING_SUPPLIES_COUNT, {
|
||||
pollInterval: 30000, // Обновляем каждые 30 секунд
|
||||
fetchPolicy: "cache-first",
|
||||
errorPolicy: "ignore",
|
||||
});
|
||||
|
||||
const ordersCount =
|
||||
pendingData?.pendingSuppliesCount?.incomingSupplierOrders || 0;
|
||||
|
||||
if (ordersCount === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="absolute -top-1 -right-1 bg-red-500 text-white text-xs rounded-full min-w-[18px] h-[18px] flex items-center justify-center font-bold animate-pulse">
|
||||
{ordersCount > 99 ? "99+" : ordersCount}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function Sidebar() {
|
||||
const { user, logout } = useAuth();
|
||||
const router = useRouter();
|
||||
@ -149,7 +208,7 @@ export function Sidebar() {
|
||||
router.push("/supplies");
|
||||
break;
|
||||
case "WHOLESALE":
|
||||
router.push("/supplies");
|
||||
router.push("/supplier-orders");
|
||||
break;
|
||||
case "LOGIST":
|
||||
router.push("/logistics-orders");
|
||||
@ -202,7 +261,8 @@ export function Sidebar() {
|
||||
const isSuppliesActive =
|
||||
pathname.startsWith("/supplies") ||
|
||||
pathname.startsWith("/fulfillment-supplies") ||
|
||||
pathname.startsWith("/logistics");
|
||||
pathname.startsWith("/logistics") ||
|
||||
pathname.startsWith("/supplier-orders");
|
||||
const isPartnersActive = pathname.startsWith("/partners");
|
||||
|
||||
return (
|
||||
@ -475,8 +535,7 @@ export function Sidebar() {
|
||||
>
|
||||
<Truck className="h-4 w-4 flex-shrink-0" />
|
||||
{!isCollapsed && <span className="ml-3">Мои поставки</span>}
|
||||
{/* Уведомление о непринятых поставках */}
|
||||
<PendingSuppliesNotification />
|
||||
{/* Селлеры не получают уведомления о поставках - только отслеживают статус */}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
@ -536,8 +595,8 @@ export function Sidebar() {
|
||||
{!isCollapsed && (
|
||||
<span className="ml-3">Входящие поставки</span>
|
||||
)}
|
||||
{/* Уведомление о непринятых поставках */}
|
||||
<PendingSuppliesNotification />
|
||||
{/* Уведомление только о поставках, не о заявках на партнерство */}
|
||||
<FulfillmentSuppliesNotification />
|
||||
</Button>
|
||||
)}
|
||||
|
||||
@ -595,8 +654,8 @@ export function Sidebar() {
|
||||
>
|
||||
<Truck className="h-4 w-4 flex-shrink-0" />
|
||||
{!isCollapsed && <span className="ml-3">Заявки</span>}
|
||||
{/* Уведомление о непринятых поставках */}
|
||||
<PendingSuppliesNotification />
|
||||
{/* Уведомление только о входящих заказах поставок, не о заявках на партнерство */}
|
||||
<WholesaleOrdersNotification />
|
||||
</Button>
|
||||
)}
|
||||
|
||||
@ -616,8 +675,8 @@ export function Sidebar() {
|
||||
>
|
||||
<Truck className="h-4 w-4 flex-shrink-0" />
|
||||
{!isCollapsed && <span className="ml-3">Перевозки</span>}
|
||||
{/* Уведомление о непринятых поставках */}
|
||||
<PendingSuppliesNotification />
|
||||
{/* Уведомление только о логистических заявках */}
|
||||
<LogisticsOrdersNotification />
|
||||
</Button>
|
||||
)}
|
||||
|
||||
|
@ -1,86 +1,104 @@
|
||||
"use client"
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useMemo } from 'react'
|
||||
import { useQuery, useMutation } from '@apollo/client'
|
||||
import { apolloClient } from '@/lib/apollo-client'
|
||||
import { Sidebar } from '@/components/dashboard/sidebar'
|
||||
import { Card } from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||
import { useState, useEffect, useMemo } from "react";
|
||||
import { useQuery, useMutation } from "@apollo/client";
|
||||
import { apolloClient } from "@/lib/apollo-client";
|
||||
import { Sidebar } from "@/components/dashboard/sidebar";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
|
||||
import { EmployeeInlineForm } from './employee-inline-form'
|
||||
import { EmployeeCompactForm } from './employee-compact-form'
|
||||
import { EmployeeEditInlineForm } from './employee-edit-inline-form'
|
||||
import { EmployeeInlineForm } from "./employee-inline-form";
|
||||
import { EmployeeCompactForm } from "./employee-compact-form";
|
||||
import { EmployeeEditInlineForm } from "./employee-edit-inline-form";
|
||||
|
||||
import { EmployeeSearch } from './employee-search'
|
||||
import { EmployeeLegend } from './employee-legend'
|
||||
import { EmployeeEmptyState } from './employee-empty-state'
|
||||
import { EmployeeRow } from './employee-row'
|
||||
import { EmployeeReports } from './employee-reports'
|
||||
import { toast } from 'sonner'
|
||||
import { GET_MY_EMPLOYEES, GET_EMPLOYEE_SCHEDULE } from '@/graphql/queries'
|
||||
import { CREATE_EMPLOYEE, UPDATE_EMPLOYEE, DELETE_EMPLOYEE, UPDATE_EMPLOYEE_SCHEDULE } from '@/graphql/mutations'
|
||||
import { Users, FileText, Plus, Layout, LayoutGrid } from 'lucide-react'
|
||||
import { EmployeeSearch } from "./employee-search";
|
||||
import { EmployeeLegend } from "./employee-legend";
|
||||
import { EmployeeEmptyState } from "./employee-empty-state";
|
||||
import { EmployeeRow } from "./employee-row";
|
||||
import { EmployeeReports } from "./employee-reports";
|
||||
import { toast } from "sonner";
|
||||
import { GET_MY_EMPLOYEES, GET_EMPLOYEE_SCHEDULE } from "@/graphql/queries";
|
||||
import {
|
||||
CREATE_EMPLOYEE,
|
||||
UPDATE_EMPLOYEE,
|
||||
DELETE_EMPLOYEE,
|
||||
UPDATE_EMPLOYEE_SCHEDULE,
|
||||
} from "@/graphql/mutations";
|
||||
import { Users, FileText, Plus, Layout, LayoutGrid } from "lucide-react";
|
||||
|
||||
// Интерфейс сотрудника
|
||||
interface Employee {
|
||||
id: string
|
||||
firstName: string
|
||||
lastName: string
|
||||
middleName?: string
|
||||
position: string
|
||||
phone: string
|
||||
email?: string
|
||||
avatar?: string
|
||||
hireDate: string
|
||||
status: 'ACTIVE' | 'VACATION' | 'SICK' | 'FIRED'
|
||||
salary?: number
|
||||
address?: string
|
||||
birthDate?: string
|
||||
passportSeries?: string
|
||||
passportNumber?: string
|
||||
passportIssued?: string
|
||||
passportDate?: string
|
||||
emergencyContact?: string
|
||||
emergencyPhone?: string
|
||||
telegram?: string
|
||||
whatsapp?: string
|
||||
passportPhoto?: string
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
id: string;
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
middleName?: string;
|
||||
position: string;
|
||||
phone: string;
|
||||
email?: string;
|
||||
avatar?: string;
|
||||
hireDate: string;
|
||||
status: "ACTIVE" | "VACATION" | "SICK" | "FIRED";
|
||||
salary?: number;
|
||||
address?: string;
|
||||
birthDate?: string;
|
||||
passportSeries?: string;
|
||||
passportNumber?: string;
|
||||
passportIssued?: string;
|
||||
passportDate?: string;
|
||||
emergencyContact?: string;
|
||||
emergencyPhone?: string;
|
||||
telegram?: string;
|
||||
whatsapp?: string;
|
||||
passportPhoto?: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export function EmployeesDashboard() {
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
const [showAddForm, setShowAddForm] = useState(false)
|
||||
const [showCompactForm, setShowCompactForm] = useState(true) // По умолчанию компактная форма
|
||||
const [showEditForm, setShowEditForm] = useState(false)
|
||||
const [createLoading, setCreateLoading] = useState(false)
|
||||
const [editingEmployee, setEditingEmployee] = useState<Employee | null>(null)
|
||||
const [deletingEmployeeId, setDeletingEmployeeId] = useState<string | null>(null)
|
||||
const [employeeSchedules, setEmployeeSchedules] = useState<{[key: string]: ScheduleRecord[]}>({})
|
||||
const [currentYear, setCurrentYear] = useState(new Date().getFullYear())
|
||||
const [currentMonth, setCurrentMonth] = useState(new Date().getMonth())
|
||||
const [activeTab, setActiveTab] = useState('combined')
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [showAddForm, setShowAddForm] = useState(false);
|
||||
const [showCompactForm, setShowCompactForm] = useState(true); // По умолчанию компактная форма
|
||||
const [showEditForm, setShowEditForm] = useState(false);
|
||||
const [createLoading, setCreateLoading] = useState(false);
|
||||
const [editingEmployee, setEditingEmployee] = useState<Employee | null>(null);
|
||||
const [deletingEmployeeId, setDeletingEmployeeId] = useState<string | null>(
|
||||
null
|
||||
);
|
||||
const [employeeSchedules, setEmployeeSchedules] = useState<{
|
||||
[key: string]: ScheduleRecord[];
|
||||
}>({});
|
||||
const [currentYear, setCurrentYear] = useState(new Date().getFullYear());
|
||||
const [currentMonth, setCurrentMonth] = useState(new Date().getMonth());
|
||||
const [activeTab, setActiveTab] = useState("combined");
|
||||
|
||||
interface ScheduleRecord {
|
||||
id: string
|
||||
date: string
|
||||
status: string
|
||||
hoursWorked?: number
|
||||
id: string;
|
||||
date: string;
|
||||
status: string;
|
||||
hoursWorked?: number;
|
||||
employee: {
|
||||
id: string
|
||||
}
|
||||
id: string;
|
||||
};
|
||||
}
|
||||
|
||||
// GraphQL запросы и мутации
|
||||
const { data, loading, refetch } = useQuery(GET_MY_EMPLOYEES)
|
||||
const [createEmployee] = useMutation(CREATE_EMPLOYEE)
|
||||
const [updateEmployee] = useMutation(UPDATE_EMPLOYEE)
|
||||
const [deleteEmployee] = useMutation(DELETE_EMPLOYEE)
|
||||
const [updateEmployeeSchedule] = useMutation(UPDATE_EMPLOYEE_SCHEDULE)
|
||||
const { data, loading, refetch } = useQuery(GET_MY_EMPLOYEES);
|
||||
const [createEmployee] = useMutation(CREATE_EMPLOYEE, {
|
||||
refetchQueries: [{ query: GET_MY_EMPLOYEES }],
|
||||
onCompleted: () => {
|
||||
refetch(); // Принудительно обновляем список
|
||||
},
|
||||
});
|
||||
const [updateEmployee] = useMutation(UPDATE_EMPLOYEE, {
|
||||
refetchQueries: [{ query: GET_MY_EMPLOYEES }],
|
||||
});
|
||||
const [deleteEmployee] = useMutation(DELETE_EMPLOYEE, {
|
||||
refetchQueries: [{ query: GET_MY_EMPLOYEES }],
|
||||
});
|
||||
const [updateEmployeeSchedule] = useMutation(UPDATE_EMPLOYEE_SCHEDULE);
|
||||
|
||||
const employees = useMemo(() => data?.myEmployees || [], [data?.myEmployees])
|
||||
const employees = useMemo(() => data?.myEmployees || [], [data?.myEmployees]);
|
||||
|
||||
// Загружаем данные табеля для всех сотрудников
|
||||
useEffect(() => {
|
||||
@ -93,35 +111,40 @@ export function EmployeesDashboard() {
|
||||
variables: {
|
||||
employeeId: employee.id,
|
||||
year: currentYear,
|
||||
month: currentMonth
|
||||
}
|
||||
})
|
||||
return { employeeId: employee.id, scheduleData: data?.employeeSchedule || [] }
|
||||
month: currentMonth,
|
||||
},
|
||||
});
|
||||
return {
|
||||
employeeId: employee.id,
|
||||
scheduleData: data?.employeeSchedule || [],
|
||||
};
|
||||
} catch (error) {
|
||||
console.error(`Error loading schedule for ${employee.id}:`, error)
|
||||
return { employeeId: employee.id, scheduleData: [] }
|
||||
console.error(`Error loading schedule for ${employee.id}:`, error);
|
||||
return { employeeId: employee.id, scheduleData: [] };
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
const results = await Promise.all(schedulePromises)
|
||||
const scheduleMap: {[key: string]: ScheduleRecord[]} = {}
|
||||
results.forEach((result: { employeeId: string; scheduleData: ScheduleRecord[] }) => {
|
||||
if (result && result.scheduleData) {
|
||||
scheduleMap[result.employeeId] = result.scheduleData
|
||||
const results = await Promise.all(schedulePromises);
|
||||
const scheduleMap: { [key: string]: ScheduleRecord[] } = {};
|
||||
results.forEach(
|
||||
(result: { employeeId: string; scheduleData: ScheduleRecord[] }) => {
|
||||
if (result && result.scheduleData) {
|
||||
scheduleMap[result.employeeId] = result.scheduleData;
|
||||
}
|
||||
}
|
||||
})
|
||||
setEmployeeSchedules(scheduleMap)
|
||||
);
|
||||
setEmployeeSchedules(scheduleMap);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
loadScheduleData()
|
||||
}, [employees, currentYear, currentMonth])
|
||||
loadScheduleData();
|
||||
}, [employees, currentYear, currentMonth]);
|
||||
|
||||
const handleEditEmployee = (employee: Employee) => {
|
||||
setEditingEmployee(employee)
|
||||
setShowEditForm(true)
|
||||
setShowAddForm(false) // Закрываем форму добавления если открыта
|
||||
}
|
||||
setEditingEmployee(employee);
|
||||
setShowEditForm(true);
|
||||
setShowAddForm(false); // Закрываем форму добавления если открыта
|
||||
};
|
||||
|
||||
const handleEmployeeSaved = async (employeeData: Partial<Employee>) => {
|
||||
try {
|
||||
@ -130,164 +153,174 @@ export function EmployeesDashboard() {
|
||||
const { data } = await updateEmployee({
|
||||
variables: {
|
||||
id: editingEmployee.id,
|
||||
input: employeeData
|
||||
}
|
||||
})
|
||||
input: employeeData,
|
||||
},
|
||||
});
|
||||
if (data?.updateEmployee?.success) {
|
||||
toast.success('Сотрудник успешно обновлен')
|
||||
refetch()
|
||||
toast.success("Сотрудник успешно обновлен");
|
||||
refetch();
|
||||
}
|
||||
} else {
|
||||
// Добавление нового сотрудника
|
||||
const { data } = await createEmployee({
|
||||
variables: { input: employeeData }
|
||||
})
|
||||
variables: { input: employeeData },
|
||||
});
|
||||
if (data?.createEmployee?.success) {
|
||||
toast.success('Сотрудник успешно добавлен')
|
||||
refetch()
|
||||
toast.success("Сотрудник успешно добавлен");
|
||||
refetch();
|
||||
}
|
||||
}
|
||||
setShowEditForm(false)
|
||||
setEditingEmployee(null)
|
||||
setShowEditForm(false);
|
||||
setEditingEmployee(null);
|
||||
} catch (error) {
|
||||
console.error('Error saving employee:', error)
|
||||
toast.error('Ошибка при сохранении сотрудника')
|
||||
console.error("Error saving employee:", error);
|
||||
toast.error("Ошибка при сохранении сотрудника");
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreateEmployee = async (employeeData: Partial<Employee>) => {
|
||||
setCreateLoading(true)
|
||||
setCreateLoading(true);
|
||||
try {
|
||||
const { data } = await createEmployee({
|
||||
variables: { input: employeeData }
|
||||
})
|
||||
variables: { input: employeeData },
|
||||
});
|
||||
if (data?.createEmployee?.success) {
|
||||
toast.success('Сотрудник успешно добавлен!')
|
||||
setShowAddForm(false)
|
||||
refetch()
|
||||
toast.success("Сотрудник успешно добавлен!");
|
||||
setShowAddForm(false);
|
||||
refetch();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error creating employee:', error)
|
||||
toast.error('Ошибка при создании сотрудника')
|
||||
console.error("Error creating employee:", error);
|
||||
toast.error("Ошибка при создании сотрудника");
|
||||
} finally {
|
||||
setCreateLoading(false)
|
||||
setCreateLoading(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleEmployeeDeleted = async (employeeId: string) => {
|
||||
try {
|
||||
setDeletingEmployeeId(employeeId)
|
||||
setDeletingEmployeeId(employeeId);
|
||||
const { data } = await deleteEmployee({
|
||||
variables: { id: employeeId }
|
||||
})
|
||||
variables: { id: employeeId },
|
||||
});
|
||||
if (data?.deleteEmployee) {
|
||||
toast.success('Сотрудник успешно уволен')
|
||||
refetch()
|
||||
toast.success("Сотрудник успешно уволен");
|
||||
refetch();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error deleting employee:', error)
|
||||
toast.error('Ошибка при увольнении сотрудника')
|
||||
console.error("Error deleting employee:", error);
|
||||
toast.error("Ошибка при увольнении сотрудника");
|
||||
} finally {
|
||||
setDeletingEmployeeId(null)
|
||||
setDeletingEmployeeId(null);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Функция для изменения статуса дня в табеле
|
||||
const changeDayStatus = async (employeeId: string, day: number, currentStatus: string) => {
|
||||
const changeDayStatus = async (
|
||||
employeeId: string,
|
||||
day: number,
|
||||
currentStatus: string
|
||||
) => {
|
||||
try {
|
||||
// Циклично переключаем статусы
|
||||
const statuses = ['WORK', 'WEEKEND', 'VACATION', 'SICK', 'ABSENT']
|
||||
const currentIndex = statuses.indexOf(currentStatus.toUpperCase())
|
||||
const nextStatus = statuses[(currentIndex + 1) % statuses.length]
|
||||
const statuses = ["WORK", "WEEKEND", "VACATION", "SICK", "ABSENT"];
|
||||
const currentIndex = statuses.indexOf(currentStatus.toUpperCase());
|
||||
const nextStatus = statuses[(currentIndex + 1) % statuses.length];
|
||||
|
||||
// Формируем дату
|
||||
const date = new Date(currentYear, currentMonth, day)
|
||||
const hours = nextStatus === 'WORK' ? 8 : 0
|
||||
const date = new Date(currentYear, currentMonth, day);
|
||||
const hours = nextStatus === "WORK" ? 8 : 0;
|
||||
|
||||
// Отправляем мутацию
|
||||
await updateEmployeeSchedule({
|
||||
variables: {
|
||||
input: {
|
||||
employeeId: employeeId,
|
||||
date: date.toISOString().split('T')[0], // YYYY-MM-DD формат
|
||||
date: date.toISOString().split("T")[0], // YYYY-MM-DD формат
|
||||
status: nextStatus,
|
||||
hoursWorked: hours
|
||||
}
|
||||
}
|
||||
})
|
||||
hoursWorked: hours,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Обновляем локальное состояние
|
||||
const updatedDate = new Date(currentYear, currentMonth, day)
|
||||
const dateStr = updatedDate.toISOString().split('T')[0]
|
||||
// Обновляем локальное состояние
|
||||
const updatedDate = new Date(currentYear, currentMonth, day);
|
||||
const dateStr = updatedDate.toISOString().split("T")[0];
|
||||
|
||||
setEmployeeSchedules(prev => {
|
||||
const currentSchedule = prev[employeeId] || []
|
||||
const existingRecordIndex = currentSchedule.findIndex(record =>
|
||||
record.date.split('T')[0] === dateStr
|
||||
)
|
||||
setEmployeeSchedules((prev) => {
|
||||
const currentSchedule = prev[employeeId] || [];
|
||||
const existingRecordIndex = currentSchedule.findIndex(
|
||||
(record) => record.date.split("T")[0] === dateStr
|
||||
);
|
||||
|
||||
const newRecord = {
|
||||
id: Date.now().toString(), // временный ID
|
||||
date: updatedDate.toISOString(),
|
||||
status: nextStatus,
|
||||
hoursWorked: hours,
|
||||
employee: { id: employeeId }
|
||||
}
|
||||
const newRecord = {
|
||||
id: Date.now().toString(), // временный ID
|
||||
date: updatedDate.toISOString(),
|
||||
status: nextStatus,
|
||||
hoursWorked: hours,
|
||||
employee: { id: employeeId },
|
||||
};
|
||||
|
||||
let updatedSchedule
|
||||
let updatedSchedule;
|
||||
if (existingRecordIndex >= 0) {
|
||||
// Обновляем существующую запись
|
||||
updatedSchedule = [...currentSchedule]
|
||||
updatedSchedule[existingRecordIndex] = { ...updatedSchedule[existingRecordIndex], ...newRecord }
|
||||
updatedSchedule = [...currentSchedule];
|
||||
updatedSchedule[existingRecordIndex] = {
|
||||
...updatedSchedule[existingRecordIndex],
|
||||
...newRecord,
|
||||
};
|
||||
} else {
|
||||
// Добавляем новую запись
|
||||
updatedSchedule = [...currentSchedule, newRecord]
|
||||
updatedSchedule = [...currentSchedule, newRecord];
|
||||
}
|
||||
|
||||
return {
|
||||
...prev,
|
||||
[employeeId]: updatedSchedule
|
||||
}
|
||||
})
|
||||
|
||||
toast.success('Статус дня обновлен')
|
||||
[employeeId]: updatedSchedule,
|
||||
};
|
||||
});
|
||||
|
||||
toast.success("Статус дня обновлен");
|
||||
} catch (error) {
|
||||
console.error('Error updating day status:', error)
|
||||
toast.error('Ошибка при обновлении статуса дня')
|
||||
console.error("Error updating day status:", error);
|
||||
toast.error("Ошибка при обновлении статуса дня");
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Функция для обновления данных дня из модалки
|
||||
const updateDayData = async (employeeId: string, date: Date, data: {
|
||||
status: string
|
||||
hoursWorked?: number
|
||||
overtimeHours?: number
|
||||
notes?: string
|
||||
}) => {
|
||||
const updateDayData = async (
|
||||
employeeId: string,
|
||||
date: Date,
|
||||
data: {
|
||||
status: string;
|
||||
hoursWorked?: number;
|
||||
overtimeHours?: number;
|
||||
notes?: string;
|
||||
}
|
||||
) => {
|
||||
try {
|
||||
// Отправляем мутацию
|
||||
await updateEmployeeSchedule({
|
||||
variables: {
|
||||
input: {
|
||||
employeeId: employeeId,
|
||||
date: date.toISOString().split('T')[0], // YYYY-MM-DD формат
|
||||
date: date.toISOString().split("T")[0], // YYYY-MM-DD формат
|
||||
status: data.status,
|
||||
hoursWorked: data.hoursWorked,
|
||||
overtimeHours: data.overtimeHours,
|
||||
notes: data.notes
|
||||
}
|
||||
}
|
||||
})
|
||||
notes: data.notes,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Обновляем локальное состояние
|
||||
const dateStr = date.toISOString().split('T')[0]
|
||||
const dateStr = date.toISOString().split("T")[0];
|
||||
|
||||
setEmployeeSchedules(prev => {
|
||||
const currentSchedule = prev[employeeId] || []
|
||||
const existingRecordIndex = currentSchedule.findIndex(record =>
|
||||
record.date.split('T')[0] === dateStr
|
||||
)
|
||||
setEmployeeSchedules((prev) => {
|
||||
const currentSchedule = prev[employeeId] || [];
|
||||
const existingRecordIndex = currentSchedule.findIndex(
|
||||
(record) => record.date.split("T")[0] === dateStr
|
||||
);
|
||||
|
||||
const newRecord = {
|
||||
id: Date.now().toString(), // временный ID
|
||||
@ -296,73 +329,98 @@ export function EmployeesDashboard() {
|
||||
hoursWorked: data.hoursWorked,
|
||||
overtimeHours: data.overtimeHours,
|
||||
notes: data.notes,
|
||||
employee: { id: employeeId }
|
||||
}
|
||||
employee: { id: employeeId },
|
||||
};
|
||||
|
||||
let updatedSchedule
|
||||
let updatedSchedule;
|
||||
if (existingRecordIndex >= 0) {
|
||||
// Обновляем существующую запись
|
||||
updatedSchedule = [...currentSchedule]
|
||||
updatedSchedule[existingRecordIndex] = { ...updatedSchedule[existingRecordIndex], ...newRecord }
|
||||
updatedSchedule = [...currentSchedule];
|
||||
updatedSchedule[existingRecordIndex] = {
|
||||
...updatedSchedule[existingRecordIndex],
|
||||
...newRecord,
|
||||
};
|
||||
} else {
|
||||
// Добавляем новую запись
|
||||
updatedSchedule = [...currentSchedule, newRecord]
|
||||
updatedSchedule = [...currentSchedule, newRecord];
|
||||
}
|
||||
|
||||
return {
|
||||
...prev,
|
||||
[employeeId]: updatedSchedule
|
||||
}
|
||||
})
|
||||
|
||||
toast.success('Данные дня обновлены')
|
||||
[employeeId]: updatedSchedule,
|
||||
};
|
||||
});
|
||||
|
||||
toast.success("Данные дня обновлены");
|
||||
} catch (error) {
|
||||
console.error('Error updating day data:', error)
|
||||
toast.error('Ошибка при обновлении данных дня')
|
||||
console.error("Error updating day data:", error);
|
||||
toast.error("Ошибка при обновлении данных дня");
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const exportToCSV = () => {
|
||||
const csvContent = [
|
||||
['ФИО', 'Должность', 'Статус', 'Зарплата', 'Телефон', 'Email', 'Дата найма'],
|
||||
[
|
||||
"ФИО",
|
||||
"Должность",
|
||||
"Статус",
|
||||
"Зарплата",
|
||||
"Телефон",
|
||||
"Email",
|
||||
"Дата найма",
|
||||
],
|
||||
...employees.map((emp: Employee) => [
|
||||
`${emp.firstName} ${emp.lastName}`,
|
||||
emp.position,
|
||||
emp.status === 'ACTIVE' ? 'Активен' :
|
||||
emp.status === 'VACATION' ? 'В отпуске' :
|
||||
emp.status === 'SICK' ? 'На больничном' : 'Уволен',
|
||||
emp.salary?.toString() || '',
|
||||
emp.status === "ACTIVE"
|
||||
? "Активен"
|
||||
: emp.status === "VACATION"
|
||||
? "В отпуске"
|
||||
: emp.status === "SICK"
|
||||
? "На больничном"
|
||||
: "Уволен",
|
||||
emp.salary?.toString() || "",
|
||||
emp.phone,
|
||||
emp.email || '',
|
||||
new Date(emp.hireDate).toLocaleDateString('ru-RU')
|
||||
])
|
||||
].map(row => row.join(',')).join('\n')
|
||||
emp.email || "",
|
||||
new Date(emp.hireDate).toLocaleDateString("ru-RU"),
|
||||
]),
|
||||
]
|
||||
.map((row) => row.join(","))
|
||||
.join("\n");
|
||||
|
||||
const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' })
|
||||
const link = document.createElement('a')
|
||||
const url = URL.createObjectURL(blob)
|
||||
link.setAttribute('href', url)
|
||||
link.setAttribute('download', `employees_report_${new Date().toISOString().split('T')[0]}.csv`)
|
||||
document.body.appendChild(link)
|
||||
link.click()
|
||||
document.body.removeChild(link)
|
||||
toast.success('Отчет успешно экспортирован')
|
||||
}
|
||||
const blob = new Blob([csvContent], { type: "text/csv;charset=utf-8;" });
|
||||
const link = document.createElement("a");
|
||||
const url = URL.createObjectURL(blob);
|
||||
link.setAttribute("href", url);
|
||||
link.setAttribute(
|
||||
"download",
|
||||
`employees_report_${new Date().toISOString().split("T")[0]}.csv`
|
||||
);
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
toast.success("Отчет успешно экспортирован");
|
||||
};
|
||||
|
||||
const generateReport = () => {
|
||||
const stats = {
|
||||
total: employees.length,
|
||||
active: employees.filter((e: Employee) => e.status === 'ACTIVE').length,
|
||||
vacation: employees.filter((e: Employee) => e.status === 'VACATION').length,
|
||||
sick: employees.filter((e: Employee) => e.status === 'SICK').length,
|
||||
inactive: employees.filter((e: Employee) => e.status === 'FIRED').length,
|
||||
avgSalary: Math.round(employees.reduce((sum: number, e: Employee) => sum + (e.salary || 0), 0) / employees.length)
|
||||
}
|
||||
active: employees.filter((e: Employee) => e.status === "ACTIVE").length,
|
||||
vacation: employees.filter((e: Employee) => e.status === "VACATION")
|
||||
.length,
|
||||
sick: employees.filter((e: Employee) => e.status === "SICK").length,
|
||||
inactive: employees.filter((e: Employee) => e.status === "FIRED").length,
|
||||
avgSalary: Math.round(
|
||||
employees.reduce(
|
||||
(sum: number, e: Employee) => sum + (e.salary || 0),
|
||||
0
|
||||
) / employees.length
|
||||
),
|
||||
};
|
||||
|
||||
const reportText = `
|
||||
ОТЧЕТ ПО СОТРУДНИКАМ
|
||||
Дата: ${new Date().toLocaleDateString('ru-RU')}
|
||||
Дата: ${new Date().toLocaleDateString("ru-RU")}
|
||||
|
||||
ОБЩАЯ СТАТИСТИКА:
|
||||
• Всего сотрудников: ${stats.total}
|
||||
@ -370,24 +428,29 @@ export function EmployeesDashboard() {
|
||||
• В отпуске: ${stats.vacation}
|
||||
• На больничном: ${stats.sick}
|
||||
• Неактивных: ${stats.inactive}
|
||||
• Средняя зарплата: ${stats.avgSalary.toLocaleString('ru-RU')} ₽
|
||||
• Средняя зарплата: ${stats.avgSalary.toLocaleString("ru-RU")} ₽
|
||||
|
||||
СПИСОК СОТРУДНИКОВ:
|
||||
${employees.map((emp: Employee) =>
|
||||
`• ${emp.firstName} ${emp.lastName} - ${emp.position}`
|
||||
).join('\n')}
|
||||
`.trim()
|
||||
${employees
|
||||
.map(
|
||||
(emp: Employee) => `• ${emp.firstName} ${emp.lastName} - ${emp.position}`
|
||||
)
|
||||
.join("\n")}
|
||||
`.trim();
|
||||
|
||||
const blob = new Blob([reportText], { type: 'text/plain;charset=utf-8;' })
|
||||
const link = document.createElement('a')
|
||||
const url = URL.createObjectURL(blob)
|
||||
link.setAttribute('href', url)
|
||||
link.setAttribute('download', `employees_summary_${new Date().toISOString().split('T')[0]}.txt`)
|
||||
document.body.appendChild(link)
|
||||
link.click()
|
||||
document.body.removeChild(link)
|
||||
toast.success('Сводный отчет создан')
|
||||
}
|
||||
const blob = new Blob([reportText], { type: "text/plain;charset=utf-8;" });
|
||||
const link = document.createElement("a");
|
||||
const url = URL.createObjectURL(blob);
|
||||
link.setAttribute("href", url);
|
||||
link.setAttribute(
|
||||
"download",
|
||||
`employees_summary_${new Date().toISOString().split("T")[0]}.txt`
|
||||
);
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
toast.success("Сводный отчет создан");
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
@ -401,7 +464,7 @@ ${employees.map((emp: Employee) =>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
@ -410,7 +473,11 @@ ${employees.map((emp: Employee) =>
|
||||
<main className="flex-1 ml-56 p-6">
|
||||
<div className="max-w-7xl mx-auto">
|
||||
{/* Панель управления с улучшенным расположением */}
|
||||
<Tabs value={activeTab} onValueChange={setActiveTab} className="w-full">
|
||||
<Tabs
|
||||
value={activeTab}
|
||||
onValueChange={setActiveTab}
|
||||
className="w-full"
|
||||
>
|
||||
<div className="flex items-center justify-between gap-6 mb-6">
|
||||
{/* Красивые табы слева */}
|
||||
<TabsList className="glass-card inline-flex h-10 items-center justify-center rounded-lg bg-white/5 p-1">
|
||||
@ -467,141 +534,160 @@ ${employees.map((emp: Employee) =>
|
||||
className="bg-gradient-to-r from-purple-600 to-pink-600 hover:from-purple-700 hover:to-pink-700 text-white border-0 shadow-lg shadow-purple-500/25 transition-all duration-300"
|
||||
>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
{showAddForm ? 'Скрыть форму' : 'Добавить'}
|
||||
{showAddForm ? "Скрыть форму" : "Добавить"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Форма добавления сотрудника */}
|
||||
{showAddForm && (
|
||||
showCompactForm ? (
|
||||
<EmployeeCompactForm
|
||||
onSave={handleCreateEmployee}
|
||||
onCancel={() => setShowAddForm(false)}
|
||||
{/* Форма добавления сотрудника */}
|
||||
{showAddForm &&
|
||||
(showCompactForm ? (
|
||||
<EmployeeCompactForm
|
||||
onSave={handleCreateEmployee}
|
||||
onCancel={() => setShowAddForm(false)}
|
||||
isLoading={createLoading}
|
||||
/>
|
||||
) : (
|
||||
<EmployeeInlineForm
|
||||
onSave={handleCreateEmployee}
|
||||
onCancel={() => setShowAddForm(false)}
|
||||
isLoading={createLoading}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* Форма редактирования сотрудника */}
|
||||
{showEditForm && editingEmployee && (
|
||||
<EmployeeEditInlineForm
|
||||
employee={editingEmployee}
|
||||
onSave={handleEmployeeSaved}
|
||||
onCancel={() => {
|
||||
setShowEditForm(false);
|
||||
setEditingEmployee(null);
|
||||
}}
|
||||
isLoading={createLoading}
|
||||
/>
|
||||
) : (
|
||||
<EmployeeInlineForm
|
||||
onSave={handleCreateEmployee}
|
||||
onCancel={() => setShowAddForm(false)}
|
||||
isLoading={createLoading}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
)}
|
||||
|
||||
{/* Форма редактирования сотрудника */}
|
||||
{showEditForm && editingEmployee && (
|
||||
<EmployeeEditInlineForm
|
||||
employee={editingEmployee}
|
||||
onSave={handleEmployeeSaved}
|
||||
onCancel={() => {
|
||||
setShowEditForm(false)
|
||||
setEditingEmployee(null)
|
||||
}}
|
||||
isLoading={createLoading}
|
||||
/>
|
||||
)}
|
||||
{/* Контент табов */}
|
||||
<TabsContent value="combined">
|
||||
<Card className="glass-card p-6">
|
||||
{(() => {
|
||||
const filteredEmployees = employees.filter(
|
||||
(employee: Employee) =>
|
||||
`${employee.firstName} ${employee.lastName}`
|
||||
.toLowerCase()
|
||||
.includes(searchQuery.toLowerCase()) ||
|
||||
employee.position
|
||||
.toLowerCase()
|
||||
.includes(searchQuery.toLowerCase())
|
||||
);
|
||||
|
||||
{/* Контент табов */}
|
||||
<TabsContent value="combined">
|
||||
<Card className="glass-card p-6">
|
||||
{(() => {
|
||||
const filteredEmployees = employees.filter((employee: Employee) =>
|
||||
`${employee.firstName} ${employee.lastName}`.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
employee.position.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
)
|
||||
if (filteredEmployees.length === 0) {
|
||||
return (
|
||||
<EmployeeEmptyState
|
||||
searchQuery={searchQuery}
|
||||
onShowAddForm={() => setShowAddForm(true)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (filteredEmployees.length === 0) {
|
||||
return (
|
||||
<EmployeeEmptyState
|
||||
searchQuery={searchQuery}
|
||||
onShowAddForm={() => setShowAddForm(true)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
<div className="space-y-6">
|
||||
{/* Навигация по месяцам и легенда в одной строке */}
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<h3 className="text-white font-medium text-lg capitalize">
|
||||
{new Date().toLocaleDateString("ru-RU", {
|
||||
weekday: "long",
|
||||
day: "numeric",
|
||||
month: "long",
|
||||
year: "numeric",
|
||||
})}
|
||||
</h3>
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Навигация по месяцам и легенда в одной строке */}
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<h3 className="text-white font-medium text-lg capitalize">
|
||||
{new Date().toLocaleDateString('ru-RU', {
|
||||
weekday: 'long',
|
||||
day: 'numeric',
|
||||
month: 'long',
|
||||
year: 'numeric'
|
||||
})}
|
||||
</h3>
|
||||
|
||||
{/* Кнопки навигации */}
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="glass-secondary text-white hover:text-white h-8 px-3"
|
||||
onClick={() => {
|
||||
const newDate = new Date(currentYear, currentMonth - 1)
|
||||
setCurrentYear(newDate.getFullYear())
|
||||
setCurrentMonth(newDate.getMonth())
|
||||
}}
|
||||
>
|
||||
←
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="glass-secondary text-white hover:text-white h-8 px-3"
|
||||
onClick={() => {
|
||||
const today = new Date()
|
||||
setCurrentYear(today.getFullYear())
|
||||
setCurrentMonth(today.getMonth())
|
||||
}}
|
||||
>
|
||||
Сегодня
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="glass-secondary text-white hover:text-white h-8 px-3"
|
||||
onClick={() => {
|
||||
const newDate = new Date(currentYear, currentMonth + 1)
|
||||
setCurrentYear(newDate.getFullYear())
|
||||
setCurrentMonth(newDate.getMonth())
|
||||
}}
|
||||
>
|
||||
→
|
||||
</Button>
|
||||
{/* Кнопки навигации */}
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="glass-secondary text-white hover:text-white h-8 px-3"
|
||||
onClick={() => {
|
||||
const newDate = new Date(
|
||||
currentYear,
|
||||
currentMonth - 1
|
||||
);
|
||||
setCurrentYear(newDate.getFullYear());
|
||||
setCurrentMonth(newDate.getMonth());
|
||||
}}
|
||||
>
|
||||
←
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="glass-secondary text-white hover:text-white h-8 px-3"
|
||||
onClick={() => {
|
||||
const today = new Date();
|
||||
setCurrentYear(today.getFullYear());
|
||||
setCurrentMonth(today.getMonth());
|
||||
}}
|
||||
>
|
||||
Сегодня
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="glass-secondary text-white hover:text-white h-8 px-3"
|
||||
onClick={() => {
|
||||
const newDate = new Date(
|
||||
currentYear,
|
||||
currentMonth + 1
|
||||
);
|
||||
setCurrentYear(newDate.getFullYear());
|
||||
setCurrentMonth(newDate.getMonth());
|
||||
}}
|
||||
>
|
||||
→
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Легенда статусов справа */}
|
||||
<EmployeeLegend />
|
||||
</div>
|
||||
|
||||
{/* Легенда статусов справа */}
|
||||
<EmployeeLegend />
|
||||
{/* Компактный список сотрудников с раскрывающимся табелем */}
|
||||
<div>
|
||||
{filteredEmployees.map(
|
||||
(employee: Employee, index: number) => (
|
||||
<div
|
||||
key={employee.id}
|
||||
className={
|
||||
index < filteredEmployees.length - 1
|
||||
? "mb-4"
|
||||
: ""
|
||||
}
|
||||
>
|
||||
<EmployeeRow
|
||||
employee={employee}
|
||||
employeeSchedules={employeeSchedules}
|
||||
currentYear={currentYear}
|
||||
currentMonth={currentMonth}
|
||||
onEdit={handleEditEmployee}
|
||||
onDelete={handleEmployeeDeleted}
|
||||
onDayStatusChange={changeDayStatus}
|
||||
onDayUpdate={updateDayData}
|
||||
deletingEmployeeId={deletingEmployeeId}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Компактный список сотрудников с раскрывающимся табелем */}
|
||||
<div>
|
||||
{filteredEmployees.map((employee: Employee, index: number) => (
|
||||
<div key={employee.id} className={index < filteredEmployees.length - 1 ? "mb-4" : ""}>
|
||||
<EmployeeRow
|
||||
employee={employee}
|
||||
employeeSchedules={employeeSchedules}
|
||||
currentYear={currentYear}
|
||||
currentMonth={currentMonth}
|
||||
onEdit={handleEditEmployee}
|
||||
onDelete={handleEmployeeDeleted}
|
||||
onDayStatusChange={changeDayStatus}
|
||||
onDayUpdate={updateDayData}
|
||||
deletingEmployeeId={deletingEmployeeId}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})()}
|
||||
</Card>
|
||||
</TabsContent>
|
||||
);
|
||||
})()}
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="reports">
|
||||
<EmployeeReports
|
||||
@ -615,5 +701,5 @@ ${employees.map((emp: Employee) =>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
@ -99,14 +99,44 @@ export function CreateFulfillmentConsumablesSupplyPage() {
|
||||
GET_MY_COUNTERPARTIES
|
||||
);
|
||||
|
||||
// ОТЛАДКА: Логируем состояние перед запросом товаров
|
||||
console.log("🔍 ДИАГНОСТИКА ЗАПРОСА ТОВАРОВ:", {
|
||||
selectedSupplier: selectedSupplier
|
||||
? {
|
||||
id: selectedSupplier.id,
|
||||
name: selectedSupplier.name || selectedSupplier.fullName,
|
||||
type: selectedSupplier.type,
|
||||
}
|
||||
: null,
|
||||
skipQuery: !selectedSupplier,
|
||||
productSearchQuery,
|
||||
});
|
||||
|
||||
// Загружаем товары для выбранного поставщика
|
||||
const { data: productsData, loading: productsLoading } = useQuery(
|
||||
GET_ALL_PRODUCTS,
|
||||
{
|
||||
skip: !selectedSupplier,
|
||||
variables: { search: productSearchQuery || null, category: null },
|
||||
}
|
||||
);
|
||||
const {
|
||||
data: productsData,
|
||||
loading: productsLoading,
|
||||
error: productsError,
|
||||
} = useQuery(GET_ALL_PRODUCTS, {
|
||||
skip: !selectedSupplier,
|
||||
variables: { search: productSearchQuery || null, category: null },
|
||||
onCompleted: (data) => {
|
||||
console.log("✅ GET_ALL_PRODUCTS COMPLETED:", {
|
||||
totalProducts: data?.allProducts?.length || 0,
|
||||
products:
|
||||
data?.allProducts?.map((p) => ({
|
||||
id: p.id,
|
||||
name: p.name,
|
||||
type: p.type,
|
||||
orgId: p.organization?.id,
|
||||
orgName: p.organization?.name,
|
||||
})) || [],
|
||||
});
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error("❌ GET_ALL_PRODUCTS ERROR:", error);
|
||||
},
|
||||
});
|
||||
|
||||
// Мутация для создания заказа поставки расходников
|
||||
const [createSupplyOrder] = useMutation(CREATE_SUPPLY_ORDER);
|
||||
@ -117,9 +147,9 @@ export function CreateFulfillmentConsumablesSupplyPage() {
|
||||
).filter((org: FulfillmentConsumableSupplier) => org.type === "WHOLESALE");
|
||||
|
||||
// Фильтруем только логистические компании
|
||||
const logisticsPartners = (
|
||||
counterpartiesData?.myCounterparties || []
|
||||
).filter((org: FulfillmentConsumableSupplier) => org.type === "LOGIST");
|
||||
const logisticsPartners = (counterpartiesData?.myCounterparties || []).filter(
|
||||
(org: FulfillmentConsumableSupplier) => org.type === "LOGIST"
|
||||
);
|
||||
|
||||
// Фильтруем поставщиков по поисковому запросу
|
||||
const filteredSuppliers = consumableSuppliers.filter(
|
||||
@ -150,6 +180,7 @@ export function CreateFulfillmentConsumablesSupplyPage() {
|
||||
}
|
||||
: null,
|
||||
productsLoading,
|
||||
productsError: productsError?.message,
|
||||
allProductsCount: productsData?.allProducts?.length || 0,
|
||||
supplierProductsCount: supplierProducts.length,
|
||||
allProducts:
|
||||
@ -160,14 +191,20 @@ export function CreateFulfillmentConsumablesSupplyPage() {
|
||||
organizationName: p.organization.name,
|
||||
type: p.type || "NO_TYPE",
|
||||
})) || [],
|
||||
supplierProducts: supplierProducts.map((p) => ({
|
||||
supplierProductsDetails: supplierProducts.slice(0, 5).map((p) => ({
|
||||
id: p.id,
|
||||
name: p.name,
|
||||
organizationId: p.organization.id,
|
||||
organizationName: p.organization.name,
|
||||
})),
|
||||
});
|
||||
}, [selectedSupplier, productsData, productsLoading, supplierProducts]);
|
||||
}, [
|
||||
selectedSupplier,
|
||||
productsData,
|
||||
productsLoading,
|
||||
productsError,
|
||||
supplierProducts.length,
|
||||
]);
|
||||
|
||||
const formatCurrency = (amount: number) => {
|
||||
return new Intl.NumberFormat("ru-RU", {
|
||||
@ -198,10 +235,13 @@ export function CreateFulfillmentConsumablesSupplyPage() {
|
||||
|
||||
// 🔒 ВАЛИДАЦИЯ ОСТАТКОВ согласно правилам (раздел 6.2)
|
||||
if (quantity > 0) {
|
||||
const availableStock = (product.stock || product.quantity || 0) - (product.ordered || 0);
|
||||
const availableStock =
|
||||
(product.stock || product.quantity || 0) - (product.ordered || 0);
|
||||
|
||||
if (quantity > availableStock) {
|
||||
toast.error(`❌ Недостаточно остатков!\nДоступно: ${availableStock} шт.\nЗапрашивается: ${quantity} шт.`);
|
||||
toast.error(
|
||||
`❌ Недостаточно остатков!\nДоступно: ${availableStock} шт.\nЗапрашивается: ${quantity} шт.`
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
@ -265,7 +305,15 @@ export function CreateFulfillmentConsumablesSupplyPage() {
|
||||
!deliveryDate ||
|
||||
!selectedLogistics
|
||||
) {
|
||||
toast.error("Заполните все обязательные поля");
|
||||
toast.error(
|
||||
"Заполните все обязательные поля: поставщик, расходники, дата доставки и логистика"
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Дополнительная проверка ID логистики
|
||||
if (!selectedLogistics.id) {
|
||||
toast.error("Выберите логистическую компанию");
|
||||
return;
|
||||
}
|
||||
|
||||
@ -279,7 +327,7 @@ export function CreateFulfillmentConsumablesSupplyPage() {
|
||||
deliveryDate: deliveryDate,
|
||||
// Для фулфилмента указываем себя как получателя (поставка на свой склад)
|
||||
fulfillmentCenterId: user?.organization?.id,
|
||||
logisticsPartnerId: selectedLogistics?.id,
|
||||
logisticsPartnerId: selectedLogistics.id,
|
||||
// 🏷️ КЛАССИФИКАЦИЯ согласно правилам (раздел 2.2)
|
||||
consumableType: "FULFILLMENT_CONSUMABLES", // Расходники фулфилмента
|
||||
items: selectedConsumables.map((consumable) => ({
|
||||
@ -574,15 +622,19 @@ export function CreateFulfillmentConsumablesSupplyPage() {
|
||||
<div className="aspect-square bg-white/5 rounded-lg overflow-hidden relative flex-shrink-0">
|
||||
{/* 🚫 ОВЕРЛЕЙ НЕДОСТУПНОСТИ */}
|
||||
{(() => {
|
||||
const totalStock = product.stock || product.quantity || 0;
|
||||
const totalStock =
|
||||
product.stock || product.quantity || 0;
|
||||
const orderedStock = product.ordered || 0;
|
||||
const availableStock = totalStock - orderedStock;
|
||||
const availableStock =
|
||||
totalStock - orderedStock;
|
||||
|
||||
if (availableStock <= 0) {
|
||||
return (
|
||||
<div className="absolute inset-0 bg-black/60 backdrop-blur-sm flex items-center justify-center z-10">
|
||||
<div className="text-center">
|
||||
<div className="text-red-400 font-bold text-xs">НЕТ В НАЛИЧИИ</div>
|
||||
<div className="text-red-400 font-bold text-xs">
|
||||
НЕТ В НАЛИЧИИ
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@ -636,9 +688,11 @@ export function CreateFulfillmentConsumablesSupplyPage() {
|
||||
)}
|
||||
{/* 🚨 ИНДИКАТОР НИЗКИХ ОСТАТКОВ согласно правилам (раздел 6.3) */}
|
||||
{(() => {
|
||||
const totalStock = product.stock || product.quantity || 0;
|
||||
const totalStock =
|
||||
product.stock || product.quantity || 0;
|
||||
const orderedStock = product.ordered || 0;
|
||||
const availableStock = totalStock - orderedStock;
|
||||
const availableStock =
|
||||
totalStock - orderedStock;
|
||||
|
||||
if (availableStock <= 0) {
|
||||
return (
|
||||
@ -663,19 +717,26 @@ export function CreateFulfillmentConsumablesSupplyPage() {
|
||||
{/* 📊 АКТУАЛЬНЫЙ ОСТАТОК согласно правилам (раздел 6.4.2) */}
|
||||
<div className="text-right">
|
||||
{(() => {
|
||||
const totalStock = product.stock || product.quantity || 0;
|
||||
const orderedStock = product.ordered || 0;
|
||||
const availableStock = totalStock - orderedStock;
|
||||
const totalStock =
|
||||
product.stock ||
|
||||
product.quantity ||
|
||||
0;
|
||||
const orderedStock =
|
||||
product.ordered || 0;
|
||||
const availableStock =
|
||||
totalStock - orderedStock;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-end">
|
||||
<span className={`text-xs font-medium ${
|
||||
availableStock <= 0
|
||||
? 'text-red-400'
|
||||
: availableStock <= 10
|
||||
? 'text-yellow-400'
|
||||
: 'text-white/80'
|
||||
}`}>
|
||||
<span
|
||||
className={`text-xs font-medium ${
|
||||
availableStock <= 0
|
||||
? "text-red-400"
|
||||
: availableStock <= 10
|
||||
? "text-yellow-400"
|
||||
: "text-white/80"
|
||||
}`}
|
||||
>
|
||||
Доступно: {availableStock}
|
||||
</span>
|
||||
{orderedStock > 0 && (
|
||||
@ -693,9 +754,11 @@ export function CreateFulfillmentConsumablesSupplyPage() {
|
||||
{/* Управление количеством */}
|
||||
<div className="flex flex-col items-center space-y-2 mt-auto">
|
||||
{(() => {
|
||||
const totalStock = product.stock || product.quantity || 0;
|
||||
const totalStock =
|
||||
product.stock || product.quantity || 0;
|
||||
const orderedStock = product.ordered || 0;
|
||||
const availableStock = totalStock - orderedStock;
|
||||
const availableStock =
|
||||
totalStock - orderedStock;
|
||||
|
||||
return (
|
||||
<div className="flex items-center space-x-2">
|
||||
@ -713,81 +776,92 @@ export function CreateFulfillmentConsumablesSupplyPage() {
|
||||
>
|
||||
<Minus className="h-3 w-3" />
|
||||
</Button>
|
||||
<Input
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
pattern="[0-9]*"
|
||||
value={
|
||||
selectedQuantity === 0
|
||||
? ""
|
||||
: selectedQuantity.toString()
|
||||
}
|
||||
onChange={(e) => {
|
||||
let inputValue = e.target.value;
|
||||
<Input
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
pattern="[0-9]*"
|
||||
value={
|
||||
selectedQuantity === 0
|
||||
? ""
|
||||
: selectedQuantity.toString()
|
||||
}
|
||||
onChange={(e) => {
|
||||
let inputValue = e.target.value;
|
||||
|
||||
// Удаляем все нецифровые символы
|
||||
inputValue = inputValue.replace(
|
||||
/[^0-9]/g,
|
||||
""
|
||||
);
|
||||
// Удаляем все нецифровые символы
|
||||
inputValue = inputValue.replace(
|
||||
/[^0-9]/g,
|
||||
""
|
||||
);
|
||||
|
||||
// Удаляем ведущие нули
|
||||
inputValue = inputValue.replace(
|
||||
/^0+/,
|
||||
""
|
||||
);
|
||||
// Удаляем ведущие нули
|
||||
inputValue = inputValue.replace(
|
||||
/^0+/,
|
||||
""
|
||||
);
|
||||
|
||||
// Если строка пустая после удаления нулей, устанавливаем 0
|
||||
const numericValue =
|
||||
inputValue === ""
|
||||
? 0
|
||||
: parseInt(inputValue);
|
||||
// Если строка пустая после удаления нулей, устанавливаем 0
|
||||
const numericValue =
|
||||
inputValue === ""
|
||||
? 0
|
||||
: parseInt(inputValue);
|
||||
|
||||
// Ограничиваем значение максимумом доступного остатка
|
||||
const clampedValue = Math.min(
|
||||
numericValue,
|
||||
availableStock,
|
||||
99999
|
||||
);
|
||||
// Ограничиваем значение максимумом доступного остатка
|
||||
const clampedValue = Math.min(
|
||||
numericValue,
|
||||
availableStock,
|
||||
99999
|
||||
);
|
||||
|
||||
updateConsumableQuantity(
|
||||
product.id,
|
||||
clampedValue
|
||||
);
|
||||
}}
|
||||
onBlur={(e) => {
|
||||
// При потере фокуса, если поле пустое, устанавливаем 0
|
||||
if (e.target.value === "") {
|
||||
updateConsumableQuantity(
|
||||
product.id,
|
||||
0
|
||||
);
|
||||
}
|
||||
}}
|
||||
className="w-16 h-7 text-center text-sm bg-white/10 border-white/20 text-white rounded px-1 focus:ring-2 focus:ring-purple-400/50 focus:border-purple-400/50"
|
||||
placeholder="0"
|
||||
/>
|
||||
updateConsumableQuantity(
|
||||
product.id,
|
||||
clampedValue
|
||||
);
|
||||
}}
|
||||
onBlur={(e) => {
|
||||
// При потере фокуса, если поле пустое, устанавливаем 0
|
||||
if (e.target.value === "") {
|
||||
updateConsumableQuantity(
|
||||
product.id,
|
||||
0
|
||||
);
|
||||
}
|
||||
}}
|
||||
className="w-16 h-7 text-center text-sm bg-white/10 border-white/20 text-white rounded px-1 focus:ring-2 focus:ring-purple-400/50 focus:border-purple-400/50"
|
||||
placeholder="0"
|
||||
/>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
updateConsumableQuantity(
|
||||
product.id,
|
||||
Math.min(selectedQuantity + 1, availableStock, 99999)
|
||||
Math.min(
|
||||
selectedQuantity + 1,
|
||||
availableStock,
|
||||
99999
|
||||
)
|
||||
)
|
||||
}
|
||||
className={`h-6 w-6 p-0 rounded-full transition-all duration-300 ${
|
||||
selectedQuantity >= availableStock || availableStock <= 0
|
||||
? 'text-white/30 cursor-not-allowed'
|
||||
: 'text-white/60 hover:text-white hover:bg-white/20'
|
||||
selectedQuantity >=
|
||||
availableStock ||
|
||||
availableStock <= 0
|
||||
? "text-white/30 cursor-not-allowed"
|
||||
: "text-white/60 hover:text-white hover:bg-white/20"
|
||||
}`}
|
||||
disabled={selectedQuantity >= availableStock || availableStock <= 0}
|
||||
disabled={
|
||||
selectedQuantity >=
|
||||
availableStock ||
|
||||
availableStock <= 0
|
||||
}
|
||||
title={
|
||||
availableStock <= 0
|
||||
? 'Товар отсутствует на складе'
|
||||
: selectedQuantity >= availableStock
|
||||
? `Максимум доступно: ${availableStock}`
|
||||
: 'Увеличить количество'
|
||||
? "Товар отсутствует на складе"
|
||||
: selectedQuantity >=
|
||||
availableStock
|
||||
? `Максимум доступно: ${availableStock}`
|
||||
: "Увеличить количество"
|
||||
}
|
||||
>
|
||||
<Plus className="h-3 w-3" />
|
||||
@ -903,7 +977,9 @@ export function CreateFulfillmentConsumablesSupplyPage() {
|
||||
value={selectedLogistics?.id || ""}
|
||||
onChange={(e) => {
|
||||
const logisticsId = e.target.value;
|
||||
const logistics = logisticsPartners.find(p => p.id === logisticsId);
|
||||
const logistics = logisticsPartners.find(
|
||||
(p) => p.id === logisticsId
|
||||
);
|
||||
setSelectedLogistics(logistics || null);
|
||||
}}
|
||||
className="w-full bg-white/10 border border-white/20 rounded-md px-3 py-2 text-white text-sm focus:outline-none focus:ring-1 focus:ring-purple-500 focus:border-transparent appearance-none"
|
||||
@ -922,8 +998,18 @@ export function CreateFulfillmentConsumablesSupplyPage() {
|
||||
))}
|
||||
</select>
|
||||
<div className="absolute inset-y-0 right-0 flex items-center px-2 pointer-events-none">
|
||||
<svg className="w-4 h-4 text-white/60" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
||||
<svg
|
||||
className="w-4 h-4 text-white/60"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M19 9l-7 7-7-7"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -1,17 +1,29 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import React, { useState } from "react";
|
||||
import { useQuery } from "@apollo/client";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { Sidebar } from "@/components/dashboard/sidebar";
|
||||
import { useSidebar } from "@/hooks/useSidebar";
|
||||
import { GET_PENDING_SUPPLIES_COUNT } from "@/graphql/queries";
|
||||
import { Building2, ShoppingCart } from "lucide-react";
|
||||
import {
|
||||
Building2,
|
||||
ShoppingCart,
|
||||
Package,
|
||||
Wrench,
|
||||
RotateCcw,
|
||||
Clock,
|
||||
FileText,
|
||||
CheckCircle,
|
||||
} from "lucide-react";
|
||||
|
||||
// Импорты компонентов подразделов
|
||||
import { FulfillmentSuppliesTab } from "./fulfillment-supplies/fulfillment-supplies-tab";
|
||||
import { MarketplaceSuppliesTab } from "./marketplace-supplies/marketplace-supplies-tab";
|
||||
import { FulfillmentDetailedSuppliesTab } from "./fulfillment-supplies/fulfillment-detailed-supplies-tab";
|
||||
import { FulfillmentConsumablesOrdersTab } from "./fulfillment-supplies/fulfillment-consumables-orders-tab";
|
||||
import { PvzReturnsTab } from "./fulfillment-supplies/pvz-returns-tab";
|
||||
|
||||
// Компонент для отображения бейджа с уведомлениями
|
||||
function NotificationBadge({ count }: { count: number }) {
|
||||
@ -27,72 +39,390 @@ function NotificationBadge({ count }: { count: number }) {
|
||||
export function FulfillmentSuppliesDashboard() {
|
||||
const { getSidebarMargin } = useSidebar();
|
||||
const [activeTab, setActiveTab] = useState("fulfillment");
|
||||
const [activeSubTab, setActiveSubTab] = useState("goods"); // товар
|
||||
const [activeThirdTab, setActiveThirdTab] = useState("new"); // новые
|
||||
|
||||
// Загружаем данные о непринятых поставках
|
||||
const { data: pendingData } = useQuery(GET_PENDING_SUPPLIES_COUNT, {
|
||||
pollInterval: 30000, // Обновляем каждые 30 секунд
|
||||
fetchPolicy: "cache-first",
|
||||
errorPolicy: "ignore",
|
||||
});
|
||||
const { data: pendingData, error: pendingError } = useQuery(
|
||||
GET_PENDING_SUPPLIES_COUNT,
|
||||
{
|
||||
pollInterval: 30000, // Обновляем каждые 30 секунд
|
||||
fetchPolicy: "cache-first",
|
||||
errorPolicy: "ignore",
|
||||
onError: (error) => {
|
||||
console.error("❌ GET_PENDING_SUPPLIES_COUNT Error:", error);
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
const pendingCount = pendingData?.pendingSuppliesCount?.total || 0;
|
||||
// Логируем ошибку для диагностики
|
||||
React.useEffect(() => {
|
||||
if (pendingError) {
|
||||
console.error("🚨 Ошибка загрузки счетчиков поставок:", pendingError);
|
||||
}
|
||||
}, [pendingError]);
|
||||
|
||||
// ✅ ПРАВИЛЬНО: Для фулфилмента считаем только поставки, НЕ заявки на партнерство
|
||||
const pendingCount = pendingData?.pendingSuppliesCount?.supplyOrders || 0;
|
||||
const ourSupplyOrdersCount =
|
||||
pendingData?.pendingSuppliesCount?.ourSupplyOrders || 0;
|
||||
const sellerSupplyOrdersCount =
|
||||
pendingData?.pendingSuppliesCount?.sellerSupplyOrders || 0;
|
||||
|
||||
return (
|
||||
<div className="h-screen flex overflow-hidden">
|
||||
<Sidebar />
|
||||
<main
|
||||
className={`flex-1 ${getSidebarMargin()} px-2 xl:px-4 py-2 xl:py-3 overflow-hidden transition-all duration-300`}
|
||||
className={`flex-1 ${getSidebarMargin()} px-6 py-4 overflow-hidden transition-all duration-300`}
|
||||
>
|
||||
<div className="h-full w-full flex flex-col">
|
||||
{/* Основной контент с табами */}
|
||||
<div className="flex-1 overflow-hidden">
|
||||
<Tabs
|
||||
value={activeTab}
|
||||
onValueChange={setActiveTab}
|
||||
className="h-full flex flex-col"
|
||||
>
|
||||
<TabsList className="grid w-full grid-cols-2 bg-white/5 backdrop-blur border-white/10 flex-shrink-0 h-8 xl:h-10">
|
||||
<TabsTrigger
|
||||
value="fulfillment"
|
||||
className="data-[state=active]:bg-white/20 data-[state=active]:text-white text-white/70 flex items-center gap-1 text-xs xl:text-sm relative"
|
||||
<div className="h-full w-full flex flex-col space-y-4">
|
||||
{/* БЛОК 1: ТАБЫ ВСЕХ УРОВНЕЙ */}
|
||||
<div className="bg-white/10 backdrop-blur-xl border border-white/20 rounded-2xl p-6">
|
||||
{/* УРОВЕНЬ 1: Главные табы */}
|
||||
<div className="mb-4">
|
||||
<div className="grid w-full grid-cols-2 bg-white/15 backdrop-blur border-white/30 rounded-xl h-11 p-2">
|
||||
<button
|
||||
onClick={() => setActiveTab("fulfillment")}
|
||||
className={`flex items-center gap-2 text-sm font-semibold transition-all duration-200 rounded-lg px-3 ${
|
||||
activeTab === "fulfillment"
|
||||
? "bg-gradient-to-r from-purple-500/40 to-pink-500/40 text-white shadow-lg"
|
||||
: "text-white/80 hover:text-white"
|
||||
}`}
|
||||
>
|
||||
<Building2 className="h-3 w-3" />
|
||||
<Building2 className="h-4 w-4" />
|
||||
<span className="hidden sm:inline">
|
||||
Поставки на фулфилмент
|
||||
</span>
|
||||
<span className="sm:hidden">Фулфилмент</span>
|
||||
<NotificationBadge count={pendingCount} />
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="marketplace"
|
||||
className="data-[state=active]:bg-white/20 data-[state=active]:text-white text-white/70 flex items-center gap-1 text-xs xl:text-sm"
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab("marketplace")}
|
||||
className={`flex items-center gap-2 text-sm font-semibold transition-all duration-200 rounded-lg px-3 ${
|
||||
activeTab === "marketplace"
|
||||
? "bg-gradient-to-r from-purple-500/40 to-pink-500/40 text-white shadow-lg"
|
||||
: "text-white/80 hover:text-white"
|
||||
}`}
|
||||
>
|
||||
<ShoppingCart className="h-3 w-3" />
|
||||
<ShoppingCart className="h-4 w-4" />
|
||||
<span className="hidden sm:inline">
|
||||
Поставки на маркетплейсы
|
||||
</span>
|
||||
<span className="sm:hidden">Маркетплейсы</span>
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<TabsContent
|
||||
value="fulfillment"
|
||||
className="flex-1 overflow-hidden mt-2 xl:mt-3"
|
||||
>
|
||||
<Card className="glass-card h-full overflow-hidden p-0">
|
||||
<FulfillmentSuppliesTab />
|
||||
</Card>
|
||||
</TabsContent>
|
||||
{/* УРОВЕНЬ 2: Подтабы */}
|
||||
{activeTab === "fulfillment" && (
|
||||
<div className="ml-4 mb-3">
|
||||
<div className="grid w-full grid-cols-4 bg-white/8 backdrop-blur border-white/20 h-9 rounded-lg p-1">
|
||||
<button
|
||||
onClick={() => setActiveSubTab("goods")}
|
||||
className={`flex items-center gap-1 text-xs font-medium transition-all duration-150 rounded-md px-2 ${
|
||||
activeSubTab === "goods"
|
||||
? "bg-white/15 text-white border-white/20"
|
||||
: "text-white/60 hover:text-white/80"
|
||||
}`}
|
||||
>
|
||||
<Package className="h-3 w-3" />
|
||||
<span className="hidden sm:inline">Товар</span>
|
||||
<span className="sm:hidden">Т</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveSubTab("detailed-supplies")}
|
||||
className={`flex items-center gap-1 text-xs font-medium transition-all duration-150 rounded-md px-2 relative ${
|
||||
activeSubTab === "detailed-supplies"
|
||||
? "bg-white/15 text-white border-white/20"
|
||||
: "text-white/60 hover:text-white/80"
|
||||
}`}
|
||||
>
|
||||
<Building2 className="h-3 w-3" />
|
||||
<span className="hidden md:inline">
|
||||
Расходники фулфилмента
|
||||
</span>
|
||||
<span className="md:hidden hidden sm:inline">
|
||||
Фулфилмент
|
||||
</span>
|
||||
<span className="sm:hidden">Ф</span>
|
||||
<NotificationBadge count={ourSupplyOrdersCount} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveSubTab("consumables")}
|
||||
className={`flex items-center gap-1 text-xs font-medium transition-all duration-150 rounded-md px-2 relative ${
|
||||
activeSubTab === "consumables"
|
||||
? "bg-white/15 text-white border-white/20"
|
||||
: "text-white/60 hover:text-white/80"
|
||||
}`}
|
||||
>
|
||||
<Wrench className="h-3 w-3" />
|
||||
<span className="hidden md:inline">
|
||||
Расходники селлеров
|
||||
</span>
|
||||
<span className="md:hidden hidden sm:inline">Селлеры</span>
|
||||
<span className="sm:hidden">С</span>
|
||||
<NotificationBadge count={sellerSupplyOrdersCount} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveSubTab("returns")}
|
||||
className={`flex items-center gap-1 text-xs font-medium transition-all duration-150 rounded-md px-2 ${
|
||||
activeSubTab === "returns"
|
||||
? "bg-white/15 text-white border-white/20"
|
||||
: "text-white/60 hover:text-white/80"
|
||||
}`}
|
||||
>
|
||||
<RotateCcw className="h-3 w-3" />
|
||||
<span className="hidden sm:inline">Возвраты с ПВЗ</span>
|
||||
<span className="sm:hidden">В</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<TabsContent
|
||||
value="marketplace"
|
||||
className="flex-1 overflow-hidden mt-2 xl:mt-3"
|
||||
>
|
||||
<Card className="glass-card h-full overflow-hidden p-0">
|
||||
<MarketplaceSuppliesTab />
|
||||
</Card>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
{/* УРОВЕНЬ 3: Подподтабы */}
|
||||
{activeTab === "fulfillment" && activeSubTab === "goods" && (
|
||||
<div className="ml-8">
|
||||
<div className="grid w-full grid-cols-3 bg-white/5 backdrop-blur border-white/15 h-8 rounded-md p-1">
|
||||
<button
|
||||
onClick={() => setActiveThirdTab("new")}
|
||||
className={`flex items-center gap-1 text-xs font-normal transition-all duration-150 rounded-sm px-2 ${
|
||||
activeThirdTab === "new"
|
||||
? "bg-white/10 text-white"
|
||||
: "text-white/50 hover:text-white/70"
|
||||
}`}
|
||||
>
|
||||
<Clock className="h-2.5 w-2.5" />
|
||||
<span className="hidden sm:inline">Новые</span>
|
||||
<span className="sm:hidden">Н</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveThirdTab("receiving")}
|
||||
className={`flex items-center gap-1 text-xs font-normal transition-all duration-150 rounded-sm px-2 ${
|
||||
activeThirdTab === "receiving"
|
||||
? "bg-white/10 text-white"
|
||||
: "text-white/50 hover:text-white/70"
|
||||
}`}
|
||||
>
|
||||
<FileText className="h-2.5 w-2.5" />
|
||||
<span className="hidden sm:inline">Приёмка</span>
|
||||
<span className="sm:hidden">П</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveThirdTab("received")}
|
||||
className={`flex items-center gap-1 text-xs font-normal transition-all duration-150 rounded-sm px-2 ${
|
||||
activeThirdTab === "received"
|
||||
? "bg-white/10 text-white"
|
||||
: "text-white/50 hover:text-white/70"
|
||||
}`}
|
||||
>
|
||||
<CheckCircle className="h-2.5 w-2.5" />
|
||||
<span className="hidden sm:inline">Принято</span>
|
||||
<span className="sm:hidden">Пр</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* БЛОК 2: МОДУЛИ СТАТИСТИКИ */}
|
||||
<div className="bg-white/10 backdrop-blur-xl border border-white/20 rounded-2xl p-6">
|
||||
<h3 className="text-white font-semibold mb-4">Статистика</h3>
|
||||
|
||||
{/* Статистика для расходников фулфилмента */}
|
||||
{activeTab === "fulfillment" &&
|
||||
activeSubTab === "detailed-supplies" && (
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<div className="bg-white/5 backdrop-blur rounded-lg p-4">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Building2 className="h-5 w-5 text-blue-400" />
|
||||
<div>
|
||||
<p className="text-xs text-white/60">Наши заказы</p>
|
||||
<p className="text-lg font-semibold text-white">
|
||||
{ourSupplyOrdersCount}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-white/5 backdrop-blur rounded-lg p-4">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Package className="h-5 w-5 text-green-400" />
|
||||
<div>
|
||||
<p className="text-xs text-white/60">Всего позиций</p>
|
||||
<p className="text-lg font-semibold text-white">-</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-white/5 backdrop-blur rounded-lg p-4">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Wrench className="h-5 w-5 text-purple-400" />
|
||||
<div>
|
||||
<p className="text-xs text-white/60">На складе</p>
|
||||
<p className="text-lg font-semibold text-white">-</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-white/5 backdrop-blur rounded-lg p-4">
|
||||
<div className="flex items-center space-x-2">
|
||||
<CheckCircle className="h-5 w-5 text-emerald-400" />
|
||||
<div>
|
||||
<p className="text-xs text-white/60">Доставлено</p>
|
||||
<p className="text-lg font-semibold text-white">-</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Статистика для расходников селлеров */}
|
||||
{activeTab === "fulfillment" && activeSubTab === "consumables" && (
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<div className="bg-white/5 backdrop-blur rounded-lg p-4">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Wrench className="h-5 w-5 text-orange-400" />
|
||||
<div>
|
||||
<p className="text-xs text-white/60">От селлеров</p>
|
||||
<p className="text-lg font-semibold text-white">
|
||||
{sellerSupplyOrdersCount}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-white/5 backdrop-blur rounded-lg p-4">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Clock className="h-5 w-5 text-yellow-400" />
|
||||
<div>
|
||||
<p className="text-xs text-white/60">В обработке</p>
|
||||
<p className="text-lg font-semibold text-white">-</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-white/5 backdrop-blur rounded-lg p-4">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Package className="h-5 w-5 text-blue-400" />
|
||||
<div>
|
||||
<p className="text-xs text-white/60">Принято</p>
|
||||
<p className="text-lg font-semibold text-white">-</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-white/5 backdrop-blur rounded-lg p-4">
|
||||
<div className="flex items-center space-x-2">
|
||||
<CheckCircle className="h-5 w-5 text-green-400" />
|
||||
<div>
|
||||
<p className="text-xs text-white/60">Использовано</p>
|
||||
<p className="text-lg font-semibold text-white">-</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Статистика для товаров */}
|
||||
{activeTab === "fulfillment" && activeSubTab === "goods" && (
|
||||
<div className="grid grid-cols-3 md:grid-cols-6 gap-4">
|
||||
<div className="bg-white/5 backdrop-blur rounded-lg p-4">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Clock className="h-5 w-5 text-blue-400" />
|
||||
<div>
|
||||
<p className="text-xs text-white/60">Новые</p>
|
||||
<p className="text-lg font-semibold text-white">-</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-white/5 backdrop-blur rounded-lg p-4">
|
||||
<div className="flex items-center space-x-2">
|
||||
<FileText className="h-5 w-5 text-yellow-400" />
|
||||
<div>
|
||||
<p className="text-xs text-white/60">Приёмка</p>
|
||||
<p className="text-lg font-semibold text-white">-</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-white/5 backdrop-blur rounded-lg p-4">
|
||||
<div className="flex items-center space-x-2">
|
||||
<CheckCircle className="h-5 w-5 text-green-400" />
|
||||
<div>
|
||||
<p className="text-xs text-white/60">Принято</p>
|
||||
<p className="text-lg font-semibold text-white">-</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Общая статистика для других разделов */}
|
||||
{activeTab === "fulfillment" && activeSubTab === "returns" && (
|
||||
<div className="text-white/70">Статистика возвратов с ПВЗ</div>
|
||||
)}
|
||||
|
||||
{activeTab === "marketplace" && (
|
||||
<div className="text-white/70">
|
||||
Статистика поставок на маркетплейсы
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* БЛОК 3: ОСНОВНОЙ КОНТЕНТ */}
|
||||
<div className="flex-1 overflow-hidden">
|
||||
<div className="bg-white/10 backdrop-blur-xl border border-white/20 rounded-2xl h-full overflow-hidden p-6">
|
||||
<div className="h-full">
|
||||
<h3 className="text-white font-semibold mb-4">
|
||||
Контент: {activeTab} → {activeSubTab} → {activeThirdTab}
|
||||
</h3>
|
||||
{/* КОНТЕНТ ДЛЯ ТОВАРОВ */}
|
||||
{activeTab === "fulfillment" &&
|
||||
activeSubTab === "goods" &&
|
||||
activeThirdTab === "new" && (
|
||||
<div className="text-white/80">
|
||||
Здесь отображаются НОВЫЕ поставки товаров на фулфилмент
|
||||
</div>
|
||||
)}
|
||||
{activeTab === "fulfillment" &&
|
||||
activeSubTab === "goods" &&
|
||||
activeThirdTab === "receiving" && (
|
||||
<div className="text-white/80">
|
||||
Здесь отображаются товары в ПРИЁМКЕ
|
||||
</div>
|
||||
)}
|
||||
{activeTab === "fulfillment" &&
|
||||
activeSubTab === "goods" &&
|
||||
activeThirdTab === "received" && (
|
||||
<div className="text-white/80">
|
||||
Здесь отображаются ПРИНЯТЫЕ товары
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* КОНТЕНТ ДЛЯ РАСХОДНИКОВ ФУЛФИЛМЕНТА */}
|
||||
{activeTab === "fulfillment" &&
|
||||
activeSubTab === "detailed-supplies" && (
|
||||
<div className="h-full overflow-hidden">
|
||||
<FulfillmentDetailedSuppliesTab />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* КОНТЕНТ ДЛЯ РАСХОДНИКОВ СЕЛЛЕРОВ */}
|
||||
{activeTab === "fulfillment" &&
|
||||
activeSubTab === "consumables" && (
|
||||
<div className="h-full overflow-hidden">
|
||||
<FulfillmentConsumablesOrdersTab />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* КОНТЕНТ ДЛЯ ВОЗВРАТОВ С ПВЗ */}
|
||||
{activeTab === "fulfillment" && activeSubTab === "returns" && (
|
||||
<div className="h-full overflow-hidden">
|
||||
<PvzReturnsTab />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* КОНТЕНТ ДЛЯ МАРКЕТПЛЕЙСОВ */}
|
||||
{activeTab === "marketplace" && (
|
||||
<div className="text-white/80">
|
||||
Содержимое поставок на маркетплейсы
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import React, { useState } from "react";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@ -12,8 +12,14 @@ import {
|
||||
GET_MY_SUPPLIES,
|
||||
GET_PENDING_SUPPLIES_COUNT,
|
||||
GET_WAREHOUSE_PRODUCTS,
|
||||
GET_MY_EMPLOYEES,
|
||||
GET_LOGISTICS_PARTNERS,
|
||||
} from "@/graphql/queries";
|
||||
import { UPDATE_SUPPLY_ORDER_STATUS } from "@/graphql/mutations";
|
||||
import {
|
||||
UPDATE_SUPPLY_ORDER_STATUS,
|
||||
ASSIGN_LOGISTICS_TO_SUPPLY,
|
||||
FULFILLMENT_RECEIVE_ORDER,
|
||||
} from "@/graphql/mutations";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
@ -34,16 +40,36 @@ import {
|
||||
Store,
|
||||
Bell,
|
||||
AlertTriangle,
|
||||
UserPlus,
|
||||
Settings,
|
||||
} from "lucide-react";
|
||||
|
||||
interface SupplyOrder {
|
||||
id: string;
|
||||
partnerId: string;
|
||||
deliveryDate: string;
|
||||
status: "PENDING" | "CONFIRMED" | "IN_TRANSIT" | "DELIVERED" | "CANCELLED";
|
||||
status:
|
||||
| "PENDING"
|
||||
| "SUPPLIER_APPROVED"
|
||||
| "CONFIRMED"
|
||||
| "LOGISTICS_CONFIRMED"
|
||||
| "SHIPPED"
|
||||
| "IN_TRANSIT"
|
||||
| "DELIVERED"
|
||||
| "CANCELLED";
|
||||
totalAmount: number;
|
||||
totalItems: number;
|
||||
createdAt: string;
|
||||
fulfillmentCenter?: {
|
||||
id: string;
|
||||
name: string;
|
||||
fullName: string;
|
||||
};
|
||||
organization?: {
|
||||
id: string;
|
||||
name: string;
|
||||
fullName: string;
|
||||
};
|
||||
partner: {
|
||||
id: string;
|
||||
inn: string;
|
||||
@ -83,31 +109,106 @@ interface SupplyOrder {
|
||||
|
||||
export function FulfillmentConsumablesOrdersTab() {
|
||||
const [expandedOrders, setExpandedOrders] = useState<Set<string>>(new Set());
|
||||
const [assigningOrders, setAssigningOrders] = useState<Set<string>>(
|
||||
new Set()
|
||||
);
|
||||
const [selectedLogistics, setSelectedLogistics] = useState<{
|
||||
[orderId: string]: string;
|
||||
}>({});
|
||||
const [selectedEmployees, setSelectedEmployees] = useState<{
|
||||
[orderId: string]: string;
|
||||
}>({});
|
||||
const { user } = useAuth();
|
||||
|
||||
// Запросы данных
|
||||
const {
|
||||
data: employeesData,
|
||||
loading: employeesLoading,
|
||||
error: employeesError,
|
||||
} = useQuery(GET_MY_EMPLOYEES);
|
||||
const {
|
||||
data: logisticsData,
|
||||
loading: logisticsLoading,
|
||||
error: logisticsError,
|
||||
} = useQuery(GET_LOGISTICS_PARTNERS);
|
||||
|
||||
// Отладочная информация
|
||||
console.log("DEBUG EMPLOYEES:", {
|
||||
loading: employeesLoading,
|
||||
error: employeesError?.message,
|
||||
errorDetails: employeesError,
|
||||
data: employeesData,
|
||||
employees: employeesData?.myEmployees,
|
||||
});
|
||||
console.log("DEBUG LOGISTICS:", {
|
||||
loading: logisticsLoading,
|
||||
error: logisticsError?.message,
|
||||
errorDetails: logisticsError,
|
||||
data: logisticsData,
|
||||
partners: logisticsData?.logisticsPartners,
|
||||
});
|
||||
|
||||
// Логируем ошибки отдельно
|
||||
if (employeesError) {
|
||||
console.error("EMPLOYEES ERROR:", employeesError);
|
||||
}
|
||||
if (logisticsError) {
|
||||
console.error("LOGISTICS ERROR:", logisticsError);
|
||||
}
|
||||
|
||||
// Загружаем заказы поставок
|
||||
const { data, loading, error, refetch } = useQuery(GET_SUPPLY_ORDERS);
|
||||
|
||||
// Мутация для обновления статуса заказа
|
||||
const [updateSupplyOrderStatus, { loading: updating }] = useMutation(
|
||||
UPDATE_SUPPLY_ORDER_STATUS,
|
||||
// Мутация для приемки поставки фулфилментом
|
||||
const [fulfillmentReceiveOrder, { loading: receiving }] = useMutation(
|
||||
FULFILLMENT_RECEIVE_ORDER,
|
||||
{
|
||||
onCompleted: (data) => {
|
||||
if (data.updateSupplyOrderStatus.success) {
|
||||
toast.success(data.updateSupplyOrderStatus.message);
|
||||
if (data.fulfillmentReceiveOrder.success) {
|
||||
toast.success(data.fulfillmentReceiveOrder.message);
|
||||
refetch(); // Обновляем список заказов
|
||||
} else {
|
||||
toast.error(data.updateSupplyOrderStatus.message);
|
||||
toast.error(data.fulfillmentReceiveOrder.message);
|
||||
}
|
||||
},
|
||||
refetchQueries: [
|
||||
{ query: GET_SUPPLY_ORDERS }, // Обновляем заказы поставок
|
||||
{ query: GET_MY_SUPPLIES }, // Обновляем склад фулфилмента (расходники фулфилмента)
|
||||
{ query: GET_WAREHOUSE_PRODUCTS }, // Обновляем товары склада
|
||||
{ query: GET_PENDING_SUPPLIES_COUNT }, // Обновляем счетчики уведомлений
|
||||
],
|
||||
onError: (error) => {
|
||||
console.error("Error updating supply order status:", error);
|
||||
toast.error("Ошибка при обновлении статуса заказа");
|
||||
console.error("Error receiving supply order:", error);
|
||||
toast.error("Ошибка при приеме заказа поставки");
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
// Мутация для назначения логистики и ответственного
|
||||
const [assignLogisticsToSupply, { loading: assigning }] = useMutation(
|
||||
ASSIGN_LOGISTICS_TO_SUPPLY,
|
||||
{
|
||||
onCompleted: (data) => {
|
||||
if (data.assignLogisticsToSupply.success) {
|
||||
toast.success("Логистика и ответственный назначены успешно");
|
||||
refetch(); // Обновляем список заказов
|
||||
// Сбрасываем состояние назначения
|
||||
setAssigningOrders((prev) => {
|
||||
const newSet = new Set(prev);
|
||||
newSet.delete(data.assignLogisticsToSupply.supplyOrder.id);
|
||||
return newSet;
|
||||
});
|
||||
} else {
|
||||
toast.error(
|
||||
data.assignLogisticsToSupply.message ||
|
||||
"Ошибка при назначении логистики"
|
||||
);
|
||||
}
|
||||
},
|
||||
refetchQueries: [{ query: GET_SUPPLY_ORDERS }],
|
||||
onError: (error) => {
|
||||
console.error("Error assigning logistics:", error);
|
||||
toast.error("Ошибка при назначении логистики");
|
||||
},
|
||||
}
|
||||
);
|
||||
@ -144,6 +245,19 @@ export function FulfillmentConsumablesOrdersTab() {
|
||||
number: fulfillmentOrders.length - index, // Обратный порядок для новых заказов сверху
|
||||
}));
|
||||
|
||||
// Автоматически открываем режим назначения для заказов, которые требуют назначения логистики
|
||||
React.useEffect(() => {
|
||||
fulfillmentOrders.forEach((order) => {
|
||||
if (canAssignLogistics(order) && !assigningOrders.has(order.id)) {
|
||||
setAssigningOrders((prev) => {
|
||||
const newSet = new Set(prev);
|
||||
newSet.add(order.id);
|
||||
return newSet;
|
||||
});
|
||||
}
|
||||
});
|
||||
}, [fulfillmentOrders]);
|
||||
|
||||
const getStatusBadge = (status: SupplyOrder["status"]) => {
|
||||
const statusMap = {
|
||||
PENDING: {
|
||||
@ -151,11 +265,26 @@ export function FulfillmentConsumablesOrdersTab() {
|
||||
color: "bg-blue-500/20 text-blue-300 border-blue-500/30",
|
||||
icon: Clock,
|
||||
},
|
||||
CONFIRMED: {
|
||||
label: "Подтверждена",
|
||||
SUPPLIER_APPROVED: {
|
||||
label: "Одобрено",
|
||||
color: "bg-green-500/20 text-green-300 border-green-500/30",
|
||||
icon: CheckCircle,
|
||||
},
|
||||
CONFIRMED: {
|
||||
label: "Подтверждена",
|
||||
color: "bg-emerald-500/20 text-emerald-300 border-emerald-500/30",
|
||||
icon: CheckCircle,
|
||||
},
|
||||
LOGISTICS_CONFIRMED: {
|
||||
label: "Логистика OK",
|
||||
color: "bg-cyan-500/20 text-cyan-300 border-cyan-500/30",
|
||||
icon: Truck,
|
||||
},
|
||||
SHIPPED: {
|
||||
label: "Отгружено",
|
||||
color: "bg-orange-500/20 text-orange-300 border-orange-500/30",
|
||||
icon: Package,
|
||||
},
|
||||
IN_TRANSIT: {
|
||||
label: "В пути",
|
||||
color: "bg-yellow-500/20 text-yellow-300 border-yellow-500/30",
|
||||
@ -181,24 +310,69 @@ export function FulfillmentConsumablesOrdersTab() {
|
||||
);
|
||||
};
|
||||
|
||||
const handleStatusUpdate = async (
|
||||
orderId: string,
|
||||
newStatus: SupplyOrder["status"]
|
||||
) => {
|
||||
// Функция для приема заказа фулфилментом
|
||||
const handleReceiveOrder = async (orderId: string) => {
|
||||
try {
|
||||
await updateSupplyOrderStatus({
|
||||
variables: {
|
||||
id: orderId,
|
||||
status: newStatus,
|
||||
},
|
||||
await fulfillmentReceiveOrder({
|
||||
variables: { id: orderId },
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Error updating status:", error);
|
||||
console.error("Error receiving order:", error);
|
||||
}
|
||||
};
|
||||
|
||||
const canMarkAsDelivered = (status: SupplyOrder["status"]) => {
|
||||
return status === "IN_TRANSIT";
|
||||
// Проверяем, можно ли принять заказ (для фулфилмента)
|
||||
const canReceiveOrder = (status: SupplyOrder["status"]) => {
|
||||
return status === "SHIPPED";
|
||||
};
|
||||
|
||||
const toggleAssignmentMode = (orderId: string) => {
|
||||
setAssigningOrders((prev) => {
|
||||
const newSet = new Set(prev);
|
||||
if (newSet.has(orderId)) {
|
||||
newSet.delete(orderId);
|
||||
} else {
|
||||
newSet.add(orderId);
|
||||
}
|
||||
return newSet;
|
||||
});
|
||||
};
|
||||
|
||||
const handleAssignLogistics = async (orderId: string) => {
|
||||
const logisticsId = selectedLogistics[orderId];
|
||||
const employeeId = selectedEmployees[orderId];
|
||||
|
||||
if (!logisticsId) {
|
||||
toast.error("Выберите логистическую компанию");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!employeeId) {
|
||||
toast.error("Выберите ответственного сотрудника");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await assignLogisticsToSupply({
|
||||
variables: {
|
||||
supplyOrderId: orderId,
|
||||
logisticsPartnerId: logisticsId,
|
||||
responsibleId: employeeId,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Error assigning logistics:", error);
|
||||
}
|
||||
};
|
||||
|
||||
const canAssignLogistics = (order: SupplyOrder) => {
|
||||
// Можем назначать логистику если:
|
||||
// 1. Статус SUPPLIER_APPROVED (одобрено поставщиком) или CONFIRMED (подтвержден фулфилментом)
|
||||
// 2. Логистика еще не назначена
|
||||
return (
|
||||
(order.status === "SUPPLIER_APPROVED" || order.status === "CONFIRMED") &&
|
||||
!order.logisticsPartner
|
||||
);
|
||||
};
|
||||
|
||||
const formatDate = (dateString: string) => {
|
||||
@ -247,15 +421,15 @@ export function FulfillmentConsumablesOrdersTab() {
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-2">
|
||||
<Card className="bg-white/10 backdrop-blur border-white/20 p-2">
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="p-1 bg-blue-500/20 rounded">
|
||||
<Clock className="h-3 w-3 text-blue-400" />
|
||||
<div className="p-1 bg-green-500/20 rounded">
|
||||
<CheckCircle className="h-3 w-3 text-green-400" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-white/60 text-xs">Ожидание</p>
|
||||
<p className="text-white/60 text-xs">Одобрено</p>
|
||||
<p className="text-sm font-bold text-white">
|
||||
{
|
||||
fulfillmentOrders.filter(
|
||||
(order) => order.status === "PENDING"
|
||||
(order) => order.status === "SUPPLIER_APPROVED"
|
||||
).length
|
||||
}
|
||||
</p>
|
||||
@ -265,8 +439,8 @@ export function FulfillmentConsumablesOrdersTab() {
|
||||
|
||||
<Card className="bg-white/10 backdrop-blur border-white/20 p-2">
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="p-1 bg-green-500/20 rounded">
|
||||
<CheckCircle className="h-3 w-3 text-green-400" />
|
||||
<div className="p-1 bg-emerald-500/20 rounded">
|
||||
<CheckCircle className="h-3 w-3 text-emerald-400" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-white/60 text-xs">Подтверждено</p>
|
||||
@ -336,8 +510,18 @@ export function FulfillmentConsumablesOrdersTab() {
|
||||
ordersWithNumbers.map((order) => (
|
||||
<Card
|
||||
key={order.id}
|
||||
className="bg-white/10 backdrop-blur border-white/20 overflow-hidden hover:bg-white/15 transition-colors cursor-pointer"
|
||||
onClick={() => toggleOrderExpansion(order.id)}
|
||||
className={`bg-white/10 backdrop-blur border-white/20 overflow-hidden hover:bg-white/15 transition-colors ${
|
||||
canAssignLogistics(order) && assigningOrders.has(order.id)
|
||||
? "cursor-default"
|
||||
: "cursor-pointer"
|
||||
}`}
|
||||
onClick={() => {
|
||||
if (
|
||||
!(canAssignLogistics(order) && assigningOrders.has(order.id))
|
||||
) {
|
||||
toggleOrderExpansion(order.id);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{/* Компактная основная информация */}
|
||||
<div className="px-3 py-2">
|
||||
@ -427,53 +611,20 @@ export function FulfillmentConsumablesOrdersTab() {
|
||||
|
||||
{/* Правая часть - статус и действия */}
|
||||
<div className="flex items-center space-x-2 flex-shrink-0">
|
||||
<Badge
|
||||
className={`${
|
||||
order.status === "PENDING"
|
||||
? "bg-blue-500/20 text-blue-300 border-blue-500/30"
|
||||
: order.status === "CONFIRMED"
|
||||
? "bg-green-500/20 text-green-300 border-green-500/30"
|
||||
: order.status === "IN_TRANSIT"
|
||||
? "bg-yellow-500/20 text-yellow-300 border-yellow-500/30"
|
||||
: order.status === "DELIVERED"
|
||||
? "bg-purple-500/20 text-purple-300 border-purple-500/30"
|
||||
: "bg-red-500/20 text-red-300 border-red-500/30"
|
||||
} border flex items-center gap-1 text-xs px-2 py-1`}
|
||||
>
|
||||
{order.status === "PENDING" && (
|
||||
<Clock className="h-3 w-3" />
|
||||
)}
|
||||
{order.status === "CONFIRMED" && (
|
||||
<CheckCircle className="h-3 w-3" />
|
||||
)}
|
||||
{order.status === "IN_TRANSIT" && (
|
||||
<Truck className="h-3 w-3" />
|
||||
)}
|
||||
{order.status === "DELIVERED" && (
|
||||
<Package className="h-3 w-3" />
|
||||
)}
|
||||
{order.status === "CANCELLED" && (
|
||||
<XCircle className="h-3 w-3" />
|
||||
)}
|
||||
{order.status === "PENDING" && "Ожидание"}
|
||||
{order.status === "CONFIRMED" && "Подтверждена"}
|
||||
{order.status === "IN_TRANSIT" && "В пути"}
|
||||
{order.status === "DELIVERED" && "Доставлена"}
|
||||
{order.status === "CANCELLED" && "Отменена"}
|
||||
</Badge>
|
||||
{getStatusBadge(order.status)}
|
||||
|
||||
{canMarkAsDelivered(order.status) && (
|
||||
{canReceiveOrder(order.status) && (
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleStatusUpdate(order.id, "DELIVERED");
|
||||
handleReceiveOrder(order.id);
|
||||
}}
|
||||
disabled={updating}
|
||||
disabled={receiving}
|
||||
className="bg-green-500/20 hover:bg-green-500/30 text-green-300 border border-green-500/30 text-xs px-2 py-1 h-7"
|
||||
>
|
||||
<CheckCircle className="h-3 w-3 mr-1" />
|
||||
Получено
|
||||
Принять
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
@ -528,6 +679,100 @@ export function FulfillmentConsumablesOrdersTab() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Назначение логистики и ответственного в одной строке */}
|
||||
{assigningOrders.has(order.id) && canAssignLogistics(order) && (
|
||||
<div className="mt-2 p-2 bg-blue-500/10 border border-blue-500/20 rounded">
|
||||
<div className="flex items-center gap-3">
|
||||
{/* Иконка и заголовок */}
|
||||
<div className="flex items-center text-blue-300 text-xs font-medium whitespace-nowrap">
|
||||
<Settings className="h-3 w-3 mr-1" />
|
||||
Назначить:
|
||||
</div>
|
||||
|
||||
{/* Выбор логистики */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<select
|
||||
value={selectedLogistics[order.id] || ""}
|
||||
onChange={(e) => {
|
||||
setSelectedLogistics((prev) => ({
|
||||
...prev,
|
||||
[order.id]: e.target.value,
|
||||
}));
|
||||
}}
|
||||
className="w-full bg-white/10 border border-white/20 text-white text-xs rounded px-2 py-1 focus:ring-2 focus:ring-blue-400/50 focus:border-blue-400/50 appearance-none"
|
||||
>
|
||||
<option value="" className="bg-gray-800 text-white">
|
||||
{logisticsData?.logisticsPartners?.length > 0
|
||||
? "Выберите логистику"
|
||||
: "Нет логистики"}
|
||||
</option>
|
||||
{logisticsData?.logisticsPartners?.map(
|
||||
(logistics: any) => (
|
||||
<option
|
||||
key={logistics.id}
|
||||
value={logistics.id}
|
||||
className="bg-gray-800 text-white"
|
||||
>
|
||||
{logistics.name || logistics.fullName}
|
||||
</option>
|
||||
)
|
||||
) || []}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Выбор ответственного */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<select
|
||||
value={selectedEmployees[order.id] || ""}
|
||||
onChange={(e) => {
|
||||
setSelectedEmployees((prev) => ({
|
||||
...prev,
|
||||
[order.id]: e.target.value,
|
||||
}));
|
||||
}}
|
||||
className="w-full bg-white/10 border border-white/20 text-white text-xs rounded px-2 py-1 focus:ring-2 focus:ring-blue-400/50 focus:border-blue-400/50 appearance-none"
|
||||
>
|
||||
<option value="" className="bg-gray-800 text-white">
|
||||
{employeesData?.myEmployees?.length > 0
|
||||
? "Выберите ответственного"
|
||||
: "Нет сотрудников"}
|
||||
</option>
|
||||
{employeesData?.myEmployees?.map((employee: any) => (
|
||||
<option
|
||||
key={employee.id}
|
||||
value={employee.id}
|
||||
className="bg-gray-800 text-white"
|
||||
>
|
||||
{employee.fullName || employee.name}
|
||||
</option>
|
||||
)) || []}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Кнопки действий */}
|
||||
<div className="flex gap-1 flex-shrink-0">
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => handleAssignLogistics(order.id)}
|
||||
disabled={assigning}
|
||||
className="bg-green-500/20 hover:bg-green-500/30 text-green-300 border border-green-500/30 text-xs px-2 py-1 h-6"
|
||||
>
|
||||
<UserPlus className="h-3 w-3 mr-1" />
|
||||
Принять
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => toggleAssignmentMode(order.id)}
|
||||
className="border-white/20 text-white/60 hover:bg-white/10 text-xs px-2 py-1 h-6"
|
||||
>
|
||||
✕
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Развернутые детали заказа */}
|
||||
{expandedOrders.has(order.id) && (
|
||||
<>
|
||||
|
@ -33,8 +33,6 @@ import {
|
||||
CheckCircle,
|
||||
} from "lucide-react";
|
||||
|
||||
|
||||
|
||||
// Интерфейс для заказа
|
||||
interface SupplyOrder {
|
||||
id: string;
|
||||
@ -174,10 +172,14 @@ export function FulfillmentDetailedSuppliesTab() {
|
||||
// "Расходники фулфилмента" = расходники, которые МЫ (фулфилмент-центр) заказали для себя
|
||||
// Критерии: создатель = мы И получатель = мы (ОБА условия)
|
||||
const ourSupplyOrders: SupplyOrder[] = (data?.supplyOrders || []).filter(
|
||||
(order: SupplyOrder) => {
|
||||
(order: any) => {
|
||||
// Защита от null/undefined значений
|
||||
return (
|
||||
order.organizationId === currentOrganizationId && // Создали мы
|
||||
order.fulfillmentCenterId === currentOrganizationId // Получатель - мы
|
||||
order?.organizationId === currentOrganizationId && // Создали мы
|
||||
order?.fulfillmentCenterId === currentOrganizationId && // Получатель - мы
|
||||
order?.organization && // Проверяем наличие organization
|
||||
order?.partner && // Проверяем наличие partner
|
||||
Array.isArray(order?.items) // Проверяем наличие items
|
||||
);
|
||||
}
|
||||
);
|
||||
@ -248,7 +250,9 @@ export function FulfillmentDetailedSuppliesTab() {
|
||||
{/* Заголовок с кнопкой создания поставки */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-xl font-bold text-white mb-1">Расходники фулфилмента</h2>
|
||||
<h2 className="text-xl font-bold text-white mb-1">
|
||||
Расходники фулфилмента
|
||||
</h2>
|
||||
<p className="text-white/60 text-sm">
|
||||
Поставки расходников, поступающие на склад фулфилмент-центра
|
||||
</p>
|
||||
|
@ -40,8 +40,6 @@ import {
|
||||
} from "lucide-react";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
|
||||
|
||||
|
||||
// Интерфейсы для данных
|
||||
interface Employee {
|
||||
id: string;
|
||||
@ -662,52 +660,50 @@ export function FulfillmentGoodsTab() {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col p-2 xl:p-4">
|
||||
<Tabs
|
||||
value={activeTab}
|
||||
onValueChange={setActiveTab}
|
||||
className="h-full flex flex-col"
|
||||
>
|
||||
{/* Вкладки товаров */}
|
||||
<TabsList className="grid w-full grid-cols-3 bg-white/10 backdrop-blur border-white/10 flex-shrink-0 h-8 xl:h-10 mb-2 xl:mb-4">
|
||||
<TabsTrigger
|
||||
value="new"
|
||||
className="data-[state=active]:bg-white/20 data-[state=active]:text-white text-white/70 flex items-center gap-1 xl:gap-2 text-xs xl:text-sm"
|
||||
>
|
||||
<Clock className="h-3 w-3 xl:h-4 xl:w-4" />
|
||||
<span className="hidden sm:inline">Новые</span>
|
||||
<span className="sm:hidden">Н</span>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="receiving"
|
||||
className="data-[state=active]:bg-white/20 data-[state=active]:text-white text-white/70 flex items-center gap-1 xl:gap-2 text-xs xl:text-sm"
|
||||
>
|
||||
<FileText className="h-3 w-3 xl:h-4 xl:w-4" />
|
||||
<span className="hidden sm:inline">Приёмка</span>
|
||||
<span className="sm:hidden">П</span>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="received"
|
||||
className="data-[state=active]:bg-white/20 data-[state=active]:text-white text-white/70 flex items-center gap-1 xl:gap-2 text-xs xl:text-sm"
|
||||
>
|
||||
<CheckCircle className="h-3 w-3 xl:h-4 xl:w-4" />
|
||||
<span className="hidden sm:inline">Принято</span>
|
||||
<span className="sm:hidden">Пр</span>
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
<div className="space-y-3">
|
||||
{/* УРОВЕНЬ 3: Подподтабы (маленький размер, больший отступ) */}
|
||||
<div className="ml-8">
|
||||
<Tabs value={activeTab} onValueChange={setActiveTab} className="w-full">
|
||||
<TabsList className="grid w-full grid-cols-3 bg-white/5 backdrop-blur border-white/15 h-8 rounded-md p-1 mb-3">
|
||||
<TabsTrigger
|
||||
value="new"
|
||||
className="data-[state=active]:bg-white/10 data-[state=active]:text-white text-white/50 hover:text-white/70 flex items-center gap-1 text-xs font-normal transition-all duration-150 rounded-sm"
|
||||
>
|
||||
<Clock className="h-2.5 w-2.5" />
|
||||
<span className="hidden sm:inline">Новые</span>
|
||||
<span className="sm:hidden">Н</span>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="receiving"
|
||||
className="data-[state=active]:bg-white/10 data-[state=active]:text-white text-white/50 hover:text-white/70 flex items-center gap-1 text-xs font-normal transition-all duration-150 rounded-sm"
|
||||
>
|
||||
<FileText className="h-2.5 w-2.5" />
|
||||
<span className="hidden sm:inline">Приёмка</span>
|
||||
<span className="sm:hidden">П</span>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="received"
|
||||
className="data-[state=active]:bg-white/10 data-[state=active]:text-white text-white/50 hover:text-white/70 flex items-center gap-1 text-xs font-normal transition-all duration-150 rounded-sm"
|
||||
>
|
||||
<CheckCircle className="h-2.5 w-2.5" />
|
||||
<span className="hidden sm:inline">Принято</span>
|
||||
<span className="sm:hidden">Пр</span>
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="new" className="flex-1 overflow-hidden">
|
||||
<TabContent tabName="new" />
|
||||
</TabsContent>
|
||||
<TabsContent value="new" className="space-y-0">
|
||||
<TabContent tabName="new" />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="receiving" className="flex-1 overflow-hidden">
|
||||
<TabContent tabName="receiving" />
|
||||
</TabsContent>
|
||||
<TabsContent value="receiving" className="space-y-0">
|
||||
<TabContent tabName="receiving" />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="received" className="flex-1 overflow-hidden">
|
||||
<TabContent tabName="received" />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
<TabsContent value="received" className="space-y-0">
|
||||
<TabContent tabName="received" />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
|
@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { useSearchParams, useRouter } from "next/navigation";
|
||||
import { useQuery } from "@apollo/client";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
@ -32,13 +32,33 @@ export function FulfillmentSuppliesTab() {
|
||||
const [activeTab, setActiveTab] = useState("goods");
|
||||
|
||||
// Загружаем данные о непринятых поставках
|
||||
const { data: pendingData } = useQuery(GET_PENDING_SUPPLIES_COUNT, {
|
||||
pollInterval: 30000, // Обновляем каждые 30 секунд
|
||||
fetchPolicy: "cache-first",
|
||||
errorPolicy: "ignore",
|
||||
});
|
||||
const { data: pendingData, error: pendingError } = useQuery(
|
||||
GET_PENDING_SUPPLIES_COUNT,
|
||||
{
|
||||
pollInterval: 30000, // Обновляем каждые 30 секунд
|
||||
fetchPolicy: "cache-first",
|
||||
errorPolicy: "ignore",
|
||||
onError: (error) => {
|
||||
console.error(
|
||||
"❌ GET_PENDING_SUPPLIES_COUNT Error in FulfillmentSuppliesTab:",
|
||||
error
|
||||
);
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
const pendingCount = pendingData?.pendingSuppliesCount?.total || 0;
|
||||
// Логируем ошибку для диагностики
|
||||
React.useEffect(() => {
|
||||
if (pendingError) {
|
||||
console.error(
|
||||
"🚨 Ошибка загрузки счетчиков в FulfillmentSuppliesTab:",
|
||||
pendingError
|
||||
);
|
||||
}
|
||||
}, [pendingError]);
|
||||
|
||||
// ✅ ПРАВИЛЬНО: Для фулфилмента считаем только поставки, НЕ заявки на партнерство
|
||||
const pendingCount = pendingData?.pendingSuppliesCount?.supplyOrders || 0;
|
||||
const ourSupplyOrdersCount =
|
||||
pendingData?.pendingSuppliesCount?.ourSupplyOrders || 0;
|
||||
const sellerSupplyOrdersCount =
|
||||
@ -66,74 +86,74 @@ export function FulfillmentSuppliesTab() {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col">
|
||||
<Tabs
|
||||
value={activeTab}
|
||||
onValueChange={handleTabChange}
|
||||
className="h-full flex flex-col"
|
||||
>
|
||||
<TabsList className="grid w-full grid-cols-4 bg-white/10 backdrop-blur border-white/10 flex-shrink-0 h-8 xl:h-10 mb-2 xl:mb-3 mx-2 xl:mx-4 mt-2 xl:mt-4">
|
||||
<TabsTrigger
|
||||
value="goods"
|
||||
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"
|
||||
>
|
||||
<Package className="h-2.5 w-2.5 xl:h-3 xl:w-3" />
|
||||
<span className="hidden sm:inline">Товар</span>
|
||||
<span className="sm:hidden">Т</span>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="detailed-supplies"
|
||||
className="data-[state=active]:bg-white/20 data-[state=active]:text-white text-white/70 flex items-center gap-1 text-[10px] xl:text-xs relative"
|
||||
>
|
||||
<Building2 className="h-2.5 w-2.5 xl:h-3 xl:w-3" />
|
||||
<span className="hidden md:inline">Расходники фулфилмента</span>
|
||||
<span className="md:hidden hidden sm:inline">Фулфилмент</span>
|
||||
<span className="sm:hidden">Ф</span>
|
||||
<NotificationBadge count={ourSupplyOrdersCount} />
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="consumables"
|
||||
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"
|
||||
>
|
||||
<Wrench className="h-2.5 w-2.5 xl:h-3 xl:w-3" />
|
||||
<span className="hidden md:inline">Расходники селлеров</span>
|
||||
<span className="md:hidden hidden sm:inline">Селлеры</span>
|
||||
<span className="sm:hidden">С</span>
|
||||
<NotificationBadge count={sellerSupplyOrdersCount} />
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="returns"
|
||||
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"
|
||||
>
|
||||
<RotateCcw className="h-2.5 w-2.5 xl:h-3 xl:w-3" />
|
||||
<span className="hidden sm:inline">Возвраты с ПВЗ</span>
|
||||
<span className="sm:hidden">В</span>
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="goods" className="flex-1 overflow-hidden">
|
||||
<FulfillmentGoodsTab />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent
|
||||
value="detailed-supplies"
|
||||
className="flex-1 overflow-hidden"
|
||||
<div className="space-y-3">
|
||||
{/* УРОВЕНЬ 2: Подтабы (средний размер, отступ показывает иерархию) */}
|
||||
<div className="ml-4">
|
||||
<Tabs
|
||||
value={activeTab}
|
||||
onValueChange={handleTabChange}
|
||||
className="w-full"
|
||||
>
|
||||
<div className="h-full p-2 xl:p-4 overflow-y-auto">
|
||||
<FulfillmentDetailedSuppliesTab />
|
||||
</div>
|
||||
</TabsContent>
|
||||
<TabsList className="grid w-full grid-cols-4 bg-white/8 backdrop-blur border-white/20 h-9 rounded-lg p-1 mb-3">
|
||||
<TabsTrigger
|
||||
value="goods"
|
||||
className="data-[state=active]:bg-white/15 data-[state=active]:text-white data-[state=active]:border-white/20 text-white/60 hover:text-white/80 flex items-center gap-1 text-xs font-medium transition-all duration-150 rounded-md"
|
||||
>
|
||||
<Package className="h-3 w-3" />
|
||||
<span className="hidden sm:inline">Товар</span>
|
||||
<span className="sm:hidden">Т</span>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="detailed-supplies"
|
||||
className="data-[state=active]:bg-white/15 data-[state=active]:text-white data-[state=active]:border-white/20 text-white/60 hover:text-white/80 flex items-center gap-1 text-xs font-medium transition-all duration-150 relative rounded-md"
|
||||
>
|
||||
<Building2 className="h-3 w-3" />
|
||||
<span className="hidden md:inline">Расходники фулфилмента</span>
|
||||
<span className="md:hidden hidden sm:inline">Фулфилмент</span>
|
||||
<span className="sm:hidden">Ф</span>
|
||||
<NotificationBadge count={ourSupplyOrdersCount} />
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="consumables"
|
||||
className="data-[state=active]:bg-white/15 data-[state=active]:text-white data-[state=active]:border-white/20 text-white/60 hover:text-white/80 flex items-center gap-1 text-xs font-medium transition-all duration-150 relative rounded-md"
|
||||
>
|
||||
<Wrench className="h-3 w-3" />
|
||||
<span className="hidden md:inline">Расходники селлеров</span>
|
||||
<span className="md:hidden hidden sm:inline">Селлеры</span>
|
||||
<span className="sm:hidden">С</span>
|
||||
<NotificationBadge count={sellerSupplyOrdersCount} />
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="returns"
|
||||
className="data-[state=active]:bg-white/15 data-[state=active]:text-white data-[state=active]:border-white/20 text-white/60 hover:text-white/80 flex items-center gap-1 text-xs font-medium transition-all duration-150 rounded-md"
|
||||
>
|
||||
<RotateCcw className="h-3 w-3" />
|
||||
<span className="hidden sm:inline">Возвраты с ПВЗ</span>
|
||||
<span className="sm:hidden">В</span>
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="consumables" className="flex-1 overflow-hidden">
|
||||
<div className="h-full p-2 xl:p-4 overflow-y-auto">
|
||||
<FulfillmentConsumablesOrdersTab />
|
||||
</div>
|
||||
</TabsContent>
|
||||
<TabsContent value="goods" className="space-y-0">
|
||||
<FulfillmentGoodsTab />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="returns" className="flex-1 overflow-hidden">
|
||||
<PvzReturnsTab />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
<TabsContent value="detailed-supplies" className="space-y-0">
|
||||
<div className="p-4">
|
||||
<FulfillmentDetailedSuppliesTab />
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="consumables" className="space-y-0">
|
||||
<div className="p-4">
|
||||
<FulfillmentConsumablesOrdersTab />
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="returns" className="space-y-0">
|
||||
<PvzReturnsTab />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
@ -7,6 +7,11 @@ import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Avatar, AvatarImage, AvatarFallback } from "@/components/ui/avatar";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover";
|
||||
import { Sidebar } from "@/components/dashboard/sidebar";
|
||||
import { useSidebar } from "@/hooks/useSidebar";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
@ -15,7 +20,8 @@ import {
|
||||
GET_MY_COUNTERPARTIES,
|
||||
GET_SUPPLY_ORDERS,
|
||||
GET_WAREHOUSE_PRODUCTS,
|
||||
GET_MY_SUPPLIES, // Расходники селлеров
|
||||
GET_MY_SUPPLIES, // Расходники селлеров (старые данные заказов)
|
||||
GET_SELLER_SUPPLIES_ON_WAREHOUSE, // Расходники селлеров на складе (новый API)
|
||||
GET_MY_FULFILLMENT_SUPPLIES, // Расходники фулфилмента
|
||||
GET_FULFILLMENT_WAREHOUSE_STATS, // Статистика склада с изменениями за сутки
|
||||
} from "@/graphql/queries";
|
||||
@ -55,6 +61,7 @@ interface ProductVariant {
|
||||
defectsQuantity: number;
|
||||
sellerSuppliesPlace?: string;
|
||||
sellerSuppliesQuantity: number;
|
||||
sellerSuppliesOwners?: string[]; // Владельцы расходников
|
||||
pvzReturnsPlace?: string;
|
||||
pvzReturnsQuantity: number;
|
||||
}
|
||||
@ -72,6 +79,7 @@ interface ProductItem {
|
||||
defectsQuantity: number;
|
||||
sellerSuppliesPlace?: string;
|
||||
sellerSuppliesQuantity: number;
|
||||
sellerSuppliesOwners?: string[]; // Владельцы расходников
|
||||
pvzReturnsPlace?: string;
|
||||
pvzReturnsQuantity: number;
|
||||
// Третий уровень - варианты товара
|
||||
@ -200,13 +208,13 @@ export function FulfillmentWarehouseDashboard() {
|
||||
fetchPolicy: "cache-and-network",
|
||||
});
|
||||
|
||||
// Загружаем расходники селлеров
|
||||
// Загружаем расходники селлеров на складе фулфилмента
|
||||
const {
|
||||
data: suppliesData,
|
||||
loading: suppliesLoading,
|
||||
error: suppliesError,
|
||||
refetch: refetchSupplies,
|
||||
} = useQuery(GET_MY_SUPPLIES, {
|
||||
data: sellerSuppliesData,
|
||||
loading: sellerSuppliesLoading,
|
||||
error: sellerSuppliesError,
|
||||
refetch: refetchSellerSupplies,
|
||||
} = useQuery(GET_SELLER_SUPPLIES_ON_WAREHOUSE, {
|
||||
fetchPolicy: "cache-and-network",
|
||||
});
|
||||
|
||||
@ -246,8 +254,10 @@ export function FulfillmentWarehouseDashboard() {
|
||||
goods: warehouseStatsData.fulfillmentWarehouseStats.goods,
|
||||
defects: warehouseStatsData.fulfillmentWarehouseStats.defects,
|
||||
pvzReturns: warehouseStatsData.fulfillmentWarehouseStats.pvzReturns,
|
||||
fulfillmentSupplies: warehouseStatsData.fulfillmentWarehouseStats.fulfillmentSupplies,
|
||||
sellerSupplies: warehouseStatsData.fulfillmentWarehouseStats.sellerSupplies,
|
||||
fulfillmentSupplies:
|
||||
warehouseStatsData.fulfillmentWarehouseStats.fulfillmentSupplies,
|
||||
sellerSupplies:
|
||||
warehouseStatsData.fulfillmentWarehouseStats.sellerSupplies,
|
||||
});
|
||||
}
|
||||
|
||||
@ -258,7 +268,7 @@ export function FulfillmentWarehouseDashboard() {
|
||||
);
|
||||
const supplyOrders: SupplyOrder[] = ordersData?.supplyOrders || [];
|
||||
const allProducts = productsData?.warehouseProducts || [];
|
||||
const mySupplies = suppliesData?.mySupplies || []; // Расходники селлеров
|
||||
const sellerSupplies = sellerSuppliesData?.sellerSuppliesOnWarehouse || []; // Расходники селлеров на складе
|
||||
const myFulfillmentSupplies =
|
||||
fulfillmentSuppliesData?.myFulfillmentSupplies || []; // Расходники фулфилмента
|
||||
|
||||
@ -276,8 +286,8 @@ export function FulfillmentWarehouseDashboard() {
|
||||
deliveredOrders: supplyOrders.filter((o) => o.status === "DELIVERED")
|
||||
.length,
|
||||
productsCount: allProducts.length,
|
||||
suppliesCount: mySupplies.length, // Добавляем логирование расходников
|
||||
supplies: mySupplies.map((s: any) => ({
|
||||
suppliesCount: sellerSupplies.length, // Добавляем логирование расходников
|
||||
supplies: sellerSupplies.map((s: any) => ({
|
||||
id: s.id,
|
||||
name: s.name,
|
||||
currentStock: s.currentStock,
|
||||
@ -293,7 +303,7 @@ export function FulfillmentWarehouseDashboard() {
|
||||
})),
|
||||
// Добавляем анализ соответствия товаров и расходников
|
||||
productSupplyMatching: allProducts.map((product: any) => {
|
||||
const matchingSupply = mySupplies.find((supply: any) => {
|
||||
const matchingSupply = sellerSupplies.find((supply: any) => {
|
||||
return (
|
||||
supply.name.toLowerCase() === product.name.toLowerCase() ||
|
||||
supply.name
|
||||
@ -311,11 +321,11 @@ export function FulfillmentWarehouseDashboard() {
|
||||
counterpartiesLoading,
|
||||
ordersLoading,
|
||||
productsLoading,
|
||||
suppliesLoading, // Добавляем статус загрузки расходников
|
||||
sellerSuppliesLoading, // Добавляем статус загрузки расходников селлеров
|
||||
counterpartiesError: counterpartiesError?.message,
|
||||
ordersError: ordersError?.message,
|
||||
productsError: productsError?.message,
|
||||
suppliesError: suppliesError?.message, // Добавляем ошибки загрузки расходников
|
||||
sellerSuppliesError: sellerSuppliesError?.message, // Добавляем ошибки загрузки расходников селлеров
|
||||
});
|
||||
|
||||
// Расчет поступлений расходников за сутки (выносим отдельно для использования в storeData)
|
||||
@ -408,7 +418,7 @@ export function FulfillmentWarehouseDashboard() {
|
||||
console.log("📊 Статистика расходников селлера:", {
|
||||
suppliesReceivedToday,
|
||||
suppliesUsedToday,
|
||||
totalSellerSupplies: mySupplies.reduce(
|
||||
totalSellerSupplies: sellerSupplies.reduce(
|
||||
(sum: number, supply: any) => sum + (supply.currentStock || 0),
|
||||
0
|
||||
),
|
||||
@ -418,7 +428,10 @@ export function FulfillmentWarehouseDashboard() {
|
||||
// Получаем статистику склада из GraphQL (с реальными изменениями за сутки)
|
||||
const warehouseStats: WarehouseStats = useMemo(() => {
|
||||
// Если данные еще загружаются, возвращаем нули
|
||||
if (warehouseStatsLoading || !warehouseStatsData?.fulfillmentWarehouseStats) {
|
||||
if (
|
||||
warehouseStatsLoading ||
|
||||
!warehouseStatsData?.fulfillmentWarehouseStats
|
||||
) {
|
||||
return {
|
||||
products: { current: 0, change: 0 },
|
||||
goods: { current: 0, change: 0 },
|
||||
@ -511,26 +524,64 @@ export function FulfillmentWarehouseDashboard() {
|
||||
}
|
||||
});
|
||||
|
||||
// Группируем расходники по названию
|
||||
const groupedSupplies = new Map<string, number>();
|
||||
mySupplies.forEach((supply: any) => {
|
||||
// ИСПРАВЛЕНО: Группируем расходники по СЕЛЛЕРУ-ВЛАДЕЛЬЦУ, а не по названию
|
||||
const suppliesByOwner = new Map<
|
||||
string,
|
||||
Map<string, { quantity: number; ownerName: string }>
|
||||
>();
|
||||
|
||||
sellerSupplies.forEach((supply: any) => {
|
||||
const ownerId = supply.sellerOwner?.id;
|
||||
const ownerName =
|
||||
supply.sellerOwner?.name ||
|
||||
supply.sellerOwner?.fullName ||
|
||||
"Неизвестный селлер";
|
||||
const supplyName = supply.name;
|
||||
const currentStock = supply.currentStock || 0;
|
||||
const supplyType = supply.type;
|
||||
|
||||
if (groupedSupplies.has(supplyName)) {
|
||||
groupedSupplies.set(
|
||||
supplyName,
|
||||
groupedSupplies.get(supplyName)! + currentStock
|
||||
// ИСПРАВЛЕНО: Строгая проверка согласно правилам
|
||||
if (!ownerId || supplyType !== "SELLER_CONSUMABLES") {
|
||||
console.warn(
|
||||
"⚠️ ОТФИЛЬТРОВАН расходник в компоненте (нарушение правил):",
|
||||
{
|
||||
id: supply.id,
|
||||
name: supplyName,
|
||||
type: supplyType,
|
||||
ownerId,
|
||||
ownerName,
|
||||
reason: !ownerId
|
||||
? "нет sellerOwner.id"
|
||||
: "тип не SELLER_CONSUMABLES",
|
||||
}
|
||||
);
|
||||
return; // Пропускаем согласно ПРАВИЛУ 6 из секции 11.6
|
||||
}
|
||||
|
||||
// Инициализируем группу для селлера, если её нет
|
||||
if (!suppliesByOwner.has(ownerId)) {
|
||||
suppliesByOwner.set(ownerId, new Map());
|
||||
}
|
||||
|
||||
const ownerSupplies = suppliesByOwner.get(ownerId)!;
|
||||
|
||||
if (ownerSupplies.has(supplyName)) {
|
||||
// Суммируем количество, если расходник уже есть у этого селлера
|
||||
const existing = ownerSupplies.get(supplyName)!;
|
||||
existing.quantity += currentStock;
|
||||
} else {
|
||||
groupedSupplies.set(supplyName, currentStock);
|
||||
// Добавляем новый расходник для этого селлера
|
||||
ownerSupplies.set(supplyName, {
|
||||
quantity: currentStock,
|
||||
ownerName: ownerName,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Логирование группировки
|
||||
console.log("📊 Группировка товаров и расходников:", {
|
||||
groupedProductsCount: groupedProducts.size,
|
||||
groupedSuppliesCount: groupedSupplies.size,
|
||||
suppliesByOwnerCount: suppliesByOwner.size,
|
||||
groupedProducts: Array.from(groupedProducts.entries()).map(
|
||||
([name, data]) => ({
|
||||
name,
|
||||
@ -539,10 +590,20 @@ export function FulfillmentWarehouseDashboard() {
|
||||
uniqueSuppliers: [...new Set(data.suppliers)],
|
||||
})
|
||||
),
|
||||
groupedSupplies: Array.from(groupedSupplies.entries()).map(
|
||||
([name, quantity]) => ({
|
||||
name,
|
||||
totalQuantity: quantity,
|
||||
suppliesByOwner: Array.from(suppliesByOwner.entries()).map(
|
||||
([ownerId, ownerSupplies]) => ({
|
||||
ownerId,
|
||||
suppliesCount: ownerSupplies.size,
|
||||
totalQuantity: Array.from(ownerSupplies.values()).reduce(
|
||||
(sum, s) => sum + s.quantity,
|
||||
0
|
||||
),
|
||||
ownerName:
|
||||
Array.from(ownerSupplies.values())[0]?.ownerName || "Unknown",
|
||||
supplies: Array.from(ownerSupplies.entries()).map(([name, data]) => ({
|
||||
name,
|
||||
quantity: data.quantity,
|
||||
})),
|
||||
})
|
||||
),
|
||||
});
|
||||
@ -567,37 +628,56 @@ export function FulfillmentWarehouseDashboard() {
|
||||
const productData = groupedProducts.get(productName)!;
|
||||
const itemProducts = productData.totalQuantity;
|
||||
|
||||
// Ищем соответствующий расходник по названию
|
||||
const matchingSupplyQuantity = groupedSupplies.get(productName) || 0;
|
||||
// ИСПРАВЛЕНО: Ищем расходники конкретного селлера-владельца
|
||||
let itemSuppliesQuantity = 0;
|
||||
let suppliesOwners: string[] = [];
|
||||
|
||||
// Если нет точного совпадения, ищем частичное совпадение
|
||||
let itemSuppliesQuantity = matchingSupplyQuantity;
|
||||
if (itemSuppliesQuantity === 0) {
|
||||
for (const [supplyName, quantity] of groupedSupplies.entries()) {
|
||||
if (
|
||||
supplyName.toLowerCase().includes(productName.toLowerCase()) ||
|
||||
productName.toLowerCase().includes(supplyName.toLowerCase())
|
||||
) {
|
||||
itemSuppliesQuantity = quantity;
|
||||
break;
|
||||
// Получаем реального селлера для этого виртуального партнера
|
||||
const realSeller = sellerPartners[index];
|
||||
|
||||
if (realSeller?.id && suppliesByOwner.has(realSeller.id)) {
|
||||
const sellerSupplies = suppliesByOwner.get(realSeller.id)!;
|
||||
|
||||
// Ищем расходники этого селлера по названию товара
|
||||
const matchingSupply = sellerSupplies.get(productName);
|
||||
|
||||
if (matchingSupply) {
|
||||
itemSuppliesQuantity = matchingSupply.quantity;
|
||||
suppliesOwners = [matchingSupply.ownerName];
|
||||
} else {
|
||||
// Если нет точного совпадения, ищем частичное среди расходников ЭТОГО селлера
|
||||
for (const [supplyName, supplyData] of sellerSupplies.entries()) {
|
||||
if (
|
||||
supplyName
|
||||
.toLowerCase()
|
||||
.includes(productName.toLowerCase()) ||
|
||||
productName.toLowerCase().includes(supplyName.toLowerCase())
|
||||
) {
|
||||
itemSuppliesQuantity = supplyData.quantity;
|
||||
suppliesOwners = [supplyData.ownerName];
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback к процентному соотношению
|
||||
if (itemSuppliesQuantity === 0) {
|
||||
itemSuppliesQuantity = Math.floor(itemProducts * 0.1);
|
||||
}
|
||||
// Если у этого селлера нет расходников для данного товара - оставляем 0
|
||||
// НЕ используем fallback, так как должны показывать только реальные данные
|
||||
|
||||
console.log(`📦 Товар "${productName}":`, {
|
||||
totalQuantity: itemProducts,
|
||||
suppliersCount: productData.suppliers.length,
|
||||
uniqueSuppliers: [...new Set(productData.suppliers)],
|
||||
matchingSupplyQuantity: matchingSupplyQuantity,
|
||||
finalSuppliesQuantity: itemSuppliesQuantity,
|
||||
usedFallback:
|
||||
matchingSupplyQuantity === 0 && itemSuppliesQuantity > 0,
|
||||
});
|
||||
console.log(
|
||||
`📦 Товар "${productName}" (партнер: ${
|
||||
realSeller?.name || "Unknown"
|
||||
}):`,
|
||||
{
|
||||
totalQuantity: itemProducts,
|
||||
suppliersCount: productData.suppliers.length,
|
||||
uniqueSuppliers: [...new Set(productData.suppliers)],
|
||||
sellerSuppliesQuantity: itemSuppliesQuantity,
|
||||
suppliesOwners: suppliesOwners,
|
||||
sellerId: realSeller?.id,
|
||||
hasSellerSupplies: itemSuppliesQuantity > 0,
|
||||
}
|
||||
);
|
||||
|
||||
return {
|
||||
id: `grouped-${productName}-${itemIndex}`, // Уникальный ID для группированного товара
|
||||
@ -615,6 +695,7 @@ export function FulfillmentWarehouseDashboard() {
|
||||
defectsQuantity: 0, // Нет реальных данных о браке
|
||||
sellerSuppliesPlace: `D${index + 1}-${itemIndex + 1}`,
|
||||
sellerSuppliesQuantity: itemSuppliesQuantity, // Суммированное количество расходников (реальные данные)
|
||||
sellerSuppliesOwners: suppliesOwners, // Владельцы расходников (ИСПРАВЛЕНО)
|
||||
pvzReturnsPlace: `E${index + 1}-${itemIndex + 1}`,
|
||||
pvzReturnsQuantity: 0, // Нет реальных данных о возвратах с ПВЗ
|
||||
// Создаем варианты товара
|
||||
@ -634,6 +715,7 @@ export function FulfillmentWarehouseDashboard() {
|
||||
sellerSuppliesQuantity: Math.floor(
|
||||
itemSuppliesQuantity * 0.4
|
||||
), // Часть от расходников
|
||||
sellerSuppliesOwners: suppliesOwners, // Владельцы расходников (ИСПРАВЛЕНО)
|
||||
pvzReturnsPlace: `E${index + 1}-${itemIndex + 1}-1`,
|
||||
pvzReturnsQuantity: 0, // Нет реальных данных о возвратах
|
||||
},
|
||||
@ -650,6 +732,7 @@ export function FulfillmentWarehouseDashboard() {
|
||||
sellerSuppliesQuantity: Math.floor(
|
||||
itemSuppliesQuantity * 0.4
|
||||
), // Часть от расходников
|
||||
sellerSuppliesOwners: suppliesOwners, // Владельцы расходников (ИСПРАВЛЕНО)
|
||||
pvzReturnsPlace: `E${index + 1}-${itemIndex + 1}-2`,
|
||||
pvzReturnsQuantity: 0, // Нет реальных данных о возвратах
|
||||
},
|
||||
@ -666,6 +749,7 @@ export function FulfillmentWarehouseDashboard() {
|
||||
sellerSuppliesQuantity: Math.floor(
|
||||
itemSuppliesQuantity * 0.2
|
||||
), // Оставшаяся часть расходников
|
||||
sellerSuppliesOwners: suppliesOwners, // Владельцы расходников (ИСПРАВЛЕНО)
|
||||
pvzReturnsPlace: `E${index + 1}-${itemIndex + 1}-3`,
|
||||
pvzReturnsQuantity: 0, // Нет реальных данных о возвратах
|
||||
},
|
||||
@ -738,7 +822,7 @@ export function FulfillmentWarehouseDashboard() {
|
||||
totalSellerSupplies > 0
|
||||
? Math.floor(
|
||||
(totalSellerSupplies /
|
||||
(mySupplies.reduce(
|
||||
(sellerSupplies.reduce(
|
||||
(sum: number, supply: any) =>
|
||||
sum + (supply.currentStock || 0),
|
||||
0
|
||||
@ -774,7 +858,7 @@ export function FulfillmentWarehouseDashboard() {
|
||||
items,
|
||||
};
|
||||
});
|
||||
}, [sellerPartners, allProducts, mySupplies, suppliesReceivedToday]);
|
||||
}, [sellerPartners, allProducts, sellerSupplies, suppliesReceivedToday]);
|
||||
|
||||
// Функции для аватаров магазинов
|
||||
const getInitials = (name: string): string => {
|
||||
@ -1007,9 +1091,14 @@ export function FulfillmentWarehouseDashboard() {
|
||||
onClick?: () => void;
|
||||
}) => {
|
||||
// Используем percentChange из GraphQL, если доступно, иначе вычисляем локально
|
||||
const displayPercentChange = percentChange !== undefined && percentChange !== null && !isNaN(percentChange)
|
||||
? percentChange
|
||||
: (current > 0 ? (change / current) * 100 : 0);
|
||||
const displayPercentChange =
|
||||
percentChange !== undefined &&
|
||||
percentChange !== null &&
|
||||
!isNaN(percentChange)
|
||||
? percentChange
|
||||
: current > 0
|
||||
? (change / current) * 100
|
||||
: 0;
|
||||
|
||||
return (
|
||||
<div
|
||||
@ -1125,7 +1214,7 @@ export function FulfillmentWarehouseDashboard() {
|
||||
counterpartiesLoading ||
|
||||
ordersLoading ||
|
||||
productsLoading ||
|
||||
suppliesLoading
|
||||
sellerSuppliesLoading
|
||||
) {
|
||||
return (
|
||||
<div className="h-screen flex overflow-hidden">
|
||||
@ -1210,7 +1299,7 @@ export function FulfillmentWarehouseDashboard() {
|
||||
counterpartiesLoading ||
|
||||
ordersLoading ||
|
||||
productsLoading ||
|
||||
suppliesLoading
|
||||
sellerSuppliesLoading
|
||||
}
|
||||
>
|
||||
<RotateCcw className="h-3 w-3 mr-1" />
|
||||
@ -1224,7 +1313,10 @@ export function FulfillmentWarehouseDashboard() {
|
||||
icon={Box}
|
||||
current={warehouseStats.products.current}
|
||||
change={warehouseStats.products.change}
|
||||
percentChange={warehouseStatsData?.fulfillmentWarehouseStats?.products?.percentChange}
|
||||
percentChange={
|
||||
warehouseStatsData?.fulfillmentWarehouseStats?.products
|
||||
?.percentChange
|
||||
}
|
||||
description="Готовые к отправке"
|
||||
/>
|
||||
<StatCard
|
||||
@ -1232,7 +1324,10 @@ export function FulfillmentWarehouseDashboard() {
|
||||
icon={Package}
|
||||
current={warehouseStats.goods.current}
|
||||
change={warehouseStats.goods.change}
|
||||
percentChange={warehouseStatsData?.fulfillmentWarehouseStats?.goods?.percentChange}
|
||||
percentChange={
|
||||
warehouseStatsData?.fulfillmentWarehouseStats?.goods
|
||||
?.percentChange
|
||||
}
|
||||
description="В обработке"
|
||||
/>
|
||||
<StatCard
|
||||
@ -1240,7 +1335,10 @@ export function FulfillmentWarehouseDashboard() {
|
||||
icon={AlertTriangle}
|
||||
current={warehouseStats.defects.current}
|
||||
change={warehouseStats.defects.change}
|
||||
percentChange={warehouseStatsData?.fulfillmentWarehouseStats?.defects?.percentChange}
|
||||
percentChange={
|
||||
warehouseStatsData?.fulfillmentWarehouseStats?.defects
|
||||
?.percentChange
|
||||
}
|
||||
description="Требует утилизации"
|
||||
/>
|
||||
<StatCard
|
||||
@ -1248,7 +1346,10 @@ export function FulfillmentWarehouseDashboard() {
|
||||
icon={RotateCcw}
|
||||
current={warehouseStats.pvzReturns.current}
|
||||
change={warehouseStats.pvzReturns.change}
|
||||
percentChange={warehouseStatsData?.fulfillmentWarehouseStats?.pvzReturns?.percentChange}
|
||||
percentChange={
|
||||
warehouseStatsData?.fulfillmentWarehouseStats?.pvzReturns
|
||||
?.percentChange
|
||||
}
|
||||
description="К обработке"
|
||||
/>
|
||||
<StatCard
|
||||
@ -1256,7 +1357,10 @@ export function FulfillmentWarehouseDashboard() {
|
||||
icon={Wrench}
|
||||
current={warehouseStats.fulfillmentSupplies.current}
|
||||
change={warehouseStats.fulfillmentSupplies.change}
|
||||
percentChange={warehouseStatsData?.fulfillmentWarehouseStats?.fulfillmentSupplies?.percentChange}
|
||||
percentChange={
|
||||
warehouseStatsData?.fulfillmentWarehouseStats
|
||||
?.fulfillmentSupplies?.percentChange
|
||||
}
|
||||
description="Расходники, этикетки"
|
||||
onClick={() => router.push("/fulfillment-warehouse/supplies")}
|
||||
/>
|
||||
@ -1265,7 +1369,10 @@ export function FulfillmentWarehouseDashboard() {
|
||||
icon={Users}
|
||||
current={warehouseStats.sellerSupplies.current}
|
||||
change={warehouseStats.sellerSupplies.change}
|
||||
percentChange={warehouseStatsData?.fulfillmentWarehouseStats?.sellerSupplies?.percentChange}
|
||||
percentChange={
|
||||
warehouseStatsData?.fulfillmentWarehouseStats?.sellerSupplies
|
||||
?.percentChange
|
||||
}
|
||||
description="Материалы клиентов"
|
||||
/>
|
||||
</div>
|
||||
@ -1935,11 +2042,43 @@ export function FulfillmentWarehouseDashboard() {
|
||||
|
||||
{/* Расходники селлера */}
|
||||
<div className="grid grid-cols-2 gap-0">
|
||||
<div className="px-1 py-2 text-center text-xs text-white font-medium">
|
||||
{formatNumber(
|
||||
item.sellerSuppliesQuantity
|
||||
)}
|
||||
</div>
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<div className="px-1 py-2 text-center text-xs text-white font-medium cursor-help hover:bg-white/10 rounded">
|
||||
{formatNumber(
|
||||
item.sellerSuppliesQuantity
|
||||
)}
|
||||
</div>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-64 glass-card">
|
||||
<div className="text-xs">
|
||||
<div className="font-medium mb-2 text-white">
|
||||
Расходники селлеров:
|
||||
</div>
|
||||
{item.sellerSuppliesOwners &&
|
||||
item.sellerSuppliesOwners.length >
|
||||
0 ? (
|
||||
<div className="space-y-1">
|
||||
{item.sellerSuppliesOwners.map(
|
||||
(owner, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="text-white/80 flex items-center"
|
||||
>
|
||||
<div className="w-2 h-2 bg-purple-500 rounded-full mr-2 flex-shrink-0"></div>
|
||||
{owner}
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-white/60">
|
||||
Нет данных о владельцах
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<div className="px-1 py-2 text-center text-xs text-white/60">
|
||||
{item.sellerSuppliesPlace || "-"}
|
||||
</div>
|
||||
@ -2065,11 +2204,45 @@ export function FulfillmentWarehouseDashboard() {
|
||||
|
||||
{/* Расходники селлера */}
|
||||
<div className="grid grid-cols-2 gap-0">
|
||||
<div className="px-1 py-1.5 text-center text-[10px] text-white font-medium">
|
||||
{formatNumber(
|
||||
variant.sellerSuppliesQuantity
|
||||
)}
|
||||
</div>
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<div className="px-1 py-1.5 text-center text-[10px] text-white font-medium cursor-help hover:bg-white/10 rounded">
|
||||
{formatNumber(
|
||||
variant.sellerSuppliesQuantity
|
||||
)}
|
||||
</div>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-64 glass-card">
|
||||
<div className="text-xs">
|
||||
<div className="font-medium mb-2 text-white">
|
||||
Расходники селлеров:
|
||||
</div>
|
||||
{variant.sellerSuppliesOwners &&
|
||||
variant
|
||||
.sellerSuppliesOwners
|
||||
.length > 0 ? (
|
||||
<div className="space-y-1">
|
||||
{variant.sellerSuppliesOwners.map(
|
||||
(owner, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="text-white/80 flex items-center"
|
||||
>
|
||||
<div className="w-2 h-2 bg-purple-500 rounded-full mr-2 flex-shrink-0"></div>
|
||||
{owner}
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-white/60">
|
||||
Нет данных о
|
||||
владельцах
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<div className="px-1 py-1.5 text-center text-[10px] text-white/60">
|
||||
{variant.sellerSuppliesPlace ||
|
||||
"-"}
|
||||
|
@ -13,7 +13,7 @@ import { useAuth } from "@/hooks/useAuth";
|
||||
import { GET_SUPPLY_ORDERS } from "@/graphql/queries";
|
||||
import {
|
||||
LOGISTICS_CONFIRM_ORDER,
|
||||
LOGISTICS_REJECT_ORDER
|
||||
LOGISTICS_REJECT_ORDER,
|
||||
} from "@/graphql/mutations";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
@ -37,7 +37,15 @@ interface SupplyOrder {
|
||||
organizationId: string;
|
||||
partnerId: string;
|
||||
deliveryDate: string;
|
||||
status: "PENDING" | "SUPPLIER_APPROVED" | "LOGISTICS_CONFIRMED" | "SHIPPED" | "DELIVERED" | "CANCELLED";
|
||||
status:
|
||||
| "PENDING"
|
||||
| "SUPPLIER_APPROVED"
|
||||
| "CONFIRMED"
|
||||
| "LOGISTICS_CONFIRMED"
|
||||
| "SHIPPED"
|
||||
| "IN_TRANSIT"
|
||||
| "DELIVERED"
|
||||
| "CANCELLED";
|
||||
totalAmount: number;
|
||||
totalItems: number;
|
||||
createdAt: string;
|
||||
@ -89,7 +97,11 @@ export function LogisticsOrdersDashboard() {
|
||||
fetchPolicy: "cache-and-network",
|
||||
});
|
||||
|
||||
console.log(`DEBUG ЛОГИСТИКА: loading=${loading}, error=${error?.message}, totalOrders=${data?.supplyOrders?.length || 0}`);
|
||||
console.log(
|
||||
`DEBUG ЛОГИСТИКА: loading=${loading}, error=${
|
||||
error?.message
|
||||
}, totalOrders=${data?.supplyOrders?.length || 0}`
|
||||
);
|
||||
|
||||
// Мутации для действий логистики
|
||||
const [logisticsConfirmOrder] = useMutation(LOGISTICS_CONFIRM_ORDER, {
|
||||
@ -137,8 +149,15 @@ export function LogisticsOrdersDashboard() {
|
||||
// Фильтруем заказы где текущая организация является логистическим партнером
|
||||
const logisticsOrders: SupplyOrder[] = (data?.supplyOrders || []).filter(
|
||||
(order: SupplyOrder) => {
|
||||
const isLogisticsPartner = order.logisticsPartner?.id === user?.organization?.id;
|
||||
console.log(`DEBUG ЛОГИСТИКА: Заказ ${order.id.slice(-8)} - статус: ${order.status}, logisticsPartnerId: ${order.logisticsPartner?.id}, currentOrgId: ${user?.organization?.id}, isLogisticsPartner: ${isLogisticsPartner}`);
|
||||
const isLogisticsPartner =
|
||||
order.logisticsPartner?.id === user?.organization?.id;
|
||||
console.log(
|
||||
`DEBUG ЛОГИСТИКА: Заказ ${order.id.slice(-8)} - статус: ${
|
||||
order.status
|
||||
}, logisticsPartnerId: ${order.logisticsPartner?.id}, currentOrgId: ${
|
||||
user?.organization?.id
|
||||
}, isLogisticsPartner: ${isLogisticsPartner}`
|
||||
);
|
||||
return isLogisticsPartner;
|
||||
}
|
||||
);
|
||||
@ -155,6 +174,11 @@ export function LogisticsOrdersDashboard() {
|
||||
color: "bg-yellow-500/20 text-yellow-300 border-yellow-500/30",
|
||||
icon: AlertTriangle,
|
||||
},
|
||||
CONFIRMED: {
|
||||
label: "Требует подтверждения",
|
||||
color: "bg-yellow-500/20 text-yellow-300 border-yellow-500/30",
|
||||
icon: AlertTriangle,
|
||||
},
|
||||
LOGISTICS_CONFIRMED: {
|
||||
label: "Подтверждено",
|
||||
color: "bg-blue-500/20 text-blue-300 border-blue-500/30",
|
||||
@ -247,7 +271,9 @@ export function LogisticsOrdersDashboard() {
|
||||
return (
|
||||
<div className="h-screen flex overflow-hidden">
|
||||
<Sidebar />
|
||||
<main className={`flex-1 ${getSidebarMargin()} px-4 py-3 flex flex-col transition-all duration-300 overflow-hidden`}>
|
||||
<main
|
||||
className={`flex-1 ${getSidebarMargin()} px-4 py-3 flex flex-col transition-all duration-300 overflow-hidden`}
|
||||
>
|
||||
<div className="flex-1 overflow-y-auto flex items-center justify-center">
|
||||
<div className="text-white">Загрузка заказов...</div>
|
||||
</div>
|
||||
@ -260,9 +286,13 @@ export function LogisticsOrdersDashboard() {
|
||||
return (
|
||||
<div className="h-screen flex overflow-hidden">
|
||||
<Sidebar />
|
||||
<main className={`flex-1 ${getSidebarMargin()} px-4 py-3 flex flex-col transition-all duration-300 overflow-hidden`}>
|
||||
<main
|
||||
className={`flex-1 ${getSidebarMargin()} px-4 py-3 flex flex-col transition-all duration-300 overflow-hidden`}
|
||||
>
|
||||
<div className="flex-1 overflow-y-auto flex items-center justify-center">
|
||||
<div className="text-red-300">Ошибка загрузки заказов: {error.message}</div>
|
||||
<div className="text-red-300">
|
||||
Ошибка загрузки заказов: {error.message}
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
@ -272,7 +302,9 @@ export function LogisticsOrdersDashboard() {
|
||||
return (
|
||||
<div className="h-screen flex overflow-hidden">
|
||||
<Sidebar />
|
||||
<main className={`flex-1 ${getSidebarMargin()} px-4 py-3 flex flex-col transition-all duration-300 overflow-hidden`}>
|
||||
<main
|
||||
className={`flex-1 ${getSidebarMargin()} px-4 py-3 flex flex-col transition-all duration-300 overflow-hidden`}
|
||||
>
|
||||
<div className="flex-1 overflow-y-auto space-y-6">
|
||||
{/* Заголовок */}
|
||||
<div className="flex items-center justify-between">
|
||||
@ -296,7 +328,13 @@ export function LogisticsOrdersDashboard() {
|
||||
<div>
|
||||
<p className="text-white/60 text-sm">Требуют подтверждения</p>
|
||||
<p className="text-xl font-bold text-white">
|
||||
{logisticsOrders.filter(order => order.status === "SUPPLIER_APPROVED").length}
|
||||
{
|
||||
logisticsOrders.filter(
|
||||
(order) =>
|
||||
order.status === "SUPPLIER_APPROVED" ||
|
||||
order.status === "CONFIRMED"
|
||||
).length
|
||||
}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@ -310,7 +348,11 @@ export function LogisticsOrdersDashboard() {
|
||||
<div>
|
||||
<p className="text-white/60 text-sm">Подтверждено</p>
|
||||
<p className="text-xl font-bold text-white">
|
||||
{logisticsOrders.filter(order => order.status === "LOGISTICS_CONFIRMED").length}
|
||||
{
|
||||
logisticsOrders.filter(
|
||||
(order) => order.status === "LOGISTICS_CONFIRMED"
|
||||
).length
|
||||
}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@ -324,7 +366,11 @@ export function LogisticsOrdersDashboard() {
|
||||
<div>
|
||||
<p className="text-white/60 text-sm">В пути</p>
|
||||
<p className="text-xl font-bold text-white">
|
||||
{logisticsOrders.filter(order => order.status === "SHIPPED").length}
|
||||
{
|
||||
logisticsOrders.filter(
|
||||
(order) => order.status === "SHIPPED"
|
||||
).length
|
||||
}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@ -338,7 +384,11 @@ export function LogisticsOrdersDashboard() {
|
||||
<div>
|
||||
<p className="text-white/60 text-sm">Доставлено</p>
|
||||
<p className="text-xl font-bold text-white">
|
||||
{logisticsOrders.filter(order => order.status === "DELIVERED").length}
|
||||
{
|
||||
logisticsOrders.filter(
|
||||
(order) => order.status === "DELIVERED"
|
||||
).length
|
||||
}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@ -355,7 +405,8 @@ export function LogisticsOrdersDashboard() {
|
||||
Нет логистических заказов
|
||||
</h3>
|
||||
<p className="text-white/60">
|
||||
Заказы поставок, требующие логистического сопровождения, будут отображаться здесь
|
||||
Заказы поставок, требующие логистического сопровождения,
|
||||
будут отображаться здесь
|
||||
</p>
|
||||
</div>
|
||||
</Card>
|
||||
@ -384,19 +435,29 @@ export function LogisticsOrdersDashboard() {
|
||||
<div className="flex items-center space-x-2">
|
||||
<Avatar className="w-8 h-8">
|
||||
<AvatarFallback className="bg-blue-500 text-white text-sm">
|
||||
{getInitials(order.partner.name || order.partner.fullName || "П")}
|
||||
{getInitials(
|
||||
order.partner.name ||
|
||||
order.partner.fullName ||
|
||||
"П"
|
||||
)}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<span className="text-white/60 text-sm">→</span>
|
||||
<Avatar className="w-8 h-8">
|
||||
<AvatarFallback className="bg-green-500 text-white text-sm">
|
||||
{getInitials(order.organization.name || order.organization.fullName || "ФФ")}
|
||||
{getInitials(
|
||||
order.organization.name ||
|
||||
order.organization.fullName ||
|
||||
"ФФ"
|
||||
)}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<h3 className="text-white font-medium text-sm truncate">
|
||||
{order.partner.name || order.partner.fullName} → {order.organization.name || order.organization.fullName}
|
||||
{order.partner.name || order.partner.fullName} →{" "}
|
||||
{order.organization.name ||
|
||||
order.organization.fullName}
|
||||
</h3>
|
||||
<p className="text-white/60 text-xs">
|
||||
Поставщик → Фулфилмент
|
||||
@ -426,7 +487,8 @@ export function LogisticsOrdersDashboard() {
|
||||
{getStatusBadge(order.status)}
|
||||
|
||||
{/* Кнопки действий для логистики */}
|
||||
{order.status === "SUPPLIER_APPROVED" && (
|
||||
{(order.status === "SUPPLIER_APPROVED" ||
|
||||
order.status === "CONFIRMED") && (
|
||||
<div className="flex items-center space-x-2">
|
||||
<Button
|
||||
size="sm"
|
||||
@ -490,7 +552,8 @@ export function LogisticsOrdersDashboard() {
|
||||
</h4>
|
||||
<div className="bg-white/5 rounded p-3">
|
||||
<p className="text-white">
|
||||
{order.organization.name || order.organization.fullName}
|
||||
{order.organization.name ||
|
||||
order.organization.fullName}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
601
src/components/supplier-orders/supplier-order-card.tsx
Normal file
601
src/components/supplier-orders/supplier-order-card.tsx
Normal file
@ -0,0 +1,601 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useMutation } from "@apollo/client";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Avatar, AvatarImage, AvatarFallback } from "@/components/ui/avatar";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import {
|
||||
SUPPLIER_APPROVE_ORDER,
|
||||
SUPPLIER_REJECT_ORDER,
|
||||
SUPPLIER_SHIP_ORDER,
|
||||
} from "@/graphql/mutations";
|
||||
import { GET_SUPPLY_ORDERS } from "@/graphql/queries";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
Calendar,
|
||||
Package,
|
||||
Truck,
|
||||
User,
|
||||
CheckCircle,
|
||||
Clock,
|
||||
XCircle,
|
||||
MapPin,
|
||||
Phone,
|
||||
Mail,
|
||||
Building,
|
||||
Hash,
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
MessageCircle,
|
||||
Loader2,
|
||||
} from "lucide-react";
|
||||
|
||||
interface SupplierOrderCardProps {
|
||||
order: {
|
||||
id: string;
|
||||
organizationId: string;
|
||||
partnerId: string;
|
||||
deliveryDate: string;
|
||||
status:
|
||||
| "PENDING"
|
||||
| "SUPPLIER_APPROVED"
|
||||
| "CONFIRMED"
|
||||
| "LOGISTICS_CONFIRMED"
|
||||
| "SHIPPED"
|
||||
| "IN_TRANSIT"
|
||||
| "DELIVERED"
|
||||
| "CANCELLED";
|
||||
totalAmount: number;
|
||||
totalItems: number;
|
||||
createdAt: string;
|
||||
organization: {
|
||||
id: string;
|
||||
name?: string;
|
||||
fullName?: string;
|
||||
type: string;
|
||||
inn?: string;
|
||||
};
|
||||
fulfillmentCenter?: {
|
||||
id: string;
|
||||
name?: string;
|
||||
fullName?: string;
|
||||
type: string;
|
||||
};
|
||||
logisticsPartner?: {
|
||||
id: string;
|
||||
name?: string;
|
||||
fullName?: string;
|
||||
type: string;
|
||||
};
|
||||
items: Array<{
|
||||
id: string;
|
||||
quantity: number;
|
||||
price: number;
|
||||
totalPrice: number;
|
||||
product: {
|
||||
id: string;
|
||||
name: string;
|
||||
article: string;
|
||||
description?: string;
|
||||
category?: {
|
||||
id: string;
|
||||
name: string;
|
||||
};
|
||||
};
|
||||
}>;
|
||||
};
|
||||
}
|
||||
|
||||
export function SupplierOrderCard({ order }: SupplierOrderCardProps) {
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
const [showRejectModal, setShowRejectModal] = useState(false);
|
||||
const [rejectReason, setRejectReason] = useState("");
|
||||
|
||||
// Мутации для действий поставщика
|
||||
const [supplierApproveOrder, { loading: approving }] = useMutation(
|
||||
SUPPLIER_APPROVE_ORDER,
|
||||
{
|
||||
refetchQueries: [{ query: GET_SUPPLY_ORDERS }],
|
||||
onCompleted: (data) => {
|
||||
if (data.supplierApproveOrder.success) {
|
||||
toast.success(data.supplierApproveOrder.message);
|
||||
} else {
|
||||
toast.error(data.supplierApproveOrder.message);
|
||||
}
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error("Error approving order:", error);
|
||||
toast.error("Ошибка при одобрении заказа");
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
const [supplierRejectOrder, { loading: rejecting }] = useMutation(
|
||||
SUPPLIER_REJECT_ORDER,
|
||||
{
|
||||
refetchQueries: [{ query: GET_SUPPLY_ORDERS }],
|
||||
onCompleted: (data) => {
|
||||
if (data.supplierRejectOrder.success) {
|
||||
toast.success(data.supplierRejectOrder.message);
|
||||
} else {
|
||||
toast.error(data.supplierRejectOrder.message);
|
||||
}
|
||||
setShowRejectModal(false);
|
||||
setRejectReason("");
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error("Error rejecting order:", error);
|
||||
toast.error("Ошибка при отклонении заказа");
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
const [supplierShipOrder, { loading: shipping }] = useMutation(
|
||||
SUPPLIER_SHIP_ORDER,
|
||||
{
|
||||
refetchQueries: [{ query: GET_SUPPLY_ORDERS }],
|
||||
onCompleted: (data) => {
|
||||
if (data.supplierShipOrder.success) {
|
||||
toast.success(data.supplierShipOrder.message);
|
||||
} else {
|
||||
toast.error(data.supplierShipOrder.message);
|
||||
}
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error("Error shipping order:", error);
|
||||
toast.error("Ошибка при отправке заказа");
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
const handleApproveOrder = async () => {
|
||||
try {
|
||||
await supplierApproveOrder({
|
||||
variables: { id: order.id },
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Error in handleApproveOrder:", error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRejectOrder = async () => {
|
||||
if (!rejectReason.trim()) {
|
||||
toast.error("Укажите причину отклонения заявки");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await supplierRejectOrder({
|
||||
variables: {
|
||||
id: order.id,
|
||||
reason: rejectReason,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Error in handleRejectOrder:", error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleShipOrder = async () => {
|
||||
try {
|
||||
await supplierShipOrder({
|
||||
variables: { id: order.id },
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Error in handleShipOrder:", error);
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusBadge = (status: string) => {
|
||||
switch (status) {
|
||||
case "PENDING":
|
||||
return (
|
||||
<Badge className="bg-yellow-500/20 text-yellow-300 border-yellow-400/30">
|
||||
🟡 ОЖИДАЕТ
|
||||
</Badge>
|
||||
);
|
||||
case "SUPPLIER_APPROVED":
|
||||
return (
|
||||
<Badge className="bg-green-500/20 text-green-300 border-green-400/30">
|
||||
🟢 ОДОБРЕНО
|
||||
</Badge>
|
||||
);
|
||||
case "CONFIRMED":
|
||||
case "LOGISTICS_CONFIRMED":
|
||||
return (
|
||||
<Badge className="bg-blue-500/20 text-blue-300 border-blue-400/30">
|
||||
🔵 В РАБОТЕ
|
||||
</Badge>
|
||||
);
|
||||
case "SHIPPED":
|
||||
case "IN_TRANSIT":
|
||||
return (
|
||||
<Badge className="bg-orange-500/20 text-orange-300 border-orange-400/30">
|
||||
🟠 В ПУТИ
|
||||
</Badge>
|
||||
);
|
||||
case "DELIVERED":
|
||||
return (
|
||||
<Badge className="bg-emerald-500/20 text-emerald-300 border-emerald-400/30">
|
||||
✅ ДОСТАВЛЕНО
|
||||
</Badge>
|
||||
);
|
||||
default:
|
||||
return (
|
||||
<Badge className="bg-white/20 text-white/70 border-white/30">
|
||||
{status}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const formatDate = (dateString: string) => {
|
||||
return new Date(dateString).toLocaleDateString("ru-RU", {
|
||||
day: "2-digit",
|
||||
month: "2-digit",
|
||||
year: "numeric",
|
||||
});
|
||||
};
|
||||
|
||||
const getInitials = (name: string) => {
|
||||
return name
|
||||
.split(" ")
|
||||
.map((word) => word[0])
|
||||
.join("")
|
||||
.toUpperCase()
|
||||
.slice(0, 2);
|
||||
};
|
||||
|
||||
const calculateVolume = () => {
|
||||
// Примерный расчет объема - можно улучшить на основе реальных данных о товарах
|
||||
return (order.totalItems * 0.02).toFixed(1); // 0.02 м³ на единицу товара
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Card className="glass-card border-white/10 hover:border-white/20 transition-all">
|
||||
{/* Основная информация - структура согласно правилам */}
|
||||
<div className="p-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-2">
|
||||
<Hash className="h-4 w-4 text-white/60" />
|
||||
<span className="text-white font-semibold">
|
||||
СФ-{order.id.slice(-8)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Calendar className="h-4 w-4 text-blue-400" />
|
||||
<span className="text-white/70 text-sm">
|
||||
{formatDate(order.createdAt)}
|
||||
</span>
|
||||
</div>
|
||||
{getStatusBadge(order.status)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Информация об участниках */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 mb-4">
|
||||
{/* Заказчик */}
|
||||
<div>
|
||||
<div className="flex items-center space-x-2 mb-2">
|
||||
<User className="h-4 w-4 text-white/60" />
|
||||
<span className="text-white/60 text-sm">Заказчик:</span>
|
||||
</div>
|
||||
<div className="flex items-center space-x-3">
|
||||
<Avatar className="w-8 h-8">
|
||||
<AvatarFallback className="bg-blue-500 text-white text-sm">
|
||||
{getInitials(
|
||||
order.organization.name ||
|
||||
order.organization.fullName ||
|
||||
"ОРГ"
|
||||
)}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div>
|
||||
<p className="text-white font-medium text-sm">
|
||||
{order.organization.name || order.organization.fullName}
|
||||
</p>
|
||||
{order.organization.inn && (
|
||||
<p className="text-white/60 text-xs">
|
||||
ИНН: {order.organization.inn}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Фулфилмент */}
|
||||
{order.fulfillmentCenter && (
|
||||
<div>
|
||||
<div className="flex items-center space-x-2 mb-2">
|
||||
<Building className="h-4 w-4 text-white/60" />
|
||||
<span className="text-white/60 text-sm">Фулфилмент:</span>
|
||||
</div>
|
||||
<p className="text-white font-medium text-sm">
|
||||
{order.fulfillmentCenter.name ||
|
||||
order.fulfillmentCenter.fullName}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Логистика */}
|
||||
{order.logisticsPartner && (
|
||||
<div>
|
||||
<div className="flex items-center space-x-2 mb-2">
|
||||
<Truck className="h-4 w-4 text-white/60" />
|
||||
<span className="text-white/60 text-sm">Логистика:</span>
|
||||
</div>
|
||||
<p className="text-white font-medium text-sm">
|
||||
{order.logisticsPartner.name ||
|
||||
order.logisticsPartner.fullName}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Краткая информация о заказе */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-6">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Package className="h-4 w-4 text-green-400" />
|
||||
<span className="text-white text-sm">
|
||||
{order.items.length} вид
|
||||
{order.items.length === 1
|
||||
? ""
|
||||
: order.items.length < 5
|
||||
? "а"
|
||||
: "ов"}{" "}
|
||||
товаров
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<span className="text-white text-sm">
|
||||
{order.totalItems} единиц
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<span className="text-white text-sm">
|
||||
📏 {calculateVolume()} м³
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<span className="text-white font-semibold">
|
||||
💰 {order.totalAmount.toLocaleString()}₽
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Кнопки действий */}
|
||||
<div className="flex items-center space-x-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
className="text-white/70 hover:text-white"
|
||||
>
|
||||
Подробности
|
||||
{isExpanded ? (
|
||||
<ChevronUp className="h-4 w-4 ml-1" />
|
||||
) : (
|
||||
<ChevronDown className="h-4 w-4 ml-1" />
|
||||
)}
|
||||
</Button>
|
||||
|
||||
{/* Действия для PENDING */}
|
||||
{order.status === "PENDING" && (
|
||||
<>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handleApproveOrder}
|
||||
disabled={approving}
|
||||
className="glass-button bg-green-500/20 hover:bg-green-500/30 text-green-300 border border-green-500/30"
|
||||
>
|
||||
{approving ? (
|
||||
<Loader2 className="h-3 w-3 mr-1 animate-spin" />
|
||||
) : (
|
||||
<CheckCircle className="h-3 w-3 mr-1" />
|
||||
)}
|
||||
Одобрить
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => setShowRejectModal(true)}
|
||||
disabled={rejecting}
|
||||
className="glass-secondary bg-red-500/20 hover:bg-red-500/30 text-red-300 border border-red-500/30"
|
||||
>
|
||||
<XCircle className="h-3 w-3 mr-1" />
|
||||
Отклонить
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Действие для LOGISTICS_CONFIRMED */}
|
||||
{order.status === "LOGISTICS_CONFIRMED" && (
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handleShipOrder}
|
||||
disabled={shipping}
|
||||
className="glass-button bg-orange-500/20 hover:bg-orange-500/30 text-orange-300 border border-orange-500/30"
|
||||
>
|
||||
{shipping ? (
|
||||
<Loader2 className="h-3 w-3 mr-1 animate-spin" />
|
||||
) : (
|
||||
<Truck className="h-3 w-3 mr-1" />
|
||||
)}
|
||||
Отгрузить
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* Кнопка связаться всегда доступна */}
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="glass-secondary text-blue-300 hover:text-blue-200"
|
||||
>
|
||||
<MessageCircle className="h-3 w-3 mr-1" />
|
||||
Связаться
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Срок доставки */}
|
||||
<div className="mt-3 pt-3 border-t border-white/10">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-2">
|
||||
<MapPin className="h-4 w-4 text-white/60" />
|
||||
<span className="text-white/60 text-sm">Доставка:</span>
|
||||
<span className="text-white text-sm">Склад фулфилмента</span>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Clock className="h-4 w-4 text-white/60" />
|
||||
<span className="text-white/60 text-sm">Срок:</span>
|
||||
<span className="text-white text-sm">
|
||||
{formatDate(order.deliveryDate)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Расширенная детализация */}
|
||||
{isExpanded && (
|
||||
<div className="border-t border-white/10 p-4">
|
||||
<h4 className="text-white font-semibold mb-3">
|
||||
📋 ДЕТАЛИ ЗАЯВКИ #{order.id.slice(-8)}
|
||||
</h4>
|
||||
|
||||
{/* Товары в заявке */}
|
||||
<div className="mb-4">
|
||||
<h5 className="text-white/80 font-medium mb-2">
|
||||
📦 ТОВАРЫ В ЗАЯВКЕ:
|
||||
</h5>
|
||||
<div className="space-y-2">
|
||||
{order.items.map((item) => (
|
||||
<div
|
||||
key={item.id}
|
||||
className="flex items-center justify-between p-2 bg-white/5 rounded"
|
||||
>
|
||||
<div className="flex-1">
|
||||
<span className="text-white text-sm">
|
||||
{item.product.name} • {item.quantity} шт • {item.price}
|
||||
₽/шт = {item.totalPrice.toLocaleString()}₽
|
||||
</span>
|
||||
<div className="text-white/60 text-xs">
|
||||
Артикул: {item.product.article}
|
||||
{item.product.category &&
|
||||
` • ${item.product.category.name}`}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<div className="pt-2 border-t border-white/10">
|
||||
<span className="text-white font-semibold">
|
||||
Общая стоимость: {order.totalAmount.toLocaleString()}₽
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Логистическая информация */}
|
||||
<div className="mb-4">
|
||||
<h5 className="text-white/80 font-medium mb-2">
|
||||
📍 ЛОГИСТИЧЕСКАЯ ИНФОРМАЦИЯ:
|
||||
</h5>
|
||||
<div className="space-y-1 text-sm">
|
||||
<div className="text-white/70">
|
||||
• Объем груза: {calculateVolume()} м³
|
||||
</div>
|
||||
<div className="text-white/70">
|
||||
• Предварительная стоимость доставки: ~
|
||||
{Math.round(
|
||||
parseFloat(calculateVolume()) * 3500
|
||||
).toLocaleString()}
|
||||
₽
|
||||
</div>
|
||||
<div className="text-white/70">
|
||||
• Маршрут: Склад поставщика →{" "}
|
||||
{order.fulfillmentCenter?.name || "Фулфилмент-центр"}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Контактная информация */}
|
||||
<div>
|
||||
<h5 className="text-white/80 font-medium mb-2">📞 КОНТАКТЫ:</h5>
|
||||
<div className="space-y-1 text-sm">
|
||||
<div className="text-white/70">
|
||||
• Заказчик:{" "}
|
||||
{order.organization.name || order.organization.fullName}
|
||||
{order.organization.inn &&
|
||||
` (ИНН: ${order.organization.inn})`}
|
||||
</div>
|
||||
{order.fulfillmentCenter && (
|
||||
<div className="text-white/70">
|
||||
• Фулфилмент:{" "}
|
||||
{order.fulfillmentCenter.name ||
|
||||
order.fulfillmentCenter.fullName}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* Модал отклонения заявки */}
|
||||
<Dialog open={showRejectModal} onOpenChange={setShowRejectModal}>
|
||||
<DialogContent className="glass-card border-white/20">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-white">Отклонить заявку</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="text-white/90 text-sm mb-2 block">
|
||||
Причина отклонения заявки:
|
||||
</label>
|
||||
<Textarea
|
||||
value={rejectReason}
|
||||
onChange={(e) => setRejectReason(e.target.value)}
|
||||
placeholder="Укажите причину отклонения..."
|
||||
className="glass-input text-white placeholder:text-white/50"
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex justify-end space-x-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => setShowRejectModal(false)}
|
||||
className="text-white/70 hover:text-white"
|
||||
>
|
||||
Отмена
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleRejectOrder}
|
||||
disabled={rejecting || !rejectReason.trim()}
|
||||
className="bg-red-500/20 hover:bg-red-500/30 text-red-300 border border-red-500/30"
|
||||
>
|
||||
{rejecting ? (
|
||||
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||
) : (
|
||||
<XCircle className="h-4 w-4 mr-2" />
|
||||
)}
|
||||
Отклонить заявку
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
178
src/components/supplier-orders/supplier-order-stats.tsx
Normal file
178
src/components/supplier-orders/supplier-order-stats.tsx
Normal file
@ -0,0 +1,178 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo } from "react";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import {
|
||||
Clock,
|
||||
CheckCircle,
|
||||
Settings,
|
||||
Truck,
|
||||
Package,
|
||||
TrendingUp,
|
||||
Calendar,
|
||||
DollarSign,
|
||||
} from "lucide-react";
|
||||
|
||||
interface SupplierOrderStatsProps {
|
||||
orders: Array<{
|
||||
id: string;
|
||||
status: string;
|
||||
totalAmount: number;
|
||||
totalItems: number;
|
||||
createdAt: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
export function SupplierOrderStats({ orders }: SupplierOrderStatsProps) {
|
||||
const stats = useMemo(() => {
|
||||
const pending = orders.filter((order) => order.status === "PENDING").length;
|
||||
const approved = orders.filter(
|
||||
(order) => order.status === "SUPPLIER_APPROVED"
|
||||
).length;
|
||||
const inProgress = orders.filter((order) =>
|
||||
["CONFIRMED", "LOGISTICS_CONFIRMED"].includes(order.status)
|
||||
).length;
|
||||
const shipping = orders.filter((order) =>
|
||||
["SHIPPED", "IN_TRANSIT"].includes(order.status)
|
||||
).length;
|
||||
const completed = orders.filter(
|
||||
(order) => order.status === "DELIVERED"
|
||||
).length;
|
||||
|
||||
const totalRevenue = orders
|
||||
.filter((order) => order.status === "DELIVERED")
|
||||
.reduce((sum, order) => sum + order.totalAmount, 0);
|
||||
|
||||
const totalItems = orders.reduce((sum, order) => sum + order.totalItems, 0);
|
||||
|
||||
// Заявки за сегодня
|
||||
const today = new Date().toDateString();
|
||||
const todayOrders = orders.filter(
|
||||
(order) => new Date(order.createdAt).toDateString() === today
|
||||
).length;
|
||||
|
||||
return {
|
||||
pending,
|
||||
approved,
|
||||
inProgress,
|
||||
shipping,
|
||||
completed,
|
||||
totalRevenue,
|
||||
totalItems,
|
||||
todayOrders,
|
||||
total: orders.length,
|
||||
};
|
||||
}, [orders]);
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 gap-4">
|
||||
{/* Ожидают одобрения */}
|
||||
<Card className="glass-card border-white/10 p-4">
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="p-2 bg-yellow-500/20 rounded-lg">
|
||||
<Clock className="h-5 w-5 text-yellow-400" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-white/60 text-sm">Ожидают одобрения</p>
|
||||
<p className="text-xl font-bold text-white">{stats.pending}</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Одобренные */}
|
||||
<Card className="glass-card border-white/10 p-4">
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="p-2 bg-green-500/20 rounded-lg">
|
||||
<CheckCircle className="h-5 w-5 text-green-400" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-white/60 text-sm">Одобренные</p>
|
||||
<p className="text-xl font-bold text-white">{stats.approved}</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* В работе */}
|
||||
<Card className="glass-card border-white/10 p-4">
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="p-2 bg-blue-500/20 rounded-lg">
|
||||
<Settings className="h-5 w-5 text-blue-400" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-white/60 text-sm">В работе</p>
|
||||
<p className="text-xl font-bold text-white">{stats.inProgress}</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Готово к отправке / В пути */}
|
||||
<Card className="glass-card border-white/10 p-4">
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="p-2 bg-orange-500/20 rounded-lg">
|
||||
<Truck className="h-5 w-5 text-orange-400" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-white/60 text-sm">Отгрузка/В пути</p>
|
||||
<p className="text-xl font-bold text-white">{stats.shipping}</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Доставлено */}
|
||||
<Card className="glass-card border-white/10 p-4">
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="p-2 bg-emerald-500/20 rounded-lg">
|
||||
<Package className="h-5 w-5 text-emerald-400" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-white/60 text-sm">Доставлено</p>
|
||||
<p className="text-xl font-bold text-white">{stats.completed}</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Заявки за сегодня */}
|
||||
<Card className="glass-card border-white/10 p-4">
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="p-2 bg-purple-500/20 rounded-lg">
|
||||
<Calendar className="h-5 w-5 text-purple-400" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-white/60 text-sm">За сегодня</p>
|
||||
<p className="text-xl font-bold text-white">{stats.todayOrders}</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Общая выручка */}
|
||||
<Card className="glass-card border-white/10 p-4 md:col-span-2">
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="p-2 bg-green-500/20 rounded-lg">
|
||||
<DollarSign className="h-5 w-5 text-green-400" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-white/60 text-sm">Выручка (завершенные)</p>
|
||||
<p className="text-xl font-bold text-white">
|
||||
{stats.totalRevenue.toLocaleString()}₽
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Всего товаров */}
|
||||
<Card className="glass-card border-white/10 p-4 md:col-span-2">
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="p-2 bg-blue-500/20 rounded-lg">
|
||||
<TrendingUp className="h-5 w-5 text-blue-400" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-white/60 text-sm">Всего товаров в заявках</p>
|
||||
<p className="text-xl font-bold text-white">
|
||||
{stats.totalItems.toLocaleString()} шт.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
@ -1,590 +1,34 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useQuery, useMutation } from "@apollo/client";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { Avatar, AvatarImage, AvatarFallback } from "@/components/ui/avatar";
|
||||
import { Sidebar } from "@/components/dashboard/sidebar";
|
||||
import { useSidebar } from "@/hooks/useSidebar";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { GET_SUPPLY_ORDERS } from "@/graphql/queries";
|
||||
import {
|
||||
SUPPLIER_APPROVE_ORDER,
|
||||
SUPPLIER_REJECT_ORDER,
|
||||
SUPPLIER_SHIP_ORDER
|
||||
} from "@/graphql/mutations";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
Calendar,
|
||||
Package,
|
||||
Truck,
|
||||
User,
|
||||
CheckCircle,
|
||||
Clock,
|
||||
XCircle,
|
||||
MapPin,
|
||||
Phone,
|
||||
Mail,
|
||||
Building,
|
||||
Hash,
|
||||
AlertTriangle,
|
||||
} from "lucide-react";
|
||||
|
||||
interface SupplyOrder {
|
||||
id: string;
|
||||
organizationId: string;
|
||||
partnerId: string;
|
||||
deliveryDate: string;
|
||||
status: "PENDING" | "SUPPLIER_APPROVED" | "LOGISTICS_CONFIRMED" | "SHIPPED" | "DELIVERED" | "CANCELLED";
|
||||
totalAmount: number;
|
||||
totalItems: number;
|
||||
createdAt: string;
|
||||
organization: {
|
||||
id: string;
|
||||
name?: string;
|
||||
fullName?: string;
|
||||
type: string;
|
||||
};
|
||||
fulfillmentCenter?: {
|
||||
id: string;
|
||||
name?: string;
|
||||
fullName?: string;
|
||||
type: string;
|
||||
};
|
||||
logisticsPartner?: {
|
||||
id: string;
|
||||
name?: string;
|
||||
fullName?: string;
|
||||
type: string;
|
||||
};
|
||||
items: Array<{
|
||||
id: string;
|
||||
quantity: number;
|
||||
price: number;
|
||||
totalPrice: number;
|
||||
product: {
|
||||
id: string;
|
||||
name: string;
|
||||
article: string;
|
||||
description?: string;
|
||||
category?: {
|
||||
id: string;
|
||||
name: string;
|
||||
};
|
||||
};
|
||||
}>;
|
||||
}
|
||||
import { SupplierOrdersTabs } from "./supplier-orders-tabs";
|
||||
import { Package, AlertTriangle } from "lucide-react";
|
||||
|
||||
export function SupplierOrdersDashboard() {
|
||||
const { getSidebarMargin } = useSidebar();
|
||||
const { user } = useAuth();
|
||||
const [expandedOrders, setExpandedOrders] = useState<Set<string>>(new Set());
|
||||
const [rejectReason, setRejectReason] = useState<string>("");
|
||||
const [showRejectModal, setShowRejectModal] = useState<string | null>(null);
|
||||
|
||||
// Загружаем заказы поставок
|
||||
const { data, loading, error, refetch } = useQuery(GET_SUPPLY_ORDERS, {
|
||||
fetchPolicy: "cache-and-network",
|
||||
});
|
||||
|
||||
// Мутации для действий поставщика
|
||||
const [supplierApproveOrder] = useMutation(SUPPLIER_APPROVE_ORDER, {
|
||||
refetchQueries: [
|
||||
{ query: GET_SUPPLY_ORDERS },
|
||||
"GetMyProducts", // Обновляем товары поставщика
|
||||
"GetWarehouseProducts", // Обновляем склад фулфилмента (если нужно)
|
||||
],
|
||||
awaitRefetchQueries: true,
|
||||
onCompleted: (data) => {
|
||||
if (data.supplierApproveOrder.success) {
|
||||
toast.success(data.supplierApproveOrder.message);
|
||||
} else {
|
||||
toast.error(data.supplierApproveOrder.message);
|
||||
}
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error("Error approving order:", error);
|
||||
toast.error("Ошибка при одобрении заказа");
|
||||
},
|
||||
});
|
||||
|
||||
const [supplierRejectOrder] = useMutation(SUPPLIER_REJECT_ORDER, {
|
||||
refetchQueries: [{ query: GET_SUPPLY_ORDERS }],
|
||||
onCompleted: (data) => {
|
||||
if (data.supplierRejectOrder.success) {
|
||||
toast.success(data.supplierRejectOrder.message);
|
||||
} else {
|
||||
toast.error(data.supplierRejectOrder.message);
|
||||
}
|
||||
setShowRejectModal(null);
|
||||
setRejectReason("");
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error("Error rejecting order:", error);
|
||||
toast.error("Ошибка при отклонении заказа");
|
||||
},
|
||||
});
|
||||
|
||||
const [supplierShipOrder] = useMutation(SUPPLIER_SHIP_ORDER, {
|
||||
refetchQueries: [
|
||||
{ query: GET_SUPPLY_ORDERS },
|
||||
"GetMyProducts", // Обновляем товары поставщика для актуальных остатков
|
||||
],
|
||||
onCompleted: (data) => {
|
||||
if (data.supplierShipOrder.success) {
|
||||
toast.success(data.supplierShipOrder.message);
|
||||
} else {
|
||||
toast.error(data.supplierShipOrder.message);
|
||||
}
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error("Error shipping order:", error);
|
||||
toast.error("Ошибка при отправке заказа");
|
||||
},
|
||||
});
|
||||
|
||||
const toggleOrderExpansion = (orderId: string) => {
|
||||
const newExpanded = new Set(expandedOrders);
|
||||
if (newExpanded.has(orderId)) {
|
||||
newExpanded.delete(orderId);
|
||||
} else {
|
||||
newExpanded.add(orderId);
|
||||
}
|
||||
setExpandedOrders(newExpanded);
|
||||
};
|
||||
|
||||
// Фильтруем заказы где текущая организация является поставщиком
|
||||
// В GraphQL partnerId - это ID поставщика, а organizationId - это ID создателя заказа
|
||||
const supplierOrders: SupplyOrder[] = (data?.supplyOrders || []).filter(
|
||||
(order: SupplyOrder) => {
|
||||
// Нужно найти поле partner или использовать partnerId
|
||||
// Проверяем через partnerId из схемы
|
||||
const isSupplier = order.partnerId === user?.organization?.id;
|
||||
return isSupplier;
|
||||
}
|
||||
);
|
||||
|
||||
const getStatusBadge = (status: SupplyOrder["status"]) => {
|
||||
const statusMap = {
|
||||
PENDING: {
|
||||
label: "Ожидает одобрения",
|
||||
color: "bg-yellow-500/20 text-yellow-300 border-yellow-500/30",
|
||||
icon: Clock,
|
||||
},
|
||||
SUPPLIER_APPROVED: {
|
||||
label: "Ожидает подтверждения логистики",
|
||||
color: "bg-blue-500/20 text-blue-300 border-blue-500/30",
|
||||
icon: Clock,
|
||||
},
|
||||
LOGISTICS_CONFIRMED: {
|
||||
label: "Готов к отправке",
|
||||
color: "bg-cyan-500/20 text-cyan-300 border-cyan-500/30",
|
||||
icon: CheckCircle,
|
||||
},
|
||||
SHIPPED: {
|
||||
label: "Отправлено",
|
||||
color: "bg-orange-500/20 text-orange-300 border-orange-500/30",
|
||||
icon: Truck,
|
||||
},
|
||||
DELIVERED: {
|
||||
label: "Доставлено",
|
||||
color: "bg-green-500/20 text-green-300 border-green-500/30",
|
||||
icon: Package,
|
||||
},
|
||||
CANCELLED: {
|
||||
label: "Отменено",
|
||||
color: "bg-red-500/20 text-red-300 border-red-500/30",
|
||||
icon: XCircle,
|
||||
},
|
||||
};
|
||||
const { label, color, icon: Icon } = statusMap[status];
|
||||
return (
|
||||
<Badge className={`${color} border flex items-center gap-1 text-xs`}>
|
||||
<Icon className="h-3 w-3" />
|
||||
{label}
|
||||
</Badge>
|
||||
);
|
||||
};
|
||||
|
||||
const handleApproveOrder = async (orderId: string) => {
|
||||
await supplierApproveOrder({ variables: { id: orderId } });
|
||||
};
|
||||
|
||||
const handleRejectOrder = async (orderId: string) => {
|
||||
await supplierRejectOrder({
|
||||
variables: { id: orderId, reason: rejectReason || undefined },
|
||||
});
|
||||
};
|
||||
|
||||
const handleShipOrder = async (orderId: string) => {
|
||||
await supplierShipOrder({ variables: { id: orderId } });
|
||||
};
|
||||
|
||||
const formatDate = (dateString: string) => {
|
||||
return new Date(dateString).toLocaleDateString("ru-RU", {
|
||||
day: "2-digit",
|
||||
month: "2-digit",
|
||||
year: "numeric",
|
||||
});
|
||||
};
|
||||
|
||||
const formatCurrency = (amount: number) => {
|
||||
return new Intl.NumberFormat("ru-RU", {
|
||||
style: "currency",
|
||||
currency: "RUB",
|
||||
}).format(amount);
|
||||
};
|
||||
|
||||
const getInitials = (name: string): string => {
|
||||
return name
|
||||
.split(" ")
|
||||
.map((word) => word.charAt(0))
|
||||
.join("")
|
||||
.toUpperCase()
|
||||
.slice(0, 2);
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="h-screen flex overflow-hidden">
|
||||
<Sidebar />
|
||||
<main className={`flex-1 ${getSidebarMargin()} px-4 py-3 flex flex-col transition-all duration-300 overflow-hidden`}>
|
||||
<div className="flex-1 overflow-y-auto flex items-center justify-center">
|
||||
<div className="text-white">Загрузка заказов...</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="h-screen flex overflow-hidden">
|
||||
<Sidebar />
|
||||
<main className={`flex-1 ${getSidebarMargin()} px-4 py-3 flex flex-col transition-all duration-300 overflow-hidden`}>
|
||||
<div className="flex-1 overflow-y-auto flex items-center justify-center">
|
||||
<div className="text-red-300">Ошибка загрузки заказов: {error.message}</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-screen flex overflow-hidden">
|
||||
<Sidebar />
|
||||
<main className={`flex-1 ${getSidebarMargin()} px-4 py-3 flex flex-col transition-all duration-300 overflow-hidden`}>
|
||||
<main
|
||||
className={`flex-1 ${getSidebarMargin()} px-4 py-3 flex flex-col transition-all duration-300 overflow-hidden`}
|
||||
>
|
||||
<div className="flex-1 overflow-y-auto space-y-6">
|
||||
{/* Заголовок */}
|
||||
{/* Заголовок страницы */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-white mb-2">
|
||||
Заказы поставок
|
||||
</h1>
|
||||
<h1 className="text-2xl font-bold text-white mb-2">Заявки</h1>
|
||||
<p className="text-white/60">
|
||||
Управление входящими заказами от фулфилмент-центров
|
||||
Управление входящими заявками от заказчиков согласно правилам
|
||||
системы
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Статистика */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<Card className="bg-white/10 backdrop-blur border-white/20 p-4">
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="p-2 bg-yellow-500/20 rounded">
|
||||
<Clock className="h-5 w-5 text-yellow-400" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-white/60 text-sm">Ожидают одобрения</p>
|
||||
<p className="text-xl font-bold text-white">
|
||||
{supplierOrders.filter(order => order.status === "PENDING").length}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="bg-white/10 backdrop-blur border-white/20 p-4">
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="p-2 bg-cyan-500/20 rounded">
|
||||
<CheckCircle className="h-5 w-5 text-cyan-400" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-white/60 text-sm">Готово к отправке</p>
|
||||
<p className="text-xl font-bold text-white">
|
||||
{supplierOrders.filter(order => order.status === "LOGISTICS_CONFIRMED").length}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="bg-white/10 backdrop-blur border-white/20 p-4">
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="p-2 bg-orange-500/20 rounded">
|
||||
<Truck className="h-5 w-5 text-orange-400" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-white/60 text-sm">В пути</p>
|
||||
<p className="text-xl font-bold text-white">
|
||||
{supplierOrders.filter(order => order.status === "SHIPPED").length}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="bg-white/10 backdrop-blur border-white/20 p-4">
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="p-2 bg-green-500/20 rounded">
|
||||
<Package className="h-5 w-5 text-green-400" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-white/60 text-sm">Доставлено</p>
|
||||
<p className="text-xl font-bold text-white">
|
||||
{supplierOrders.filter(order => order.status === "DELIVERED").length}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Список заказов */}
|
||||
<div className="space-y-4">
|
||||
{supplierOrders.length === 0 ? (
|
||||
<Card className="bg-white/10 backdrop-blur border-white/20 p-8">
|
||||
<div className="text-center">
|
||||
<Package className="h-12 w-12 text-white/40 mx-auto mb-4" />
|
||||
<h3 className="text-lg font-semibold text-white mb-2">
|
||||
Нет заказов поставок
|
||||
</h3>
|
||||
<p className="text-white/60">
|
||||
Входящие заказы от фулфилмент-центров будут отображаться здесь
|
||||
</p>
|
||||
</div>
|
||||
</Card>
|
||||
) : (
|
||||
supplierOrders.map((order) => (
|
||||
<Card
|
||||
key={order.id}
|
||||
className="bg-white/10 backdrop-blur border-white/20 overflow-hidden hover:bg-white/15 transition-colors cursor-pointer"
|
||||
onClick={() => toggleOrderExpansion(order.id)}
|
||||
>
|
||||
{/* Основная информация о заказе */}
|
||||
<div className="p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
{/* Левая часть */}
|
||||
<div className="flex items-center space-x-4 flex-1 min-w-0">
|
||||
{/* Номер заказа */}
|
||||
<div className="flex items-center space-x-2">
|
||||
<Hash className="h-4 w-4 text-white/60" />
|
||||
<span className="text-white font-semibold">
|
||||
{order.id.slice(-8)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Заказчик */}
|
||||
<div className="flex items-center space-x-3 min-w-0">
|
||||
<Avatar className="w-8 h-8">
|
||||
<AvatarFallback className="bg-blue-500 text-white text-sm">
|
||||
{getInitials(order.organization.name || order.organization.fullName || "ФФ")}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="min-w-0">
|
||||
<h3 className="text-white font-medium text-sm truncate">
|
||||
{order.organization.name || order.organization.fullName}
|
||||
</h3>
|
||||
<p className="text-white/60 text-xs">
|
||||
{order.organization.type === "FULFILLMENT" ? "Фулфилмент" : "Организация"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Краткая информация */}
|
||||
<div className="hidden lg:flex items-center space-x-4">
|
||||
<div className="flex items-center space-x-1">
|
||||
<Calendar className="h-4 w-4 text-blue-400" />
|
||||
<span className="text-white text-sm">
|
||||
{formatDate(order.deliveryDate)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center space-x-1">
|
||||
<Package className="h-4 w-4 text-green-400" />
|
||||
<span className="text-white text-sm">
|
||||
{order.totalItems} шт.
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Правая часть - статус и действия */}
|
||||
<div className="flex items-center space-x-3 flex-shrink-0">
|
||||
{getStatusBadge(order.status)}
|
||||
|
||||
{/* Кнопки действий для поставщика */}
|
||||
{order.status === "PENDING" && (
|
||||
<div className="flex items-center space-x-2">
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleApproveOrder(order.id);
|
||||
}}
|
||||
className="bg-green-500/20 hover:bg-green-500/30 text-green-300 border border-green-500/30 text-xs px-3 py-1 h-7"
|
||||
>
|
||||
<CheckCircle className="h-3 w-3 mr-1" />
|
||||
Одобрить
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setShowRejectModal(order.id);
|
||||
}}
|
||||
className="bg-red-500/20 hover:bg-red-500/30 text-red-300 border border-red-500/30 text-xs px-3 py-1 h-7"
|
||||
>
|
||||
<XCircle className="h-3 w-3 mr-1" />
|
||||
Отклонить
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{order.status === "LOGISTICS_CONFIRMED" && (
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleShipOrder(order.id);
|
||||
}}
|
||||
className="bg-orange-500/20 hover:bg-orange-500/30 text-orange-300 border border-orange-500/30 text-xs px-3 py-1 h-7"
|
||||
>
|
||||
<Truck className="h-3 w-3 mr-1" />
|
||||
Отправить
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Развернутые детали */}
|
||||
{expandedOrders.has(order.id) && (
|
||||
<>
|
||||
<Separator className="my-4 bg-white/10" />
|
||||
|
||||
{/* Сумма заказа */}
|
||||
<div className="mb-4 p-3 bg-white/5 rounded">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-white/60">Общая сумма:</span>
|
||||
<span className="text-white font-semibold text-lg">
|
||||
{formatCurrency(order.totalAmount)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Информация о логистике */}
|
||||
{order.logisticsPartner && (
|
||||
<div className="mb-4">
|
||||
<h4 className="text-white font-semibold mb-2 flex items-center text-sm">
|
||||
<Truck className="h-4 w-4 mr-2 text-purple-400" />
|
||||
Логистическая компания
|
||||
</h4>
|
||||
<div className="bg-white/5 rounded p-3">
|
||||
<p className="text-white">
|
||||
{order.logisticsPartner.name || order.logisticsPartner.fullName}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Список товаров */}
|
||||
<div>
|
||||
<h4 className="text-white font-semibold mb-3 flex items-center text-sm">
|
||||
<Package className="h-4 w-4 mr-2 text-green-400" />
|
||||
Товары ({order.items.length})
|
||||
</h4>
|
||||
<div className="space-y-2">
|
||||
{order.items.map((item) => (
|
||||
<div
|
||||
key={item.id}
|
||||
className="bg-white/5 rounded p-3 flex items-center justify-between"
|
||||
>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h5 className="text-white font-medium text-sm">
|
||||
{item.product.name}
|
||||
</h5>
|
||||
<p className="text-white/60 text-xs">
|
||||
Артикул: {item.product.article}
|
||||
</p>
|
||||
{item.product.category && (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="bg-blue-500/20 text-blue-300 text-xs mt-1"
|
||||
>
|
||||
{item.product.category.name}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-right flex-shrink-0 ml-4">
|
||||
<p className="text-white font-semibold">
|
||||
{item.quantity} шт.
|
||||
</p>
|
||||
<p className="text-white/60 text-xs">
|
||||
{formatCurrency(item.price)}
|
||||
</p>
|
||||
<p className="text-green-400 font-semibold text-sm">
|
||||
{formatCurrency(item.totalPrice)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
{/* Основной интерфейс заявок */}
|
||||
<SupplierOrdersTabs />
|
||||
</div>
|
||||
|
||||
{/* Модальное окно для отклонения заказа */}
|
||||
{showRejectModal && (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
|
||||
<Card className="bg-gray-900 border-white/20 p-6 max-w-md w-full mx-4">
|
||||
<h3 className="text-white font-semibold text-lg mb-4">
|
||||
Отклонить заказ
|
||||
</h3>
|
||||
<p className="text-white/60 text-sm mb-4">
|
||||
Укажите причину отклонения заказа (необязательно):
|
||||
</p>
|
||||
<textarea
|
||||
value={rejectReason}
|
||||
onChange={(e) => setRejectReason(e.target.value)}
|
||||
placeholder="Причина отклонения..."
|
||||
className="w-full bg-white/10 border border-white/20 rounded-md px-3 py-2 text-white text-sm focus:outline-none focus:ring-1 focus:ring-red-500 focus:border-transparent mb-4"
|
||||
rows={3}
|
||||
/>
|
||||
<div className="flex items-center space-x-3">
|
||||
<Button
|
||||
onClick={() => handleRejectOrder(showRejectModal)}
|
||||
className="bg-red-500/20 hover:bg-red-500/30 text-red-300 border border-red-500/30"
|
||||
>
|
||||
Отклонить заказ
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => {
|
||||
setShowRejectModal(null);
|
||||
setRejectReason("");
|
||||
}}
|
||||
variant="outline"
|
||||
className="border-white/20 text-white hover:bg-white/10"
|
||||
>
|
||||
Отмена
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
|
210
src/components/supplier-orders/supplier-orders-search.tsx
Normal file
210
src/components/supplier-orders/supplier-orders-search.tsx
Normal file
@ -0,0 +1,210 @@
|
||||
"use client";
|
||||
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover";
|
||||
import {
|
||||
Search,
|
||||
Filter,
|
||||
Calendar,
|
||||
DollarSign,
|
||||
Package,
|
||||
Building,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
|
||||
interface SupplierOrdersSearchProps {
|
||||
searchQuery: string;
|
||||
onSearchChange: (value: string) => void;
|
||||
priceRange: { min: string; max: string };
|
||||
onPriceRangeChange: (range: { min: string; max: string }) => void;
|
||||
dateFilter: string;
|
||||
onDateFilterChange: (value: string) => void;
|
||||
}
|
||||
|
||||
export function SupplierOrdersSearch({
|
||||
searchQuery,
|
||||
onSearchChange,
|
||||
priceRange,
|
||||
onPriceRangeChange,
|
||||
dateFilter,
|
||||
onDateFilterChange,
|
||||
}: SupplierOrdersSearchProps) {
|
||||
const hasActiveFilters = priceRange.min || priceRange.max || dateFilter;
|
||||
|
||||
const clearFilters = () => {
|
||||
onPriceRangeChange({ min: "", max: "" });
|
||||
onDateFilterChange("");
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className="glass-card border-white/10 p-4">
|
||||
<div className="flex flex-col md:flex-row gap-4">
|
||||
{/* Поисковая строка */}
|
||||
<div className="flex-1 relative">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-white/60" />
|
||||
<Input
|
||||
placeholder="Поиск по номеру заявки, заказчику, товарам, ИНН..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => onSearchChange(e.target.value)}
|
||||
className="glass-input text-white placeholder:text-white/50 pl-10"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Фильтры */}
|
||||
<div className="flex items-center space-x-2">
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className={`glass-secondary border-white/20 ${
|
||||
hasActiveFilters ? "border-blue-400/50 bg-blue-500/10" : ""
|
||||
}`}
|
||||
>
|
||||
<Filter className="h-4 w-4 mr-2" />
|
||||
Фильтры
|
||||
{hasActiveFilters && (
|
||||
<span className="ml-2 bg-blue-500/20 text-blue-300 px-2 py-1 rounded text-xs">
|
||||
Активны
|
||||
</span>
|
||||
)}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="glass-card border-white/20 w-80">
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h4 className="text-white font-semibold">Фильтры поиска</h4>
|
||||
{hasActiveFilters && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={clearFilters}
|
||||
className="text-white/60 hover:text-white"
|
||||
>
|
||||
<X className="h-4 w-4 mr-1" />
|
||||
Очистить
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Фильтр по дате */}
|
||||
<div className="space-y-2">
|
||||
<Label className="text-white/90 flex items-center">
|
||||
<Calendar className="h-4 w-4 mr-2" />
|
||||
Период создания заявки
|
||||
</Label>
|
||||
<Input
|
||||
type="date"
|
||||
value={dateFilter}
|
||||
onChange={(e) => onDateFilterChange(e.target.value)}
|
||||
className="glass-input text-white"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Фильтр по стоимости */}
|
||||
<div className="space-y-2">
|
||||
<Label className="text-white/90 flex items-center">
|
||||
<DollarSign className="h-4 w-4 mr-2" />
|
||||
Диапазон стоимости (₽)
|
||||
</Label>
|
||||
<div className="flex space-x-2">
|
||||
<Input
|
||||
type="number"
|
||||
placeholder="От"
|
||||
value={priceRange.min}
|
||||
onChange={(e) =>
|
||||
onPriceRangeChange({
|
||||
...priceRange,
|
||||
min: e.target.value,
|
||||
})
|
||||
}
|
||||
className="glass-input text-white placeholder:text-white/50 w-24"
|
||||
/>
|
||||
<span className="text-white/60 self-center">—</span>
|
||||
<Input
|
||||
type="number"
|
||||
placeholder="До"
|
||||
value={priceRange.max}
|
||||
onChange={(e) =>
|
||||
onPriceRangeChange({
|
||||
...priceRange,
|
||||
max: e.target.value,
|
||||
})
|
||||
}
|
||||
className="glass-input text-white placeholder:text-white/50 w-24"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Информация о поиске */}
|
||||
<div className="pt-2 border-t border-white/10">
|
||||
<p className="text-white/60 text-xs">
|
||||
💡 <strong>Поиск работает по:</strong>
|
||||
</p>
|
||||
<ul className="text-white/60 text-xs mt-1 space-y-1">
|
||||
<li>• Номеру заявки (СФ-2024-XXX)</li>
|
||||
<li>• Названию заказчика</li>
|
||||
<li>• Названию товаров</li>
|
||||
<li>• ИНН заказчика</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
{/* Быстрые фильтры */}
|
||||
<div className="hidden lg:flex items-center space-x-2 text-white/60 text-sm">
|
||||
<span>Быстро:</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
onDateFilterChange(new Date().toISOString().split("T")[0])
|
||||
}
|
||||
className="text-xs h-7 px-2"
|
||||
>
|
||||
Сегодня
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
const weekAgo = new Date();
|
||||
weekAgo.setDate(weekAgo.getDate() - 7);
|
||||
onDateFilterChange(weekAgo.toISOString().split("T")[0]);
|
||||
}}
|
||||
className="text-xs h-7 px-2"
|
||||
>
|
||||
Неделя
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Активные фильтры */}
|
||||
{hasActiveFilters && (
|
||||
<div className="mt-3 pt-3 border-t border-white/10">
|
||||
<div className="flex items-center space-x-2 text-sm">
|
||||
<span className="text-white/60">Активные фильтры:</span>
|
||||
{dateFilter && (
|
||||
<span className="bg-blue-500/20 text-blue-300 px-2 py-1 rounded border border-blue-400/30">
|
||||
📅 {new Date(dateFilter).toLocaleDateString("ru-RU")}
|
||||
</span>
|
||||
)}
|
||||
{(priceRange.min || priceRange.max) && (
|
||||
<span className="bg-green-500/20 text-green-300 px-2 py-1 rounded border border-green-400/30">
|
||||
💰 {priceRange.min || "0"}₽ — {priceRange.max || "∞"}₽
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
309
src/components/supplier-orders/supplier-orders-tabs.tsx
Normal file
309
src/components/supplier-orders/supplier-orders-tabs.tsx
Normal file
@ -0,0 +1,309 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useMemo } from "react";
|
||||
import { useQuery } from "@apollo/client";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { GET_SUPPLY_ORDERS } from "@/graphql/queries";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { SupplierOrderCard } from "./supplier-order-card";
|
||||
import { SupplierOrderStats } from "./supplier-order-stats";
|
||||
import { SupplierOrdersSearch } from "./supplier-orders-search";
|
||||
import {
|
||||
Clock,
|
||||
CheckCircle,
|
||||
Settings,
|
||||
Truck,
|
||||
Package,
|
||||
Calendar,
|
||||
Search,
|
||||
} from "lucide-react";
|
||||
|
||||
interface SupplyOrder {
|
||||
id: string;
|
||||
organizationId: string;
|
||||
partnerId: string;
|
||||
deliveryDate: string;
|
||||
status:
|
||||
| "PENDING"
|
||||
| "SUPPLIER_APPROVED"
|
||||
| "CONFIRMED"
|
||||
| "LOGISTICS_CONFIRMED"
|
||||
| "SHIPPED"
|
||||
| "IN_TRANSIT"
|
||||
| "DELIVERED"
|
||||
| "CANCELLED";
|
||||
totalAmount: number;
|
||||
totalItems: number;
|
||||
createdAt: string;
|
||||
organization: {
|
||||
id: string;
|
||||
name?: string;
|
||||
fullName?: string;
|
||||
type: string;
|
||||
inn?: string;
|
||||
};
|
||||
partner?: {
|
||||
id: string;
|
||||
name?: string;
|
||||
fullName?: string;
|
||||
inn?: string;
|
||||
address?: string;
|
||||
phones?: string[];
|
||||
emails?: string[];
|
||||
};
|
||||
fulfillmentCenter?: {
|
||||
id: string;
|
||||
name?: string;
|
||||
fullName?: string;
|
||||
type: string;
|
||||
};
|
||||
logisticsPartner?: {
|
||||
id: string;
|
||||
name?: string;
|
||||
fullName?: string;
|
||||
type: string;
|
||||
};
|
||||
items: Array<{
|
||||
id: string;
|
||||
quantity: number;
|
||||
price: number;
|
||||
totalPrice: number;
|
||||
product: {
|
||||
id: string;
|
||||
name: string;
|
||||
article: string;
|
||||
description?: string;
|
||||
category?: {
|
||||
id: string;
|
||||
name: string;
|
||||
};
|
||||
};
|
||||
}>;
|
||||
}
|
||||
|
||||
export function SupplierOrdersTabs() {
|
||||
const { user } = useAuth();
|
||||
const [activeTab, setActiveTab] = useState("new");
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [dateFilter, setDateFilter] = useState("");
|
||||
const [priceRange, setPriceRange] = useState({ min: "", max: "" });
|
||||
|
||||
// Загружаем заказы поставок
|
||||
const { data, loading, error } = useQuery(GET_SUPPLY_ORDERS, {
|
||||
fetchPolicy: "cache-and-network",
|
||||
});
|
||||
|
||||
// Фильтруем заказы где текущая организация является поставщиком
|
||||
const supplierOrders: SupplyOrder[] = useMemo(() => {
|
||||
return (data?.supplyOrders || []).filter(
|
||||
(order: SupplyOrder) => order.partnerId === user?.organization?.id
|
||||
);
|
||||
}, [data?.supplyOrders, user?.organization?.id]);
|
||||
|
||||
// Фильтрация заказов по поисковому запросу
|
||||
const filteredOrders = useMemo(() => {
|
||||
let filtered = supplierOrders;
|
||||
|
||||
// Поиск по номеру заявки, заказчику, товарам, ИНН
|
||||
if (searchQuery) {
|
||||
const query = searchQuery.toLowerCase();
|
||||
filtered = filtered.filter(
|
||||
(order) =>
|
||||
order.id.toLowerCase().includes(query) ||
|
||||
(order.organization.name || "").toLowerCase().includes(query) ||
|
||||
(order.organization.fullName || "").toLowerCase().includes(query) ||
|
||||
(order.organization.inn || "").toLowerCase().includes(query) ||
|
||||
order.items.some((item) =>
|
||||
item.product.name.toLowerCase().includes(query)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// Фильтр по диапазону цены
|
||||
if (priceRange.min || priceRange.max) {
|
||||
filtered = filtered.filter((order) => {
|
||||
if (priceRange.min && order.totalAmount < parseFloat(priceRange.min))
|
||||
return false;
|
||||
if (priceRange.max && order.totalAmount > parseFloat(priceRange.max))
|
||||
return false;
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
return filtered;
|
||||
}, [supplierOrders, searchQuery, priceRange]);
|
||||
|
||||
// Разделение заказов по статусам согласно правилам
|
||||
const ordersByStatus = useMemo(() => {
|
||||
return {
|
||||
new: filteredOrders.filter((order) => order.status === "PENDING"),
|
||||
approved: filteredOrders.filter(
|
||||
(order) => order.status === "SUPPLIER_APPROVED"
|
||||
),
|
||||
inProgress: filteredOrders.filter((order) =>
|
||||
["CONFIRMED", "LOGISTICS_CONFIRMED"].includes(order.status)
|
||||
),
|
||||
shipping: filteredOrders.filter((order) =>
|
||||
["SHIPPED", "IN_TRANSIT"].includes(order.status)
|
||||
),
|
||||
completed: filteredOrders.filter((order) => order.status === "DELIVERED"),
|
||||
all: filteredOrders,
|
||||
};
|
||||
}, [filteredOrders]);
|
||||
|
||||
const getTabBadgeCount = (tabKey: string) => {
|
||||
return ordersByStatus[tabKey as keyof typeof ordersByStatus]?.length || 0;
|
||||
};
|
||||
|
||||
const getCurrentOrders = () => {
|
||||
return ordersByStatus[activeTab as keyof typeof ordersByStatus] || [];
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="text-white/60">Загрузка заявок...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="text-red-400">
|
||||
Ошибка загрузки заявок: {error.message}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Статистика - Модуль 2 согласно правилам */}
|
||||
<SupplierOrderStats orders={supplierOrders} />
|
||||
|
||||
{/* Блок табов - отдельный блок согласно visual-design-rules.md */}
|
||||
<div className="bg-white/10 backdrop-blur-xl border border-white/20 rounded-2xl p-6">
|
||||
<Tabs value={activeTab} onValueChange={setActiveTab} className="w-full">
|
||||
<TabsList className="bg-transparent p-0 space-x-2">
|
||||
{/* Уровень 2: Фильтрация по статусам */}
|
||||
<TabsTrigger
|
||||
value="new"
|
||||
className="h-9 bg-white/8 border-white/20 rounded-lg font-medium ml-0 data-[state=active]:bg-white/15 data-[state=active]:text-white"
|
||||
>
|
||||
<Clock className="h-4 w-4 mr-2" />
|
||||
Новые
|
||||
{getTabBadgeCount("new") > 0 && (
|
||||
<Badge className="ml-2 bg-red-500/20 text-red-300 border-red-400/30">
|
||||
{getTabBadgeCount("new")}
|
||||
</Badge>
|
||||
)}
|
||||
</TabsTrigger>
|
||||
|
||||
<TabsTrigger
|
||||
value="approved"
|
||||
className="h-9 bg-white/8 border-white/20 rounded-lg font-medium ml-4 data-[state=active]:bg-white/15 data-[state=active]:text-white"
|
||||
>
|
||||
<CheckCircle className="h-4 w-4 mr-2" />
|
||||
Одобренные
|
||||
{getTabBadgeCount("approved") > 0 && (
|
||||
<Badge className="ml-2 bg-green-500/20 text-green-300 border-green-400/30">
|
||||
{getTabBadgeCount("approved")}
|
||||
</Badge>
|
||||
)}
|
||||
</TabsTrigger>
|
||||
|
||||
<TabsTrigger
|
||||
value="inProgress"
|
||||
className="h-9 bg-white/8 border-white/20 rounded-lg font-medium ml-4 data-[state=active]:bg-white/15 data-[state=active]:text-white"
|
||||
>
|
||||
<Settings className="h-4 w-4 mr-2" />В работе
|
||||
{getTabBadgeCount("inProgress") > 0 && (
|
||||
<Badge className="ml-2 bg-blue-500/20 text-blue-300 border-blue-400/30">
|
||||
{getTabBadgeCount("inProgress")}
|
||||
</Badge>
|
||||
)}
|
||||
</TabsTrigger>
|
||||
|
||||
<TabsTrigger
|
||||
value="shipping"
|
||||
className="h-9 bg-white/8 border-white/20 rounded-lg font-medium ml-4 data-[state=active]:bg-white/15 data-[state=active]:text-white"
|
||||
>
|
||||
<Truck className="h-4 w-4 mr-2" />
|
||||
Отгрузка
|
||||
{getTabBadgeCount("shipping") > 0 && (
|
||||
<Badge className="ml-2 bg-orange-500/20 text-orange-300 border-orange-400/30">
|
||||
{getTabBadgeCount("shipping")}
|
||||
</Badge>
|
||||
)}
|
||||
</TabsTrigger>
|
||||
|
||||
<TabsTrigger
|
||||
value="completed"
|
||||
className="h-9 bg-white/8 border-white/20 rounded-lg font-medium ml-4 data-[state=active]:bg-white/15 data-[state=active]:text-white"
|
||||
>
|
||||
<Package className="h-4 w-4 mr-2" />
|
||||
Завершенные
|
||||
{getTabBadgeCount("completed") > 0 && (
|
||||
<Badge className="ml-2 bg-emerald-500/20 text-emerald-300 border-emerald-400/30">
|
||||
{getTabBadgeCount("completed")}
|
||||
</Badge>
|
||||
)}
|
||||
</TabsTrigger>
|
||||
|
||||
<TabsTrigger
|
||||
value="all"
|
||||
className="h-9 bg-white/8 border-white/20 rounded-lg font-medium ml-4 data-[state=active]:bg-white/15 data-[state=active]:text-white"
|
||||
>
|
||||
Все заявки
|
||||
{getTabBadgeCount("all") > 0 && (
|
||||
<Badge className="ml-2 bg-white/20 text-white/70 border-white/30">
|
||||
{getTabBadgeCount("all")}
|
||||
</Badge>
|
||||
)}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
</div>
|
||||
|
||||
{/* Поиск и фильтры */}
|
||||
<SupplierOrdersSearch
|
||||
searchQuery={searchQuery}
|
||||
onSearchChange={setSearchQuery}
|
||||
priceRange={priceRange}
|
||||
onPriceRangeChange={setPriceRange}
|
||||
dateFilter={dateFilter}
|
||||
onDateFilterChange={setDateFilter}
|
||||
/>
|
||||
|
||||
{/* Рабочее пространство - отдельный блок */}
|
||||
<div className="bg-white/10 backdrop-blur-xl border border-white/20 rounded-2xl">
|
||||
<div className="p-6">
|
||||
{getCurrentOrders().length === 0 ? (
|
||||
<div className="text-center py-12">
|
||||
<Package className="h-12 w-12 text-white/40 mx-auto mb-4" />
|
||||
<h3 className="text-lg font-semibold text-white mb-2">
|
||||
{activeTab === "new" ? "Нет новых заявок" : "Заявки не найдены"}
|
||||
</h3>
|
||||
<p className="text-white/60">
|
||||
{activeTab === "new"
|
||||
? "Новые заявки от заказчиков будут отображаться здесь"
|
||||
: "Попробуйте изменить фильтры поиска"}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{getCurrentOrders().map((order) => (
|
||||
<SupplierOrderCard key={order.id} order={order} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
@ -5,6 +5,7 @@ import { useRouter } from "next/navigation";
|
||||
import { useQuery, useMutation } from "@apollo/client";
|
||||
import { Sidebar } from "@/components/dashboard/sidebar";
|
||||
import { useSidebar } from "@/hooks/useSidebar";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
@ -77,6 +78,7 @@ interface SelectedConsumable {
|
||||
|
||||
export function CreateConsumablesSupplyPage() {
|
||||
const router = useRouter();
|
||||
const { user } = useAuth();
|
||||
const { getSidebarMargin } = useSidebar();
|
||||
const [selectedSupplier, setSelectedSupplier] =
|
||||
useState<ConsumableSupplier | null>(null);
|
||||
@ -88,6 +90,8 @@ export function CreateConsumablesSupplyPage() {
|
||||
const [deliveryDate, setDeliveryDate] = useState("");
|
||||
const [selectedFulfillmentCenter, setSelectedFulfillmentCenter] =
|
||||
useState<ConsumableSupplier | null>(null);
|
||||
const [selectedLogistics, setSelectedLogistics] =
|
||||
useState<ConsumableSupplier | null>(null);
|
||||
const [isCreatingSupply, setIsCreatingSupply] = useState(false);
|
||||
|
||||
// Загружаем контрагентов-поставщиков расходников
|
||||
@ -117,6 +121,11 @@ export function CreateConsumablesSupplyPage() {
|
||||
counterpartiesData?.myCounterparties || []
|
||||
).filter((org: ConsumableSupplier) => org.type === "FULFILLMENT");
|
||||
|
||||
// Фильтруем логистические компании
|
||||
const logisticsPartners = (counterpartiesData?.myCounterparties || []).filter(
|
||||
(org: ConsumableSupplier) => org.type === "LOGIST"
|
||||
);
|
||||
|
||||
// Фильтруем поставщиков по поисковому запросу
|
||||
const filteredSuppliers = consumableSuppliers.filter(
|
||||
(supplier: ConsumableSupplier) =>
|
||||
@ -218,19 +227,82 @@ export function CreateConsumablesSupplyPage() {
|
||||
selectedConsumables.length === 0 ||
|
||||
!deliveryDate
|
||||
) {
|
||||
toast.error("Заполните все обязательные поля");
|
||||
toast.error(
|
||||
"Заполните все обязательные поля: поставщик, расходники и дата доставки"
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Для селлеров требуется выбор фулфилмент-центра
|
||||
// TODO: Добавить проверку типа текущей организации
|
||||
if (!selectedFulfillmentCenter) {
|
||||
toast.error("Выберите фулфилмент-центр для доставки");
|
||||
return;
|
||||
}
|
||||
|
||||
// Логистика опциональна - может выбрать селлер или оставить фулфилменту
|
||||
if (selectedLogistics && !selectedLogistics.id) {
|
||||
toast.error("Некорректно выбрана логистическая компания");
|
||||
return;
|
||||
}
|
||||
|
||||
// Дополнительные проверки
|
||||
if (!selectedFulfillmentCenter.id) {
|
||||
toast.error("ID фулфилмент-центра не найден");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!selectedSupplier.id) {
|
||||
toast.error("ID поставщика не найден");
|
||||
return;
|
||||
}
|
||||
|
||||
if (selectedConsumables.length === 0) {
|
||||
toast.error("Не выбраны расходники");
|
||||
return;
|
||||
}
|
||||
|
||||
// Проверяем дату
|
||||
const deliveryDateObj = new Date(deliveryDate);
|
||||
if (isNaN(deliveryDateObj.getTime())) {
|
||||
toast.error("Некорректная дата поставки");
|
||||
return;
|
||||
}
|
||||
|
||||
setIsCreatingSupply(true);
|
||||
|
||||
// 🔍 ОТЛАДКА: проверяем текущего пользователя
|
||||
console.log("👤 Текущий пользователь:", {
|
||||
userId: user?.id,
|
||||
phone: user?.phone,
|
||||
organizationId: user?.organization?.id,
|
||||
organizationType: user?.organization?.type,
|
||||
organizationName:
|
||||
user?.organization?.name || user?.organization?.fullName,
|
||||
});
|
||||
|
||||
console.log("🚀 Создаем поставку с данными:", {
|
||||
partnerId: selectedSupplier.id,
|
||||
deliveryDate: deliveryDate,
|
||||
fulfillmentCenterId: selectedFulfillmentCenter.id,
|
||||
logisticsPartnerId: selectedLogistics?.id,
|
||||
hasLogistics: !!selectedLogistics?.id,
|
||||
consumableType: "SELLER_CONSUMABLES",
|
||||
itemsCount: selectedConsumables.length,
|
||||
mutationInput: {
|
||||
partnerId: selectedSupplier.id,
|
||||
deliveryDate: deliveryDate,
|
||||
fulfillmentCenterId: selectedFulfillmentCenter.id,
|
||||
...(selectedLogistics?.id
|
||||
? { logisticsPartnerId: selectedLogistics.id }
|
||||
: {}),
|
||||
consumableType: "SELLER_CONSUMABLES",
|
||||
items: selectedConsumables.map((consumable) => ({
|
||||
productId: consumable.id,
|
||||
quantity: consumable.selectedQuantity,
|
||||
})),
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
const result = await createSupplyOrder({
|
||||
variables: {
|
||||
@ -238,6 +310,12 @@ export function CreateConsumablesSupplyPage() {
|
||||
partnerId: selectedSupplier.id,
|
||||
deliveryDate: deliveryDate,
|
||||
fulfillmentCenterId: selectedFulfillmentCenter.id,
|
||||
// 🔄 ЛОГИСТИКА ОПЦИОНАЛЬНА: селлер может выбрать или оставить фулфилменту
|
||||
...(selectedLogistics?.id
|
||||
? { logisticsPartnerId: selectedLogistics.id }
|
||||
: {}),
|
||||
// 🏷️ КЛАССИФИКАЦИЯ согласно правилам (раздел 2.2)
|
||||
consumableType: "SELLER_CONSUMABLES", // Расходники селлеров
|
||||
items: selectedConsumables.map((consumable) => ({
|
||||
productId: consumable.id,
|
||||
quantity: consumable.selectedQuantity,
|
||||
@ -270,7 +348,21 @@ export function CreateConsumablesSupplyPage() {
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error creating consumables supply:", error);
|
||||
toast.error("Ошибка при создании поставки расходников");
|
||||
|
||||
// Детальная диагностика ошибки
|
||||
if (error instanceof Error) {
|
||||
console.error("Error details:", {
|
||||
message: error.message,
|
||||
stack: error.stack,
|
||||
name: error.name,
|
||||
});
|
||||
|
||||
// Показываем конкретную ошибку пользователю
|
||||
toast.error(`Ошибка: ${error.message}`);
|
||||
} else {
|
||||
console.error("Unknown error:", error);
|
||||
toast.error("Ошибка при создании поставки расходников");
|
||||
}
|
||||
} finally {
|
||||
setIsCreatingSupply(false);
|
||||
}
|
||||
@ -764,7 +856,7 @@ export function CreateConsumablesSupplyPage() {
|
||||
);
|
||||
setSelectedFulfillmentCenter(center || null);
|
||||
}}
|
||||
className="w-full bg-white/10 border border-white/20 text-white h-8 text-sm rounded px-2 focus:ring-2 focus:ring-purple-400/50 focus:border-purple-400/50"
|
||||
className="w-full bg-white/10 border border-white/20 text-white h-8 text-sm rounded px-2 pr-8 focus:ring-2 focus:ring-purple-400/50 focus:border-purple-400/50 appearance-none"
|
||||
required
|
||||
>
|
||||
<option value="" className="bg-gray-800 text-white">
|
||||
@ -782,8 +874,73 @@ export function CreateConsumablesSupplyPage() {
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<div className="absolute inset-y-0 right-0 flex items-center px-2 pointer-events-none">
|
||||
<svg
|
||||
className="w-4 h-4 text-white/60"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M19 9l-7 7-7-7"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* БЛОК ВЫБОРА ЛОГИСТИЧЕСКОЙ КОМПАНИИ */}
|
||||
<div className="mb-3">
|
||||
<label className="text-white/60 text-xs mb-1 block">
|
||||
Логистическая компания:
|
||||
<span className="text-white/40 ml-1">(опционально)</span>
|
||||
</label>
|
||||
<div className="relative">
|
||||
<select
|
||||
value={selectedLogistics?.id || ""}
|
||||
onChange={(e) => {
|
||||
const logisticsId = e.target.value;
|
||||
const logistics = logisticsPartners.find(
|
||||
(p) => p.id === logisticsId
|
||||
);
|
||||
setSelectedLogistics(logistics || null);
|
||||
}}
|
||||
className="w-full bg-white/10 border border-white/20 text-white h-8 text-sm rounded px-2 pr-8 focus:ring-2 focus:ring-purple-400/50 focus:border-purple-400/50 appearance-none"
|
||||
>
|
||||
<option value="" className="bg-gray-800 text-white">
|
||||
Выберите логистику или оставьте фулфилменту
|
||||
</option>
|
||||
{logisticsPartners.map((partner) => (
|
||||
<option
|
||||
key={partner.id}
|
||||
value={partner.id}
|
||||
className="bg-gray-800 text-white"
|
||||
>
|
||||
{partner.name || partner.fullName || "Логистика"}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<div className="absolute inset-y-0 right-0 flex items-center px-2 pointer-events-none">
|
||||
<svg
|
||||
className="w-4 h-4 text-white/60"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M19 9l-7 7-7-7"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-3">
|
||||
<label className="text-white/60 text-xs mb-1 block">
|
||||
Дата поставки:
|
||||
|
@ -42,7 +42,15 @@ interface SupplyOrderItem {
|
||||
interface SupplyOrder {
|
||||
id: string;
|
||||
deliveryDate: string;
|
||||
status: "PENDING" | "CONFIRMED" | "IN_TRANSIT" | "DELIVERED" | "CANCELLED";
|
||||
status:
|
||||
| "PENDING"
|
||||
| "SUPPLIER_APPROVED"
|
||||
| "CONFIRMED"
|
||||
| "LOGISTICS_CONFIRMED"
|
||||
| "SHIPPED"
|
||||
| "IN_TRANSIT"
|
||||
| "DELIVERED"
|
||||
| "CANCELLED";
|
||||
totalAmount: number;
|
||||
totalItems: number;
|
||||
createdAt: string;
|
||||
@ -102,10 +110,22 @@ export function SellerSupplyOrdersTab() {
|
||||
label: "Ожидает одобрения",
|
||||
color: "bg-blue-500/20 text-blue-300 border-blue-500/30",
|
||||
},
|
||||
SUPPLIER_APPROVED: {
|
||||
label: "Одобрена поставщиком",
|
||||
color: "bg-cyan-500/20 text-cyan-300 border-cyan-500/30",
|
||||
},
|
||||
CONFIRMED: {
|
||||
label: "Одобрена",
|
||||
label: "Подтверждена",
|
||||
color: "bg-green-500/20 text-green-300 border-green-500/30",
|
||||
},
|
||||
LOGISTICS_CONFIRMED: {
|
||||
label: "Готова к отправке",
|
||||
color: "bg-teal-500/20 text-teal-300 border-teal-500/30",
|
||||
},
|
||||
SHIPPED: {
|
||||
label: "Отправлена",
|
||||
color: "bg-orange-500/20 text-orange-300 border-orange-500/30",
|
||||
},
|
||||
IN_TRANSIT: {
|
||||
label: "В пути",
|
||||
color: "bg-yellow-500/20 text-yellow-300 border-yellow-500/30",
|
||||
@ -119,7 +139,16 @@ export function SellerSupplyOrdersTab() {
|
||||
color: "bg-red-500/20 text-red-300 border-red-500/30",
|
||||
},
|
||||
};
|
||||
const { label, color } = statusMap[status];
|
||||
const config = statusMap[status as keyof typeof statusMap];
|
||||
if (!config) {
|
||||
// Fallback для неизвестных статусов
|
||||
return (
|
||||
<Badge className="bg-gray-500/20 text-gray-300 border-gray-500/30 border text-xs">
|
||||
{status}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
const { label, color } = config;
|
||||
return <Badge className={`${color} border text-xs`}>{label}</Badge>;
|
||||
};
|
||||
|
||||
|
@ -42,7 +42,27 @@ export function SuppliesDashboard() {
|
||||
});
|
||||
|
||||
const pendingCount = pendingData?.pendingSuppliesCount;
|
||||
const hasPendingItems = pendingCount && pendingCount.total > 0;
|
||||
// ✅ ПРАВИЛЬНО: Настраиваем уведомления по типам организаций
|
||||
const hasPendingItems = (() => {
|
||||
if (!pendingCount) return false;
|
||||
|
||||
switch (user?.organization?.type) {
|
||||
case "SELLER":
|
||||
// Селлеры не получают уведомления о поставках - только отслеживают статус
|
||||
return false;
|
||||
case "WHOLESALE":
|
||||
// Поставщики видят только входящие заказы, не заявки на партнерство
|
||||
return pendingCount.incomingSupplierOrders > 0;
|
||||
case "FULFILLMENT":
|
||||
// Фулфилмент видит только поставки к обработке, не заявки на партнерство
|
||||
return pendingCount.supplyOrders > 0;
|
||||
case "LOGIST":
|
||||
// Логистика видит только логистические заявки, не заявки на партнерство
|
||||
return pendingCount.logisticsOrders > 0;
|
||||
default:
|
||||
return pendingCount.total > 0;
|
||||
}
|
||||
})();
|
||||
|
||||
// Автоматически открываем нужную вкладку при загрузке
|
||||
useEffect(() => {
|
||||
@ -69,32 +89,33 @@ export function SuppliesDashboard() {
|
||||
<Alert className="mb-4 bg-blue-500/20 border-blue-400/30 text-blue-300 animate-pulse">
|
||||
<AlertTriangle className="h-4 w-4" />
|
||||
<AlertDescription>
|
||||
У вас есть {pendingCount.total} элемент
|
||||
{pendingCount.total > 1
|
||||
? pendingCount.total < 5
|
||||
? "а"
|
||||
: "ов"
|
||||
: ""}
|
||||
, требующ{pendingCount.total > 1 ? "их" : "ий"} одобрения:
|
||||
{pendingCount.supplyOrders > 0 &&
|
||||
` ${pendingCount.supplyOrders} заказ${
|
||||
pendingCount.supplyOrders > 1
|
||||
? pendingCount.supplyOrders < 5
|
||||
? "а"
|
||||
: "ов"
|
||||
: ""
|
||||
} поставок`}
|
||||
{pendingCount.incomingRequests > 0 &&
|
||||
pendingCount.supplyOrders > 0 &&
|
||||
", "}
|
||||
{pendingCount.incomingRequests > 0 &&
|
||||
` ${pendingCount.incomingRequests} заявк${
|
||||
pendingCount.incomingRequests > 1
|
||||
? pendingCount.incomingRequests < 5
|
||||
? "и"
|
||||
: ""
|
||||
: "а"
|
||||
} на партнерство`}
|
||||
{(() => {
|
||||
switch (user?.organization?.type) {
|
||||
case "WHOLESALE":
|
||||
const orders = pendingCount.incomingSupplierOrders || 0;
|
||||
return `У вас ${orders} входящ${
|
||||
orders > 1 ? (orders < 5 ? "их" : "их") : "ий"
|
||||
} заказ${
|
||||
orders > 1 ? (orders < 5 ? "а" : "ов") : ""
|
||||
} от клиентов, ожидающ${
|
||||
orders > 1 ? "их" : "ий"
|
||||
} подтверждения`;
|
||||
case "FULFILLMENT":
|
||||
const supplies = pendingCount.supplyOrders || 0;
|
||||
return `У вас ${supplies} поставк${
|
||||
supplies > 1 ? (supplies < 5 ? "и" : "ов") : "а"
|
||||
} к обработке`;
|
||||
case "LOGIST":
|
||||
const logistics = pendingCount.logisticsOrders || 0;
|
||||
return `У вас ${logistics} логистическ${
|
||||
logistics > 1 ? (logistics < 5 ? "их" : "их") : "ая"
|
||||
} заявк${
|
||||
logistics > 1 ? (logistics < 5 ? "и" : "и") : "а"
|
||||
} к подтверждению`;
|
||||
default:
|
||||
return `У вас есть элементы, требующие внимания`;
|
||||
}
|
||||
})()}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
26
src/components/ui/textarea.tsx
Normal file
26
src/components/ui/textarea.tsx
Normal file
@ -0,0 +1,26 @@
|
||||
import * as React from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export interface TextareaProps
|
||||
extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {
|
||||
// Interface extends parent interface - can be empty but needs a member for type safety
|
||||
placeholder?: string;
|
||||
}
|
||||
|
||||
const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
|
||||
({ className, ...props }, ref) => {
|
||||
return (
|
||||
<textarea
|
||||
className={cn(
|
||||
"flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
);
|
||||
Textarea.displayName = "Textarea";
|
||||
|
||||
export { Textarea };
|
22
src/graphql/context.ts
Normal file
22
src/graphql/context.ts
Normal file
@ -0,0 +1,22 @@
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
|
||||
export interface Context {
|
||||
user: {
|
||||
id: string;
|
||||
organization?: {
|
||||
id: string;
|
||||
type: string;
|
||||
};
|
||||
} | null;
|
||||
currentUser?: {
|
||||
id: string;
|
||||
organization: {
|
||||
id: string;
|
||||
type: string;
|
||||
};
|
||||
} | null;
|
||||
admin?: {
|
||||
id: string;
|
||||
} | null;
|
||||
prisma: PrismaClient;
|
||||
}
|
@ -746,6 +746,42 @@ export const CREATE_SUPPLY_ORDER = gql`
|
||||
}
|
||||
`;
|
||||
|
||||
// Мутация для назначения логистики на поставку фулфилментом
|
||||
export const ASSIGN_LOGISTICS_TO_SUPPLY = gql`
|
||||
mutation AssignLogisticsToSupply(
|
||||
$supplyOrderId: ID!
|
||||
$logisticsPartnerId: ID!
|
||||
$responsibleId: ID
|
||||
) {
|
||||
assignLogisticsToSupply(
|
||||
supplyOrderId: $supplyOrderId
|
||||
logisticsPartnerId: $logisticsPartnerId
|
||||
responsibleId: $responsibleId
|
||||
) {
|
||||
success
|
||||
message
|
||||
order {
|
||||
id
|
||||
status
|
||||
logisticsPartnerId
|
||||
responsibleId
|
||||
logisticsPartner {
|
||||
id
|
||||
name
|
||||
fullName
|
||||
type
|
||||
}
|
||||
responsible {
|
||||
id
|
||||
firstName
|
||||
lastName
|
||||
email
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
// Мутации для логистики
|
||||
export const CREATE_LOGISTICS = gql`
|
||||
mutation CreateLogistics($input: LogisticsInput!) {
|
||||
@ -924,8 +960,16 @@ export const RELEASE_PRODUCT_RESERVE = gql`
|
||||
|
||||
// Мутация для обновления статуса "в пути"
|
||||
export const UPDATE_PRODUCT_IN_TRANSIT = gql`
|
||||
mutation UpdateProductInTransit($productId: ID!, $quantity: Int!, $operation: String!) {
|
||||
updateProductInTransit(productId: $productId, quantity: $quantity, operation: $operation) {
|
||||
mutation UpdateProductInTransit(
|
||||
$productId: ID!
|
||||
$quantity: Int!
|
||||
$operation: String!
|
||||
) {
|
||||
updateProductInTransit(
|
||||
productId: $productId
|
||||
quantity: $quantity
|
||||
operation: $operation
|
||||
) {
|
||||
success
|
||||
message
|
||||
product {
|
||||
|
@ -111,6 +111,44 @@ export const GET_MY_FULFILLMENT_SUPPLIES = gql`
|
||||
}
|
||||
`;
|
||||
|
||||
export const GET_SELLER_SUPPLIES_ON_WAREHOUSE = gql`
|
||||
query GetSellerSuppliesOnWarehouse {
|
||||
sellerSuppliesOnWarehouse {
|
||||
id
|
||||
name
|
||||
description
|
||||
price
|
||||
quantity
|
||||
unit
|
||||
category
|
||||
status
|
||||
date
|
||||
supplier
|
||||
minStock
|
||||
currentStock
|
||||
usedStock
|
||||
imageUrl
|
||||
type
|
||||
shopLocation
|
||||
createdAt
|
||||
updatedAt
|
||||
organization {
|
||||
id
|
||||
name
|
||||
fullName
|
||||
type
|
||||
}
|
||||
sellerOwner {
|
||||
id
|
||||
name
|
||||
fullName
|
||||
inn
|
||||
type
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export const GET_MY_LOGISTICS = gql`
|
||||
query GetMyLogistics {
|
||||
myLogistics {
|
||||
@ -122,6 +160,25 @@ export const GET_MY_LOGISTICS = gql`
|
||||
description
|
||||
createdAt
|
||||
updatedAt
|
||||
organization {
|
||||
id
|
||||
name
|
||||
fullName
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export const GET_LOGISTICS_PARTNERS = gql`
|
||||
query GetLogisticsPartners {
|
||||
logisticsPartners {
|
||||
id
|
||||
name
|
||||
fullName
|
||||
type
|
||||
address
|
||||
phones
|
||||
emails
|
||||
}
|
||||
}
|
||||
`;
|
||||
@ -607,6 +664,8 @@ export const GET_MY_EMPLOYEES = gql`
|
||||
firstName
|
||||
lastName
|
||||
middleName
|
||||
fullName
|
||||
name
|
||||
birthDate
|
||||
avatar
|
||||
passportSeries
|
||||
@ -927,6 +986,7 @@ export const GET_SUPPLY_ORDERS = gql`
|
||||
supplyOrders {
|
||||
id
|
||||
organizationId
|
||||
partnerId
|
||||
deliveryDate
|
||||
status
|
||||
totalAmount
|
||||
@ -1023,11 +1083,7 @@ export const GET_SELLER_STATS_CACHE = gql`
|
||||
$dateFrom: String
|
||||
$dateTo: String
|
||||
) {
|
||||
getSellerStatsCache(
|
||||
period: $period
|
||||
dateFrom: $dateFrom
|
||||
dateTo: $dateTo
|
||||
) {
|
||||
getSellerStatsCache(period: $period, dateFrom: $dateFrom, dateTo: $dateTo) {
|
||||
success
|
||||
message
|
||||
fromCache
|
||||
|
@ -900,7 +900,7 @@ export const resolvers = {
|
||||
}
|
||||
|
||||
// Возвращаем заказы где текущая организация является заказчиком, поставщиком, получателем или логистическим партнером
|
||||
return await prisma.supplyOrder.findMany({
|
||||
const orders = await prisma.supplyOrder.findMany({
|
||||
where: {
|
||||
OR: [
|
||||
{ organizationId: currentUser.organization.id }, // Заказы созданные организацией
|
||||
@ -939,6 +939,8 @@ export const resolvers = {
|
||||
},
|
||||
orderBy: { createdAt: "desc" },
|
||||
});
|
||||
|
||||
return orders;
|
||||
},
|
||||
|
||||
// Счетчик поставок, требующих одобрения
|
||||
@ -969,12 +971,17 @@ export const resolvers = {
|
||||
},
|
||||
});
|
||||
|
||||
// Расходники селлеров (созданные другими для нас) - требуют подтверждения получения
|
||||
// Расходники селлеров (созданные другими для нас) - требуют действий фулфилмента
|
||||
const sellerSupplyOrders = await prisma.supplyOrder.count({
|
||||
where: {
|
||||
fulfillmentCenterId: currentUser.organization.id, // Получатель - мы
|
||||
organizationId: { not: currentUser.organization.id }, // Создали НЕ мы
|
||||
status: "IN_TRANSIT", // В пути - нужно подтвердить получение
|
||||
status: {
|
||||
in: [
|
||||
"SUPPLIER_APPROVED", // Поставщик подтвердил - нужно назначить логистику
|
||||
"IN_TRANSIT", // В пути - нужно подтвердить получение
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@ -986,9 +993,30 @@ export const resolvers = {
|
||||
},
|
||||
});
|
||||
|
||||
// Общий счетчик поставок
|
||||
const pendingSupplyOrders =
|
||||
ourSupplyOrders + sellerSupplyOrders + incomingSupplierOrders;
|
||||
// 🚚 ЛОГИСТИЧЕСКИЕ ЗАЯВКИ ДЛЯ ЛОГИСТИКИ (LOGIST) - требуют действий логистики
|
||||
const logisticsOrders = await prisma.supplyOrder.count({
|
||||
where: {
|
||||
logisticsPartnerId: currentUser.organization.id, // Мы - назначенная логистика
|
||||
status: {
|
||||
in: [
|
||||
"CONFIRMED", // Подтверждено фулфилментом - нужно подтвердить логистикой
|
||||
"LOGISTICS_CONFIRMED", // Подтверждено логистикой - нужно забрать товар у поставщика
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Общий счетчик поставок в зависимости от типа организации
|
||||
let pendingSupplyOrders = 0;
|
||||
if (currentUser.organization.type === "FULFILLMENT") {
|
||||
pendingSupplyOrders = ourSupplyOrders + sellerSupplyOrders;
|
||||
} else if (currentUser.organization.type === "WHOLESALE") {
|
||||
pendingSupplyOrders = incomingSupplierOrders;
|
||||
} else if (currentUser.organization.type === "LOGIST") {
|
||||
pendingSupplyOrders = logisticsOrders;
|
||||
} else if (currentUser.organization.type === "SELLER") {
|
||||
pendingSupplyOrders = 0; // Селлеры не подтверждают поставки, только отслеживают
|
||||
}
|
||||
|
||||
// Считаем входящие заявки на партнерство со статусом PENDING
|
||||
const pendingIncomingRequests = await prisma.counterpartyRequest.count({
|
||||
@ -1003,6 +1031,7 @@ export const resolvers = {
|
||||
ourSupplyOrders: ourSupplyOrders, // Расходники фулфилмента
|
||||
sellerSupplyOrders: sellerSupplyOrders, // Расходники селлеров
|
||||
incomingSupplierOrders: incomingSupplierOrders, // 🔔 Входящие заказы для поставщиков
|
||||
logisticsOrders: logisticsOrders, // 🚚 Логистические заявки для логистики
|
||||
incomingRequests: pendingIncomingRequests,
|
||||
total: pendingSupplyOrders + pendingIncomingRequests,
|
||||
};
|
||||
@ -1146,9 +1175,11 @@ export const resolvers = {
|
||||
);
|
||||
|
||||
// Подсчитываем количество из таблицы Supply (актуальные остатки на складе фулфилмента)
|
||||
// ИСПРАВЛЕНО: считаем только расходники фулфилмента, исключаем расходники селлеров
|
||||
const fulfillmentSuppliesFromWarehouse = await prisma.supply.findMany({
|
||||
where: {
|
||||
organizationId: organizationId, // Склад фулфилмента
|
||||
type: "FULFILLMENT_CONSUMABLES", // ТОЛЬКО расходники фулфилмента
|
||||
},
|
||||
});
|
||||
|
||||
@ -1203,39 +1234,40 @@ export const resolvers = {
|
||||
`📊 FULFILLMENT SUPPLIES RECEIVED TODAY (ПРИБЫЛО): ${fulfillmentSuppliesReceivedToday.length} orders, ${fulfillmentSuppliesChangeToday} items`
|
||||
);
|
||||
|
||||
// Расходники селлеров - получаем из заказов от селлеров (расходники = CONSUMABLE)
|
||||
// Согласно правилам: селлеры заказывают расходники у поставщиков и доставляют на склад фулфилмента
|
||||
const sellerSuppliesCount = sellerDeliveredOrders.reduce(
|
||||
(sum, order) =>
|
||||
sum +
|
||||
order.items.reduce(
|
||||
(itemSum, item) =>
|
||||
itemSum +
|
||||
(item.product.type === "CONSUMABLE" ? item.quantity : 0),
|
||||
0
|
||||
),
|
||||
// Расходники селлеров - получаем из таблицы Supply (актуальные остатки на складе фулфилмента)
|
||||
// ИСПРАВЛЕНО: считаем из Supply с типом SELLER_CONSUMABLES
|
||||
const sellerSuppliesFromWarehouse = await prisma.supply.findMany({
|
||||
where: {
|
||||
organizationId: organizationId, // Склад фулфилмента
|
||||
type: "SELLER_CONSUMABLES", // ТОЛЬКО расходники селлеров
|
||||
},
|
||||
});
|
||||
|
||||
const sellerSuppliesCount = sellerSuppliesFromWarehouse.reduce(
|
||||
(sum, supply) => sum + (supply.currentStock || 0),
|
||||
0
|
||||
);
|
||||
|
||||
console.log(
|
||||
`💼 SELLER SUPPLIES DEBUG: totalCount=${sellerSuppliesCount} (from delivered orders)`
|
||||
`💼 SELLER SUPPLIES DEBUG: totalCount=${sellerSuppliesCount} (from Supply warehouse)`
|
||||
);
|
||||
|
||||
// Изменения расходников селлеров за сутки - используем уже полученные данные
|
||||
const sellerSuppliesChangeToday = recentSellerDeliveredOrders.reduce(
|
||||
(sum, order) =>
|
||||
sum +
|
||||
order.items.reduce(
|
||||
(itemSum, item) =>
|
||||
itemSum +
|
||||
(item.product.type === "CONSUMABLE" ? item.quantity : 0),
|
||||
0
|
||||
),
|
||||
// Изменения расходников селлеров за сутки - считаем из Supply записей, созданных за сутки
|
||||
const sellerSuppliesReceivedToday = await prisma.supply.findMany({
|
||||
where: {
|
||||
organizationId: organizationId, // Склад фулфилмента
|
||||
type: "SELLER_CONSUMABLES", // ТОЛЬКО расходники селлеров
|
||||
createdAt: { gte: oneDayAgo }, // Созданы за последние сутки
|
||||
},
|
||||
});
|
||||
|
||||
const sellerSuppliesChangeToday = sellerSuppliesReceivedToday.reduce(
|
||||
(sum, supply) => sum + (supply.currentStock || 0),
|
||||
0
|
||||
);
|
||||
|
||||
console.log(
|
||||
`📊 SELLER SUPPLIES RECEIVED TODAY: ${recentSellerDeliveredOrders.length} orders, ${sellerSuppliesChangeToday} items`
|
||||
`📊 SELLER SUPPLIES RECEIVED TODAY: ${sellerSuppliesReceivedToday.length} supplies, ${sellerSuppliesChangeToday} items`
|
||||
);
|
||||
|
||||
// Вычисляем процентные изменения
|
||||
@ -1327,6 +1359,24 @@ export const resolvers = {
|
||||
});
|
||||
},
|
||||
|
||||
// Логистические партнеры (организации-логисты)
|
||||
logisticsPartners: async (_: unknown, __: unknown, context: Context) => {
|
||||
if (!context.user) {
|
||||
throw new GraphQLError("Требуется авторизация", {
|
||||
extensions: { code: "UNAUTHENTICATED" },
|
||||
});
|
||||
}
|
||||
|
||||
// Получаем все организации типа LOGIST
|
||||
return await prisma.organization.findMany({
|
||||
where: {
|
||||
type: "LOGIST",
|
||||
// Убираем фильтр по статусу пока не определим правильные значения
|
||||
},
|
||||
orderBy: { createdAt: "desc" }, // Сортируем по дате создания вместо name
|
||||
});
|
||||
},
|
||||
|
||||
// Мои поставки Wildberries
|
||||
myWildberriesSupplies: async (
|
||||
_: unknown,
|
||||
@ -1358,6 +1408,94 @@ export const resolvers = {
|
||||
});
|
||||
},
|
||||
|
||||
// Расходники селлеров на складе фулфилмента (новый resolver)
|
||||
sellerSuppliesOnWarehouse: 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("У пользователя нет организации");
|
||||
}
|
||||
|
||||
// Только фулфилмент может получать расходники селлеров на своем складе
|
||||
if (currentUser.organization.type !== "FULFILLMENT") {
|
||||
throw new GraphQLError("Доступ разрешен только для фулфилмент-центров");
|
||||
}
|
||||
|
||||
// ИСПРАВЛЕНО: Усиленная фильтрация расходников селлеров
|
||||
const sellerSupplies = await prisma.supply.findMany({
|
||||
where: {
|
||||
organizationId: currentUser.organization.id, // На складе этого фулфилмента
|
||||
type: "SELLER_CONSUMABLES" as const, // Только расходники селлеров
|
||||
sellerOwnerId: { not: null }, // ОБЯЗАТЕЛЬНО должен быть владелец-селлер
|
||||
},
|
||||
include: {
|
||||
organization: true, // Фулфилмент-центр (хранитель)
|
||||
sellerOwner: true, // Селлер-владелец расходников
|
||||
},
|
||||
orderBy: { createdAt: "desc" },
|
||||
});
|
||||
|
||||
// Логирование для отладки
|
||||
console.log(
|
||||
"🔍 ИСПРАВЛЕНО: Запрос расходников селлеров на складе фулфилмента:",
|
||||
{
|
||||
fulfillmentId: currentUser.organization.id,
|
||||
fulfillmentName: currentUser.organization.name,
|
||||
totalSupplies: sellerSupplies.length,
|
||||
sellerSupplies: sellerSupplies.map((supply) => ({
|
||||
id: supply.id,
|
||||
name: supply.name,
|
||||
type: supply.type,
|
||||
sellerOwnerId: supply.sellerOwnerId,
|
||||
sellerOwnerName:
|
||||
supply.sellerOwner?.name || supply.sellerOwner?.fullName,
|
||||
currentStock: supply.currentStock,
|
||||
})),
|
||||
}
|
||||
);
|
||||
|
||||
// ДВОЙНАЯ ПРОВЕРКА: Фильтруем на уровне кода для гарантии
|
||||
const filteredSupplies = sellerSupplies.filter((supply) => {
|
||||
const isValid =
|
||||
supply.type === "SELLER_CONSUMABLES" &&
|
||||
supply.sellerOwnerId != null &&
|
||||
supply.sellerOwner != null;
|
||||
|
||||
if (!isValid) {
|
||||
console.warn("⚠️ ОТФИЛЬТРОВАН некорректный расходник:", {
|
||||
id: supply.id,
|
||||
name: supply.name,
|
||||
type: supply.type,
|
||||
sellerOwnerId: supply.sellerOwnerId,
|
||||
hasSellerOwner: !!supply.sellerOwner,
|
||||
});
|
||||
}
|
||||
|
||||
return isValid;
|
||||
});
|
||||
|
||||
console.log("✅ ФИНАЛЬНЫЙ РЕЗУЛЬТАТ после фильтрации:", {
|
||||
originalCount: sellerSupplies.length,
|
||||
filteredCount: filteredSupplies.length,
|
||||
removedCount: sellerSupplies.length - filteredSupplies.length,
|
||||
});
|
||||
|
||||
return filteredSupplies;
|
||||
},
|
||||
|
||||
// Мои товары и расходники (для поставщиков)
|
||||
myProducts: async (_: unknown, __: unknown, context: Context) => {
|
||||
console.log("🔍 MY_PRODUCTS RESOLVER - ВЫЗВАН:", {
|
||||
@ -1830,34 +1968,52 @@ export const resolvers = {
|
||||
|
||||
// Сотрудники организации
|
||||
myEmployees: async (_: unknown, __: unknown, context: Context) => {
|
||||
console.log("🔍 myEmployees resolver called");
|
||||
|
||||
if (!context.user) {
|
||||
console.log("❌ No user in context for myEmployees");
|
||||
throw new GraphQLError("Требуется авторизация", {
|
||||
extensions: { code: "UNAUTHENTICATED" },
|
||||
});
|
||||
}
|
||||
|
||||
const currentUser = await prisma.user.findUnique({
|
||||
where: { id: context.user.id },
|
||||
include: { organization: true },
|
||||
});
|
||||
console.log("✅ User authenticated for myEmployees:", context.user.id);
|
||||
|
||||
if (!currentUser?.organization) {
|
||||
throw new GraphQLError("У пользователя нет организации");
|
||||
try {
|
||||
const currentUser = await prisma.user.findUnique({
|
||||
where: { id: context.user.id },
|
||||
include: { organization: true },
|
||||
});
|
||||
|
||||
if (!currentUser?.organization) {
|
||||
console.log("❌ User has no organization");
|
||||
throw new GraphQLError("У пользователя нет организации");
|
||||
}
|
||||
|
||||
console.log(
|
||||
"📊 User organization type:",
|
||||
currentUser.organization.type
|
||||
);
|
||||
|
||||
if (currentUser.organization.type !== "FULFILLMENT") {
|
||||
console.log("❌ Not a fulfillment center");
|
||||
throw new GraphQLError("Доступно только для фулфилмент центров");
|
||||
}
|
||||
|
||||
const employees = await prisma.employee.findMany({
|
||||
where: { organizationId: currentUser.organization.id },
|
||||
include: {
|
||||
organization: true,
|
||||
},
|
||||
orderBy: { createdAt: "desc" },
|
||||
});
|
||||
|
||||
console.log("👥 Found employees:", employees.length);
|
||||
return employees;
|
||||
} catch (error) {
|
||||
console.error("❌ Error in myEmployees resolver:", error);
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (currentUser.organization.type !== "FULFILLMENT") {
|
||||
throw new GraphQLError("Доступно только для фулфилмент центров");
|
||||
}
|
||||
|
||||
const employees = await prisma.employee.findMany({
|
||||
where: { organizationId: currentUser.organization.id },
|
||||
include: {
|
||||
organization: true,
|
||||
},
|
||||
orderBy: { createdAt: "desc" },
|
||||
});
|
||||
|
||||
return employees;
|
||||
},
|
||||
|
||||
// Получение сотрудника по ID
|
||||
@ -3937,7 +4093,19 @@ export const resolvers = {
|
||||
include: { organization: true },
|
||||
});
|
||||
|
||||
if (!currentUser?.organization) {
|
||||
console.log("🔍 Проверка пользователя:", {
|
||||
userId: context.user.id,
|
||||
userFound: !!currentUser,
|
||||
organizationFound: !!currentUser?.organization,
|
||||
organizationType: currentUser?.organization?.type,
|
||||
organizationId: currentUser?.organization?.id,
|
||||
});
|
||||
|
||||
if (!currentUser) {
|
||||
throw new GraphQLError("Пользователь не найден");
|
||||
}
|
||||
|
||||
if (!currentUser.organization) {
|
||||
throw new GraphQLError("У пользователя нет организации");
|
||||
}
|
||||
|
||||
@ -4067,21 +4235,34 @@ export const resolvers = {
|
||||
initialStatus = "CONFIRMED"; // Логист может сразу подтверждать заказы
|
||||
}
|
||||
|
||||
const supplyOrder = await prisma.supplyOrder.create({
|
||||
data: {
|
||||
partnerId: args.input.partnerId,
|
||||
deliveryDate: new Date(args.input.deliveryDate),
|
||||
totalAmount: new Prisma.Decimal(totalAmount),
|
||||
totalItems: totalItems,
|
||||
organizationId: currentUser.organization.id,
|
||||
fulfillmentCenterId: fulfillmentCenterId,
|
||||
logisticsPartnerId: args.input.logisticsPartnerId,
|
||||
consumableType: args.input.consumableType, // Классификация расходников
|
||||
status: initialStatus,
|
||||
items: {
|
||||
create: orderItems,
|
||||
},
|
||||
// Подготавливаем данные для создания заказа
|
||||
const createData: any = {
|
||||
partnerId: args.input.partnerId,
|
||||
deliveryDate: new Date(args.input.deliveryDate),
|
||||
totalAmount: new Prisma.Decimal(totalAmount),
|
||||
totalItems: totalItems,
|
||||
organizationId: currentUser.organization.id,
|
||||
fulfillmentCenterId: fulfillmentCenterId,
|
||||
consumableType: args.input.consumableType,
|
||||
status: initialStatus,
|
||||
items: {
|
||||
create: orderItems,
|
||||
},
|
||||
};
|
||||
|
||||
// 🔄 ЛОГИСТИКА ОПЦИОНАЛЬНА: добавляем только если передана
|
||||
if (args.input.logisticsPartnerId) {
|
||||
createData.logisticsPartnerId = args.input.logisticsPartnerId;
|
||||
}
|
||||
|
||||
console.log("🔍 Создаем SupplyOrder с данными:", {
|
||||
hasLogistics: !!args.input.logisticsPartnerId,
|
||||
logisticsId: args.input.logisticsPartnerId,
|
||||
createData: createData,
|
||||
});
|
||||
|
||||
const supplyOrder = await prisma.supplyOrder.create({
|
||||
data: createData,
|
||||
include: {
|
||||
partner: {
|
||||
include: {
|
||||
@ -5961,17 +6142,13 @@ export const resolvers = {
|
||||
});
|
||||
|
||||
if (product) {
|
||||
// Согласно правилам: Основные значения = Предыдущие остатки + Прибыло - Убыло
|
||||
const currentStock = product.stock || product.quantity || 0;
|
||||
const newStock = Math.max(currentStock - item.quantity, 0);
|
||||
|
||||
// ИСПРАВЛЕНО: НЕ списываем повторно, только переводим из inTransit в sold
|
||||
// Остаток уже был уменьшен при создании/одобрении заказа
|
||||
await prisma.product.update({
|
||||
where: { id: item.product.id },
|
||||
data: {
|
||||
// Обновляем основные остатки (УБЫЛО)
|
||||
stock: newStock,
|
||||
quantity: newStock, // Синхронизируем оба поля для совместимости
|
||||
// Обновляем дополнительные значения
|
||||
// НЕ ТРОГАЕМ stock - он уже правильно уменьшен при заказе
|
||||
// Только переводим из inTransit в sold
|
||||
inTransit: Math.max(
|
||||
(product.inTransit || 0) - item.quantity,
|
||||
0
|
||||
@ -5980,7 +6157,11 @@ export const resolvers = {
|
||||
},
|
||||
});
|
||||
console.log(
|
||||
`✅ Товар поставщика "${product.name}" обновлен: доставлено ${item.quantity} единиц (остаток: ${currentStock} -> ${newStock})`
|
||||
`✅ Товар поставщика "${product.name}" обновлен: доставлено ${
|
||||
item.quantity
|
||||
} единиц (остаток НЕ ИЗМЕНЕН: ${
|
||||
product.stock || product.quantity || 0
|
||||
})`
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -6073,6 +6254,117 @@ export const resolvers = {
|
||||
}
|
||||
},
|
||||
|
||||
// Назначение логистики фулфилментом на заказ селлера
|
||||
assignLogisticsToSupply: async (
|
||||
_: unknown,
|
||||
args: {
|
||||
supplyOrderId: string;
|
||||
logisticsPartnerId: string;
|
||||
responsibleId?: 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("Только фулфилмент может назначать логистику");
|
||||
}
|
||||
|
||||
try {
|
||||
// Находим заказ
|
||||
const existingOrder = await prisma.supplyOrder.findUnique({
|
||||
where: { id: args.supplyOrderId },
|
||||
include: {
|
||||
partner: true,
|
||||
fulfillmentCenter: true,
|
||||
logisticsPartner: true,
|
||||
items: {
|
||||
include: { product: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!existingOrder) {
|
||||
throw new GraphQLError("Заказ поставки не найден");
|
||||
}
|
||||
|
||||
// Проверяем, что это заказ для нашего фулфилмент-центра
|
||||
if (existingOrder.fulfillmentCenterId !== currentUser.organization.id) {
|
||||
throw new GraphQLError("Нет доступа к этому заказу");
|
||||
}
|
||||
|
||||
// Проверяем, что статус позволяет назначить логистику
|
||||
if (existingOrder.status !== "SUPPLIER_APPROVED") {
|
||||
throw new GraphQLError(
|
||||
`Нельзя назначить логистику для заказа со статусом ${existingOrder.status}`
|
||||
);
|
||||
}
|
||||
|
||||
// Проверяем, что логистическая компания существует
|
||||
const logisticsPartner = await prisma.organization.findUnique({
|
||||
where: { id: args.logisticsPartnerId },
|
||||
});
|
||||
|
||||
if (!logisticsPartner || logisticsPartner.type !== "LOGIST") {
|
||||
throw new GraphQLError("Логистическая компания не найдена");
|
||||
}
|
||||
|
||||
// Обновляем заказ
|
||||
const updatedOrder = await prisma.supplyOrder.update({
|
||||
where: { id: args.supplyOrderId },
|
||||
data: {
|
||||
logisticsPartner: {
|
||||
connect: { id: args.logisticsPartnerId },
|
||||
},
|
||||
status: "CONFIRMED", // Переводим в статус "подтвержден фулфилментом"
|
||||
},
|
||||
include: {
|
||||
partner: true,
|
||||
fulfillmentCenter: true,
|
||||
logisticsPartner: true,
|
||||
items: {
|
||||
include: { product: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
console.log(`✅ Логистика назначена на заказ ${args.supplyOrderId}:`, {
|
||||
logisticsPartner: logisticsPartner.name,
|
||||
responsible: args.responsibleId,
|
||||
newStatus: "CONFIRMED",
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: "Логистика успешно назначена",
|
||||
order: updatedOrder,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("❌ Ошибка при назначении логистики:", error);
|
||||
return {
|
||||
success: false,
|
||||
message:
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "Ошибка при назначении логистики",
|
||||
};
|
||||
}
|
||||
},
|
||||
|
||||
// Резолверы для новых действий с заказами поставок
|
||||
supplierApproveOrder: async (
|
||||
_: unknown,
|
||||
@ -6458,7 +6750,7 @@ export const resolvers = {
|
||||
where: {
|
||||
id: args.id,
|
||||
logisticsPartnerId: currentUser.organization.id,
|
||||
status: "SUPPLIER_APPROVED",
|
||||
OR: [{ status: "SUPPLIER_APPROVED" }, { status: "CONFIRMED" }],
|
||||
},
|
||||
});
|
||||
|
||||
@ -6530,7 +6822,7 @@ export const resolvers = {
|
||||
where: {
|
||||
id: args.id,
|
||||
logisticsPartnerId: currentUser.organization.id,
|
||||
status: "SUPPLIER_APPROVED",
|
||||
OR: [{ status: "SUPPLIER_APPROVED" }, { status: "CONFIRMED" }],
|
||||
},
|
||||
});
|
||||
|
||||
@ -6608,9 +6900,15 @@ export const resolvers = {
|
||||
include: {
|
||||
items: {
|
||||
include: {
|
||||
product: true,
|
||||
product: {
|
||||
include: {
|
||||
category: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
organization: true, // Селлер-создатель заказа
|
||||
partner: true, // Поставщик
|
||||
},
|
||||
});
|
||||
|
||||
@ -6651,17 +6949,13 @@ export const resolvers = {
|
||||
});
|
||||
|
||||
if (product) {
|
||||
// Согласно правилам: Основные значения = Предыдущие остатки + Прибыло - Убыло
|
||||
const currentStock = product.stock || product.quantity || 0;
|
||||
const newStock = Math.max(currentStock - item.quantity, 0);
|
||||
|
||||
// ИСПРАВЛЕНО: НЕ списываем повторно, только переводим из inTransit в sold
|
||||
// Остаток уже был уменьшен при создании/одобрении заказа
|
||||
await prisma.product.update({
|
||||
where: { id: item.product.id },
|
||||
data: {
|
||||
// Обновляем основные остатки (УБЫЛО)
|
||||
stock: newStock,
|
||||
quantity: newStock, // Синхронизируем оба поля для совместимости
|
||||
// Обновляем дополнительные значения
|
||||
// НЕ ТРОГАЕМ stock - он уже правильно уменьшен при заказе
|
||||
// Только переводим из inTransit в sold
|
||||
inTransit: Math.max(
|
||||
(product.inTransit || 0) - item.quantity,
|
||||
0
|
||||
@ -6670,33 +6964,62 @@ export const resolvers = {
|
||||
},
|
||||
});
|
||||
console.log(
|
||||
`✅ Товар поставщика "${product.name}" обновлен: доставлено ${item.quantity} единиц`
|
||||
`✅ Товар поставщика "${product.name}" обновлен: получено ${item.quantity} единиц`
|
||||
);
|
||||
console.log(
|
||||
` 📊 Остатки: ${currentStock} -> ${newStock} (УБЫЛО: ${item.quantity})`
|
||||
` 📊 Остаток: ${
|
||||
product.stock || product.quantity || 0
|
||||
} (НЕ ИЗМЕНЕН - уже списан при заказе)`
|
||||
);
|
||||
console.log(
|
||||
` 🚚 В пути: ${product.inTransit || 0} -> ${Math.max(
|
||||
(product.inTransit || 0) - item.quantity,
|
||||
0
|
||||
)}`
|
||||
)} (УБЫЛО: ${item.quantity})`
|
||||
);
|
||||
console.log(
|
||||
` 💰 Продано: ${product.sold || 0} -> ${
|
||||
(product.sold || 0) + item.quantity
|
||||
}`
|
||||
} (ПРИБЫЛО: ${item.quantity})`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Обновляем склад фулфилмента
|
||||
// Обновляем склад фулфилмента с учетом типа расходников
|
||||
console.log("📦 Обновляем склад фулфилмента...");
|
||||
console.log(
|
||||
`🏷️ Тип поставки: ${
|
||||
existingOrder.consumableType || "FULFILLMENT_CONSUMABLES"
|
||||
}`
|
||||
);
|
||||
|
||||
for (const item of existingOrder.items) {
|
||||
// Определяем тип расходников и владельца
|
||||
const isSellerSupply =
|
||||
existingOrder.consumableType === "SELLER_CONSUMABLES";
|
||||
const supplyType = isSellerSupply
|
||||
? "SELLER_CONSUMABLES"
|
||||
: "FULFILLMENT_CONSUMABLES";
|
||||
const sellerOwnerId = isSellerSupply
|
||||
? updatedOrder.organization?.id
|
||||
: null;
|
||||
|
||||
// Для расходников селлеров ищем по имени И по владельцу
|
||||
const whereCondition = isSellerSupply
|
||||
? {
|
||||
organizationId: currentUser.organization.id,
|
||||
name: item.product.name,
|
||||
type: "SELLER_CONSUMABLES" as const,
|
||||
sellerOwnerId: sellerOwnerId,
|
||||
}
|
||||
: {
|
||||
organizationId: currentUser.organization.id,
|
||||
name: item.product.name,
|
||||
type: "FULFILLMENT_CONSUMABLES" as const,
|
||||
};
|
||||
|
||||
const existingSupply = await prisma.supply.findFirst({
|
||||
where: {
|
||||
organizationId: currentUser.organization.id,
|
||||
name: item.product.name,
|
||||
},
|
||||
where: whereCondition,
|
||||
});
|
||||
|
||||
if (existingSupply) {
|
||||
@ -6709,9 +7032,13 @@ export const resolvers = {
|
||||
},
|
||||
});
|
||||
console.log(
|
||||
`📈 Обновлен существующий расходник фулфилмента "${
|
||||
item.product.name
|
||||
}": ${existingSupply.currentStock} -> ${
|
||||
`📈 Обновлен существующий ${
|
||||
isSellerSupply ? "расходник селлера" : "расходник фулфилмента"
|
||||
} "${item.product.name}" ${
|
||||
isSellerSupply
|
||||
? `(владелец: ${updatedOrder.organization?.name})`
|
||||
: ""
|
||||
}: ${existingSupply.currentStock} -> ${
|
||||
existingSupply.currentStock + item.quantity
|
||||
}`
|
||||
);
|
||||
@ -6719,9 +7046,13 @@ export const resolvers = {
|
||||
await prisma.supply.create({
|
||||
data: {
|
||||
name: item.product.name,
|
||||
description:
|
||||
item.product.description ||
|
||||
`Расходники от ${updatedOrder.partner.name}`,
|
||||
description: isSellerSupply
|
||||
? `Расходники селлера ${
|
||||
updatedOrder.organization?.name ||
|
||||
updatedOrder.organization?.fullName
|
||||
}`
|
||||
: item.product.description ||
|
||||
`Расходники от ${updatedOrder.partner.name}`,
|
||||
price: item.price,
|
||||
quantity: item.quantity,
|
||||
currentStock: item.quantity,
|
||||
@ -6733,11 +7064,21 @@ export const resolvers = {
|
||||
updatedOrder.partner.name ||
|
||||
updatedOrder.partner.fullName ||
|
||||
"Поставщик",
|
||||
type: supplyType as
|
||||
| "SELLER_CONSUMABLES"
|
||||
| "FULFILLMENT_CONSUMABLES",
|
||||
sellerOwnerId: sellerOwnerId,
|
||||
organizationId: currentUser.organization.id,
|
||||
},
|
||||
});
|
||||
console.log(
|
||||
`➕ Создан новый расходник фулфилмента "${item.product.name}": ${item.quantity} единиц`
|
||||
`➕ Создан новый ${
|
||||
isSellerSupply ? "расходник селлера" : "расходник фулфилмента"
|
||||
} "${item.product.name}" ${
|
||||
isSellerSupply
|
||||
? `(владелец: ${updatedOrder.organization?.name})`
|
||||
: ""
|
||||
}: ${item.quantity} единиц`
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -6849,7 +7190,10 @@ export const resolvers = {
|
||||
// Иначе загружаем отдельно
|
||||
return await prisma.supply.findMany({
|
||||
where: { organizationId: parent.id },
|
||||
include: { organization: true },
|
||||
include: {
|
||||
organization: true,
|
||||
sellerOwner: true, // Включаем информацию о селлере-владельце
|
||||
},
|
||||
orderBy: { createdAt: "desc" },
|
||||
});
|
||||
},
|
||||
@ -6949,6 +7293,20 @@ export const resolvers = {
|
||||
},
|
||||
|
||||
Employee: {
|
||||
fullName: (parent: {
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
middleName?: string;
|
||||
}) => {
|
||||
const parts = [parent.lastName, parent.firstName];
|
||||
if (parent.middleName) {
|
||||
parts.push(parent.middleName);
|
||||
}
|
||||
return parts.join(" ");
|
||||
},
|
||||
name: (parent: { firstName: string; lastName: string }) => {
|
||||
return `${parent.firstName} ${parent.lastName}`;
|
||||
},
|
||||
birthDate: (parent: { birthDate?: Date | string | null }) => {
|
||||
if (!parent.birthDate) return null;
|
||||
if (parent.birthDate instanceof Date) {
|
||||
|
6
src/graphql/resolvers/auth.ts
Normal file
6
src/graphql/resolvers/auth.ts
Normal file
@ -0,0 +1,6 @@
|
||||
import { Context } from "../context";
|
||||
|
||||
export const authResolvers = {
|
||||
Query: {},
|
||||
Mutation: {},
|
||||
};
|
6
src/graphql/resolvers/employees.ts
Normal file
6
src/graphql/resolvers/employees.ts
Normal file
@ -0,0 +1,6 @@
|
||||
import { Context } from "../context";
|
||||
|
||||
export const employeeResolvers = {
|
||||
Query: {},
|
||||
Mutation: {},
|
||||
};
|
84
src/graphql/resolvers/index.ts
Normal file
84
src/graphql/resolvers/index.ts
Normal file
@ -0,0 +1,84 @@
|
||||
import { JSONScalar, DateTimeScalar } from "../scalars";
|
||||
import { authResolvers } from "./auth";
|
||||
import { employeeResolvers } from "./employees";
|
||||
import { logisticsResolvers } from "./logistics";
|
||||
import { suppliesResolvers } from "./supplies";
|
||||
|
||||
// Функция для объединения резолверов
|
||||
const mergeResolvers = (...resolvers: any[]) => {
|
||||
const result: any = {
|
||||
Query: {},
|
||||
Mutation: {},
|
||||
};
|
||||
|
||||
for (const resolver of resolvers) {
|
||||
if (resolver.Query) {
|
||||
Object.assign(result.Query, resolver.Query);
|
||||
}
|
||||
if (resolver.Mutation) {
|
||||
Object.assign(result.Mutation, resolver.Mutation);
|
||||
}
|
||||
// Объединяем другие типы резолверов (например, Employee, Organization и т.д.)
|
||||
for (const [key, value] of Object.entries(resolver)) {
|
||||
if (key !== "Query" && key !== "Mutation") {
|
||||
if (!result[key]) {
|
||||
result[key] = {};
|
||||
}
|
||||
Object.assign(result[key], value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
// Временно импортируем старые резолверы для частей, которые еще не вынесены
|
||||
// TODO: Постепенно убрать это после полного рефакторинга
|
||||
import { resolvers as oldResolvers } from "../resolvers";
|
||||
|
||||
// Объединяем новые модульные резолверы с остальными старыми
|
||||
export const resolvers = mergeResolvers(
|
||||
// Скалярные типы
|
||||
{
|
||||
JSON: JSONScalar,
|
||||
DateTime: DateTimeScalar,
|
||||
},
|
||||
|
||||
// Новые модульные резолверы
|
||||
authResolvers,
|
||||
employeeResolvers,
|
||||
logisticsResolvers,
|
||||
suppliesResolvers,
|
||||
|
||||
// Временно добавляем старые резолверы, исключая уже вынесенные
|
||||
{
|
||||
Query: {
|
||||
...oldResolvers.Query,
|
||||
// Исключаем уже вынесенные Query
|
||||
myEmployees: undefined,
|
||||
logisticsPartners: undefined,
|
||||
pendingSuppliesCount: undefined,
|
||||
},
|
||||
Mutation: {
|
||||
...oldResolvers.Mutation,
|
||||
// Исключаем уже вынесенные Mutation
|
||||
sendSmsCode: undefined,
|
||||
verifySmsCode: undefined,
|
||||
verifyInn: undefined,
|
||||
registerFulfillmentOrganization: undefined,
|
||||
createEmployee: undefined,
|
||||
updateEmployee: undefined,
|
||||
deleteEmployee: undefined,
|
||||
assignLogisticsToSupply: undefined,
|
||||
logisticsConfirmOrder: undefined,
|
||||
logisticsRejectOrder: undefined,
|
||||
},
|
||||
// Остальные типы пока оставляем из старых резолверов
|
||||
User: oldResolvers.User,
|
||||
Organization: oldResolvers.Organization,
|
||||
Product: oldResolvers.Product,
|
||||
// SupplyOrder: oldResolvers.SupplyOrder, // Удалено: отсутствует в старых резолверах
|
||||
// Employee берем из нового модуля
|
||||
Employee: undefined,
|
||||
}
|
||||
);
|
285
src/graphql/resolvers/logistics.ts
Normal file
285
src/graphql/resolvers/logistics.ts
Normal file
@ -0,0 +1,285 @@
|
||||
import { GraphQLError } from "graphql";
|
||||
import { Context } from "../context";
|
||||
import { prisma } from "../../lib/prisma";
|
||||
|
||||
export const logisticsResolvers = {
|
||||
Query: {
|
||||
// Получить логистические компании-партнеры
|
||||
logisticsPartners: async (_: unknown, __: unknown, context: Context) => {
|
||||
if (!context.user) {
|
||||
throw new GraphQLError("Требуется авторизация", {
|
||||
extensions: { code: "UNAUTHENTICATED" },
|
||||
});
|
||||
}
|
||||
|
||||
// Получаем все организации типа LOGIST
|
||||
return await prisma.organization.findMany({
|
||||
where: {
|
||||
type: "LOGIST",
|
||||
// Убираем фильтр по статусу пока не определим правильные значения
|
||||
},
|
||||
orderBy: { createdAt: "desc" }, // Сортируем по дате создания вместо name
|
||||
});
|
||||
},
|
||||
},
|
||||
|
||||
Mutation: {
|
||||
// Назначить логистику на поставку (используется фулфилментом)
|
||||
assignLogisticsToSupply: async (
|
||||
_: unknown,
|
||||
args: {
|
||||
supplyOrderId: string;
|
||||
logisticsPartnerId: string;
|
||||
responsibleId?: 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("Доступно только для фулфилмент центров");
|
||||
}
|
||||
|
||||
try {
|
||||
// Находим заказ
|
||||
const existingOrder = await prisma.supplyOrder.findUnique({
|
||||
where: { id: args.supplyOrderId },
|
||||
include: {
|
||||
partner: true,
|
||||
fulfillmentCenter: true,
|
||||
logisticsPartner: true,
|
||||
items: {
|
||||
include: { product: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!existingOrder) {
|
||||
throw new GraphQLError("Заказ поставки не найден");
|
||||
}
|
||||
|
||||
// Проверяем, что это заказ для нашего фулфилмент-центра
|
||||
if (existingOrder.fulfillmentCenterId !== currentUser.organization.id) {
|
||||
throw new GraphQLError("Нет доступа к этому заказу");
|
||||
}
|
||||
|
||||
// Проверяем, что статус позволяет назначить логистику
|
||||
if (existingOrder.status !== "SUPPLIER_APPROVED") {
|
||||
throw new GraphQLError(
|
||||
`Нельзя назначить логистику для заказа со статусом ${existingOrder.status}`
|
||||
);
|
||||
}
|
||||
|
||||
// Проверяем, что логистическая компания существует
|
||||
const logisticsPartner = await prisma.organization.findUnique({
|
||||
where: { id: args.logisticsPartnerId },
|
||||
});
|
||||
|
||||
if (!logisticsPartner || logisticsPartner.type !== "LOGIST") {
|
||||
throw new GraphQLError("Логистическая компания не найдена");
|
||||
}
|
||||
|
||||
// Обновляем заказ
|
||||
const updatedOrder = await prisma.supplyOrder.update({
|
||||
where: { id: args.supplyOrderId },
|
||||
data: {
|
||||
logisticsPartner: {
|
||||
connect: { id: args.logisticsPartnerId },
|
||||
},
|
||||
status: "CONFIRMED", // Переводим в статус "подтвержден фулфилментом"
|
||||
},
|
||||
include: {
|
||||
partner: true,
|
||||
fulfillmentCenter: true,
|
||||
logisticsPartner: true,
|
||||
items: {
|
||||
include: { product: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
console.log(`✅ Логистика назначена на заказ ${args.supplyOrderId}:`, {
|
||||
logisticsPartner: logisticsPartner.name,
|
||||
responsible: args.responsibleId,
|
||||
newStatus: "CONFIRMED",
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: "Логистика успешно назначена",
|
||||
order: updatedOrder,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("❌ Ошибка при назначении логистики:", error);
|
||||
return {
|
||||
success: false,
|
||||
message:
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "Ошибка при назначении логистики",
|
||||
};
|
||||
}
|
||||
},
|
||||
|
||||
// Подтвердить заказ логистической компанией
|
||||
logisticsConfirmOrder: async (
|
||||
_: unknown,
|
||||
args: { id: string },
|
||||
context: Context
|
||||
) => {
|
||||
if (!context.user) {
|
||||
throw new GraphQLError("Требуется авторизация", {
|
||||
extensions: { code: "UNAUTHENTICATED" },
|
||||
});
|
||||
}
|
||||
|
||||
const currentUser = await prisma.user.findUnique({
|
||||
where: { id: context.user.id },
|
||||
include: { organization: true },
|
||||
});
|
||||
|
||||
if (!currentUser?.organization) {
|
||||
throw new GraphQLError("У пользователя нет организации");
|
||||
}
|
||||
|
||||
try {
|
||||
const existingOrder = await prisma.supplyOrder.findFirst({
|
||||
where: {
|
||||
id: args.id,
|
||||
logisticsPartnerId: currentUser.organization.id,
|
||||
OR: [{ status: "SUPPLIER_APPROVED" }, { status: "CONFIRMED" }],
|
||||
},
|
||||
});
|
||||
|
||||
if (!existingOrder) {
|
||||
return {
|
||||
success: false,
|
||||
message:
|
||||
"Заказ не найден или недоступен для подтверждения логистикой",
|
||||
};
|
||||
}
|
||||
|
||||
const updatedOrder = await prisma.supplyOrder.update({
|
||||
where: { id: args.id },
|
||||
data: { status: "LOGISTICS_CONFIRMED" },
|
||||
include: {
|
||||
partner: true,
|
||||
organization: true,
|
||||
fulfillmentCenter: true,
|
||||
logisticsPartner: true,
|
||||
items: {
|
||||
include: {
|
||||
product: {
|
||||
include: {
|
||||
category: true,
|
||||
organization: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: "Заказ подтвержден логистической компанией",
|
||||
order: updatedOrder,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("Error confirming supply order:", error);
|
||||
return {
|
||||
success: false,
|
||||
message: "Ошибка при подтверждении заказа",
|
||||
};
|
||||
}
|
||||
},
|
||||
|
||||
// Отклонить заказ логистической компанией
|
||||
logisticsRejectOrder: async (
|
||||
_: unknown,
|
||||
args: { id: string },
|
||||
context: Context
|
||||
) => {
|
||||
if (!context.user) {
|
||||
throw new GraphQLError("Требуется авторизация", {
|
||||
extensions: { code: "UNAUTHENTICATED" },
|
||||
});
|
||||
}
|
||||
|
||||
const currentUser = await prisma.user.findUnique({
|
||||
where: { id: context.user.id },
|
||||
include: { organization: true },
|
||||
});
|
||||
|
||||
if (!currentUser?.organization) {
|
||||
throw new GraphQLError("У пользователя нет организации");
|
||||
}
|
||||
|
||||
try {
|
||||
const existingOrder = await prisma.supplyOrder.findFirst({
|
||||
where: {
|
||||
id: args.id,
|
||||
logisticsPartnerId: currentUser.organization.id,
|
||||
OR: [{ status: "SUPPLIER_APPROVED" }, { status: "CONFIRMED" }],
|
||||
},
|
||||
});
|
||||
|
||||
if (!existingOrder) {
|
||||
return {
|
||||
success: false,
|
||||
message: "Заказ не найден или недоступен для отклонения логистикой",
|
||||
};
|
||||
}
|
||||
|
||||
const updatedOrder = await prisma.supplyOrder.update({
|
||||
where: { id: args.id },
|
||||
data: {
|
||||
status: "CANCELLED",
|
||||
logisticsPartnerId: null, // Убираем назначенную логистику
|
||||
},
|
||||
include: {
|
||||
partner: true,
|
||||
organization: true,
|
||||
fulfillmentCenter: true,
|
||||
logisticsPartner: true,
|
||||
items: {
|
||||
include: {
|
||||
product: {
|
||||
include: {
|
||||
category: true,
|
||||
organization: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: "Заказ отклонен логистической компанией",
|
||||
order: updatedOrder,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("Error rejecting supply order:", error);
|
||||
return {
|
||||
success: false,
|
||||
message: "Ошибка при отклонении заказа",
|
||||
};
|
||||
}
|
||||
},
|
||||
},
|
||||
};
|
6
src/graphql/resolvers/supplies.ts
Normal file
6
src/graphql/resolvers/supplies.ts
Normal file
@ -0,0 +1,6 @@
|
||||
import { Context } from "../context";
|
||||
|
||||
export const suppliesResolvers = {
|
||||
Query: {},
|
||||
Mutation: {},
|
||||
};
|
51
src/graphql/scalars.ts
Normal file
51
src/graphql/scalars.ts
Normal file
@ -0,0 +1,51 @@
|
||||
import { GraphQLScalarType, Kind } from "graphql";
|
||||
|
||||
export const JSONScalar = new GraphQLScalarType({
|
||||
name: "JSON",
|
||||
serialize: (value) => value,
|
||||
parseValue: (value) => value,
|
||||
parseLiteral: (ast) => {
|
||||
switch (ast.kind) {
|
||||
case Kind.STRING:
|
||||
case Kind.BOOLEAN:
|
||||
return ast.value;
|
||||
case Kind.INT:
|
||||
case Kind.FLOAT:
|
||||
return parseFloat(ast.value);
|
||||
case Kind.OBJECT:
|
||||
return ast.fields.reduce(
|
||||
(accumulator, field) => ({
|
||||
...accumulator,
|
||||
[field.name.value]: JSONScalar.parseLiteral(field.value),
|
||||
}),
|
||||
{}
|
||||
);
|
||||
case Kind.LIST:
|
||||
return ast.values.map((n) => JSONScalar.parseLiteral(n));
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
export const DateTimeScalar = new GraphQLScalarType({
|
||||
name: "DateTime",
|
||||
serialize: (value) => {
|
||||
if (value instanceof Date) {
|
||||
return value.toISOString();
|
||||
}
|
||||
return value;
|
||||
},
|
||||
parseValue: (value) => {
|
||||
if (typeof value === "string") {
|
||||
return new Date(value);
|
||||
}
|
||||
return value;
|
||||
},
|
||||
parseLiteral: (ast) => {
|
||||
if (ast.kind === Kind.STRING) {
|
||||
return new Date(ast.value);
|
||||
}
|
||||
return null;
|
||||
},
|
||||
});
|
@ -43,6 +43,9 @@ export const typeDefs = gql`
|
||||
# Расходники фулфилмента (материалы для работы фулфилмента)
|
||||
myFulfillmentSupplies: [Supply!]!
|
||||
|
||||
# Расходники селлеров на складе фулфилмента (только для фулфилмента)
|
||||
sellerSuppliesOnWarehouse: [Supply!]!
|
||||
|
||||
# Заказы поставок расходников
|
||||
supplyOrders: [SupplyOrder!]!
|
||||
|
||||
@ -52,6 +55,9 @@ export const typeDefs = gql`
|
||||
# Логистика организации
|
||||
myLogistics: [Logistics!]!
|
||||
|
||||
# Логистические партнеры (организации-логисты)
|
||||
logisticsPartners: [Organization!]!
|
||||
|
||||
# Поставки Wildberries
|
||||
myWildberriesSupplies: [WildberriesSupply!]!
|
||||
|
||||
@ -207,6 +213,13 @@ export const typeDefs = gql`
|
||||
status: SupplyOrderStatus!
|
||||
): SupplyOrderResponse!
|
||||
|
||||
# Назначение логистики фулфилментом
|
||||
assignLogisticsToSupply(
|
||||
supplyOrderId: ID!
|
||||
logisticsPartnerId: ID!
|
||||
responsibleId: ID
|
||||
): SupplyOrderResponse!
|
||||
|
||||
# Действия поставщика
|
||||
supplierApproveOrder(id: ID!): SupplyOrderResponse!
|
||||
supplierRejectOrder(id: ID!, reason: String): SupplyOrderResponse!
|
||||
@ -230,10 +243,17 @@ export const typeDefs = gql`
|
||||
deleteProduct(id: ID!): Boolean!
|
||||
|
||||
# Валидация и управление остатками товаров
|
||||
checkArticleUniqueness(article: String!, excludeId: ID): ArticleUniquenessResponse!
|
||||
checkArticleUniqueness(
|
||||
article: String!
|
||||
excludeId: ID
|
||||
): ArticleUniquenessResponse!
|
||||
reserveProductStock(productId: ID!, quantity: Int!): ProductStockResponse!
|
||||
releaseProductReserve(productId: ID!, quantity: Int!): ProductStockResponse!
|
||||
updateProductInTransit(productId: ID!, quantity: Int!, operation: String!): ProductStockResponse!
|
||||
updateProductInTransit(
|
||||
productId: ID!
|
||||
quantity: Int!
|
||||
operation: String!
|
||||
): ProductStockResponse!
|
||||
|
||||
# Работа с категориями
|
||||
createCategory(input: CategoryInput!): CategoryResponse!
|
||||
@ -535,6 +555,11 @@ export const typeDefs = gql`
|
||||
}
|
||||
|
||||
# Типы для расходников
|
||||
enum SupplyType {
|
||||
FULFILLMENT_CONSUMABLES # Расходники фулфилмента (купленные фулфилментом для себя)
|
||||
SELLER_CONSUMABLES # Расходники селлеров (принятые от селлеров для хранения)
|
||||
}
|
||||
|
||||
type Supply {
|
||||
id: ID!
|
||||
name: String!
|
||||
@ -550,6 +575,9 @@ export const typeDefs = gql`
|
||||
currentStock: Int
|
||||
usedStock: Int
|
||||
imageUrl: String
|
||||
type: SupplyType!
|
||||
sellerOwner: Organization # Селлер-владелец (для расходников селлеров)
|
||||
shopLocation: String # Местоположение в магазине фулфилмента
|
||||
createdAt: DateTime!
|
||||
updatedAt: DateTime!
|
||||
organization: Organization!
|
||||
@ -594,8 +622,8 @@ export const typeDefs = gql`
|
||||
totalItems: Int!
|
||||
fulfillmentCenterId: ID
|
||||
fulfillmentCenter: Organization
|
||||
logisticsPartnerId: ID!
|
||||
logisticsPartner: Organization!
|
||||
logisticsPartnerId: ID
|
||||
logisticsPartner: Organization
|
||||
items: [SupplyOrderItem!]!
|
||||
createdAt: DateTime!
|
||||
updatedAt: DateTime!
|
||||
@ -612,21 +640,21 @@ export const typeDefs = gql`
|
||||
}
|
||||
|
||||
enum SupplyOrderStatus {
|
||||
PENDING # Ожидает одобрения поставщика
|
||||
CONFIRMED # Устаревший статус (для обратной совместимости)
|
||||
IN_TRANSIT # Устаревший статус (для обратной совместимости)
|
||||
SUPPLIER_APPROVED # Поставщик одобрил, ожидает подтверждения логистики
|
||||
LOGISTICS_CONFIRMED # Логистика подтвердила, ожидает отправки
|
||||
SHIPPED # Отправлено поставщиком, в пути
|
||||
DELIVERED # Доставлено и принято фулфилментом
|
||||
CANCELLED # Отменено (любой участник может отменить)
|
||||
PENDING # Ожидает одобрения поставщика
|
||||
CONFIRMED # Устаревший статус (для обратной совместимости)
|
||||
IN_TRANSIT # Устаревший статус (для обратной совместимости)
|
||||
SUPPLIER_APPROVED # Поставщик одобрил, ожидает подтверждения логистики
|
||||
LOGISTICS_CONFIRMED # Логистика подтвердила, ожидает отправки
|
||||
SHIPPED # Отправлено поставщиком, в пути
|
||||
DELIVERED # Доставлено и принято фулфилментом
|
||||
CANCELLED # Отменено (любой участник может отменить)
|
||||
}
|
||||
|
||||
input SupplyOrderInput {
|
||||
partnerId: ID!
|
||||
deliveryDate: DateTime!
|
||||
fulfillmentCenterId: ID # ID фулфилмент-центра для доставки
|
||||
logisticsPartnerId: ID! # ID логистической компании (обязательно)
|
||||
logisticsPartnerId: ID # ID логистической компании (опционально - может выбрать селлер или фулфилмент)
|
||||
items: [SupplyOrderItemInput!]!
|
||||
notes: String # Дополнительные заметки к заказу
|
||||
consumableType: String # Классификация расходников: FULFILLMENT_CONSUMABLES, SELLER_CONSUMABLES
|
||||
@ -642,6 +670,7 @@ export const typeDefs = gql`
|
||||
ourSupplyOrders: Int! # Расходники фулфилмента
|
||||
sellerSupplyOrders: Int! # Расходники селлеров
|
||||
incomingSupplierOrders: Int! # 🔔 Входящие заказы для поставщиков
|
||||
logisticsOrders: Int! # 🚚 Логистические заявки для логистики
|
||||
incomingRequests: Int!
|
||||
total: Int!
|
||||
}
|
||||
@ -819,6 +848,8 @@ export const typeDefs = gql`
|
||||
firstName: String!
|
||||
lastName: String!
|
||||
middleName: String
|
||||
fullName: String
|
||||
name: String
|
||||
birthDate: DateTime
|
||||
avatar: String
|
||||
passportPhoto: String
|
||||
|
@ -1,52 +1,79 @@
|
||||
import { ApolloClient, InMemoryCache, createHttpLink, from } from '@apollo/client'
|
||||
import { setContext } from '@apollo/client/link/context'
|
||||
import { onError } from '@apollo/client/link/error'
|
||||
import {
|
||||
ApolloClient,
|
||||
InMemoryCache,
|
||||
createHttpLink,
|
||||
from,
|
||||
} from "@apollo/client";
|
||||
import { setContext } from "@apollo/client/link/context";
|
||||
import { onError } from "@apollo/client/link/error";
|
||||
|
||||
// HTTP Link для GraphQL запросов
|
||||
const httpLink = createHttpLink({
|
||||
uri: '/api/graphql',
|
||||
})
|
||||
uri: "/api/graphql",
|
||||
});
|
||||
|
||||
// Auth Link для добавления JWT токена в заголовки
|
||||
const authLink = setContext((operation, { headers }) => {
|
||||
if (typeof window === 'undefined') {
|
||||
return { headers }
|
||||
if (typeof window === "undefined") {
|
||||
return { headers };
|
||||
}
|
||||
|
||||
// Проверяем токены администратора и пользователя
|
||||
const adminToken = localStorage.getItem('adminAuthToken')
|
||||
const userToken = localStorage.getItem('authToken')
|
||||
const adminToken = localStorage.getItem("adminAuthToken");
|
||||
const userToken = localStorage.getItem("authToken");
|
||||
|
||||
// Приоритет у админского токена
|
||||
const token = adminToken || userToken
|
||||
const tokenType = adminToken ? 'admin' : 'user'
|
||||
const token = adminToken || userToken;
|
||||
const tokenType = adminToken ? "admin" : "user";
|
||||
|
||||
console.log(`Apollo Client - Operation: ${operation.operationName}, Token type: ${tokenType}, Token:`, token ? `${token.substring(0, 20)}...` : 'No token')
|
||||
console.log(
|
||||
`Apollo Client - Operation: ${operation.operationName}, Token type: ${tokenType}, Token:`,
|
||||
token ? `${token.substring(0, 20)}...` : "No token"
|
||||
);
|
||||
|
||||
const authHeaders = {
|
||||
...headers,
|
||||
authorization: token ? `Bearer ${token}` : '',
|
||||
}
|
||||
authorization: token ? `Bearer ${token}` : "",
|
||||
};
|
||||
|
||||
console.log('Apollo Client - Auth headers:', { authorization: authHeaders.authorization ? 'Bearer ***' : 'No auth' })
|
||||
console.log("Apollo Client - Auth headers:", {
|
||||
authorization: authHeaders.authorization ? "Bearer ***" : "No auth",
|
||||
});
|
||||
|
||||
return {
|
||||
headers: authHeaders
|
||||
}
|
||||
})
|
||||
headers: authHeaders,
|
||||
};
|
||||
});
|
||||
|
||||
// Error Link для обработки ошибок - минимальная версия
|
||||
const errorLink = onError(() => {
|
||||
// Пустой обработчик - не делаем ничего
|
||||
// Это предотвращает любые ошибки в error handler
|
||||
})
|
||||
// Error Link для обработки ошибок с детальным логированием
|
||||
const errorLink = onError(
|
||||
({ graphQLErrors, networkError, operation, forward }) => {
|
||||
if (graphQLErrors) {
|
||||
graphQLErrors.forEach(({ message, locations, path, extensions }) => {
|
||||
console.error("🚨 GraphQL Error:", {
|
||||
message,
|
||||
locations,
|
||||
path,
|
||||
extensions,
|
||||
operation: operation.operationName,
|
||||
variables: operation.variables,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
if (networkError) {
|
||||
console.error("🌐 Network Error:", {
|
||||
error: networkError,
|
||||
operation: operation.operationName,
|
||||
variables: operation.variables,
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Создаем Apollo Client
|
||||
export const apolloClient = new ApolloClient({
|
||||
link: from([
|
||||
authLink,
|
||||
httpLink,
|
||||
]),
|
||||
link: from([errorLink, authLink, httpLink]),
|
||||
cache: new InMemoryCache({
|
||||
typePolicies: {
|
||||
User: {
|
||||
@ -67,45 +94,45 @@ export const apolloClient = new ApolloClient({
|
||||
}),
|
||||
defaultOptions: {
|
||||
watchQuery: {
|
||||
errorPolicy: 'all',
|
||||
errorPolicy: "all",
|
||||
},
|
||||
query: {
|
||||
errorPolicy: 'all',
|
||||
errorPolicy: "all",
|
||||
},
|
||||
},
|
||||
})
|
||||
});
|
||||
|
||||
// Утилитарные функции для работы с токеном и пользователем
|
||||
export const setAuthToken = (token: string) => {
|
||||
if (typeof window !== 'undefined') {
|
||||
localStorage.setItem('authToken', token)
|
||||
if (typeof window !== "undefined") {
|
||||
localStorage.setItem("authToken", token);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export const removeAuthToken = () => {
|
||||
if (typeof window !== 'undefined') {
|
||||
localStorage.removeItem('authToken')
|
||||
localStorage.removeItem('userData')
|
||||
if (typeof window !== "undefined") {
|
||||
localStorage.removeItem("authToken");
|
||||
localStorage.removeItem("userData");
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export const getAuthToken = (): string | null => {
|
||||
if (typeof window !== 'undefined') {
|
||||
return localStorage.getItem('authToken')
|
||||
if (typeof window !== "undefined") {
|
||||
return localStorage.getItem("authToken");
|
||||
}
|
||||
return null
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
export const setUserData = (userData: unknown) => {
|
||||
if (typeof window !== 'undefined') {
|
||||
localStorage.setItem('userData', JSON.stringify(userData))
|
||||
if (typeof window !== "undefined") {
|
||||
localStorage.setItem("userData", JSON.stringify(userData));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export const getUserData = (): unknown | null => {
|
||||
if (typeof window !== 'undefined') {
|
||||
const data = localStorage.getItem('userData')
|
||||
return data ? JSON.parse(data) : null
|
||||
if (typeof window !== "undefined") {
|
||||
const data = localStorage.getItem("userData");
|
||||
return data ? JSON.parse(data) : null;
|
||||
}
|
||||
return null
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
566
visual-design-rules.md
Normal file
566
visual-design-rules.md
Normal file
@ -0,0 +1,566 @@
|
||||
# ОБЩИЕ ПРАВИЛА ВИЗУАЛЬНОГО ДИЗАЙНА SFERA
|
||||
|
||||
> 📋 **Базовые принципы дизайна системы управления складами и поставками**
|
||||
|
||||
---
|
||||
|
||||
## 🎨 **1. ЦВЕТОВАЯ СИСТЕМА**
|
||||
|
||||
### 1.1 Основная палитра
|
||||
|
||||
**PRIMARY COLORS (OKLCH)**:
|
||||
|
||||
- **Primary**: `oklch(0.65 0.28 315)` - основной фиолетовый
|
||||
- **Primary Foreground**: `oklch(0.985 0 0)` - белый текст
|
||||
- **Secondary**: `oklch(0.94 0.08 315)` - светло-фиолетовый
|
||||
- **Background**: `oklch(0.98 0.02 320)` - светлый фон
|
||||
|
||||
**GLASS SYSTEM**:
|
||||
|
||||
- **Base**: `bg-white/10 backdrop-blur border-white/20`
|
||||
- **Cards**: `bg-white/5 backdrop-blur border-white/10`
|
||||
- **Buttons**: `glass-button` / `glass-secondary`
|
||||
|
||||
### 1.2 Функциональные цвета
|
||||
|
||||
**STATUS COLORS**:
|
||||
|
||||
- 🟢 **Success**: `green-400/500` - завершенные операции
|
||||
- 🔴 **Error**: `red-400/500` - ошибки и критичные состояния
|
||||
- 🟡 **Warning**: `yellow-400/500` - предупреждения
|
||||
- 🔵 **Info**: `blue-400/500` - информационные сообщения
|
||||
- 🟣 **Processing**: `purple-400/500` - процессы в работе
|
||||
|
||||
**CABINET COLORS**:
|
||||
|
||||
- **Селлер**: `blue-400` - синий
|
||||
- **Фулфилмент**: `purple-400` - фиолетовый
|
||||
- **Поставщик**: `green-400` - зеленый
|
||||
- **Логистика**: `orange-400` - оранжевый
|
||||
|
||||
### 1.3 Иерархическая цветовая схема
|
||||
|
||||
**УРОВНИ ДЕТАЛИЗАЦИИ (для фулфилмента)**:
|
||||
|
||||
- 🔵 **Уровень 1 (Магазины)**: уникальные цвета
|
||||
- ТехноМир: `blue-400/500`
|
||||
- Стиль и Комфорт: `pink-400/500`
|
||||
- Зелёный Дом: `emerald-400/500`
|
||||
- 🟢 **Уровень 2 (Товары)**: `green-500`
|
||||
- 🟠 **Уровень 3 (Варианты)**: `orange-500`
|
||||
|
||||
---
|
||||
|
||||
## 🔤 **2. ТИПОГРАФИКА**
|
||||
|
||||
### 2.1 Шрифты
|
||||
|
||||
**FAMILY**:
|
||||
|
||||
- **Sans**: `var(--font-geist-sans)` - основной шрифт
|
||||
- **Mono**: `var(--font-geist-mono)` - код и техническая информация
|
||||
|
||||
### 2.2 Размеры и веса
|
||||
|
||||
**HEADINGS**:
|
||||
|
||||
- **H1**: `text-3xl font-bold text-white` - заголовки страниц
|
||||
- **H2**: `text-2xl font-semibold text-white` - секции
|
||||
- **H3**: `text-xl font-semibold text-white` - подразделы
|
||||
- **H4**: `text-lg font-medium text-white` - карточки
|
||||
|
||||
**BODY TEXT**:
|
||||
|
||||
- **Primary**: `text-white` - основной текст
|
||||
- **Secondary**: `text-white/70` - вспомогательный
|
||||
- **Muted**: `text-white/60` - дополнительный
|
||||
- **Disabled**: `text-white/40` - отключенные элементы
|
||||
|
||||
**CODE & TECHNICAL**:
|
||||
|
||||
- **Code**: `font-mono text-xs text-white/60` - техническая информация
|
||||
- **Badges**: `text-xs font-medium` - статусы и метки
|
||||
|
||||
---
|
||||
|
||||
## 🧩 **3. КОМПОНЕНТЫ**
|
||||
|
||||
### 3.1 Кнопки
|
||||
|
||||
**ВАРИАНТЫ**:
|
||||
|
||||
```css
|
||||
default: bg-primary hover:bg-primary/90
|
||||
glass: glass-button (основной стиль)
|
||||
glass-secondary: glass-secondary hover:text-white/90
|
||||
outline: border hover:bg-accent
|
||||
ghost: hover:bg-accent/50
|
||||
destructive: bg-destructive hover:bg-destructive/90
|
||||
```
|
||||
|
||||
**РАЗМЕРЫ**:
|
||||
|
||||
- **sm**: `h-8 px-3` - компактные формы
|
||||
- **default**: `h-9 px-4` - стандартные
|
||||
- **lg**: `h-10 px-6` - акцентные действия
|
||||
- **icon**: `size-9` - иконочные кнопки
|
||||
|
||||
**ГРАДИЕНТЫ**:
|
||||
|
||||
- **Accent**: `bg-gradient-to-r from-purple-600 to-pink-600`
|
||||
- **Success**: `bg-gradient-to-r from-green-500 to-emerald-500`
|
||||
- **Info**: `bg-gradient-to-r from-blue-500 to-cyan-500`
|
||||
|
||||
### 3.2 Карточки
|
||||
|
||||
**БАЗОВЫЕ СТИЛИ**:
|
||||
|
||||
```css
|
||||
glass-card: bg-white/5 backdrop-blur border-white/10
|
||||
hover: border-white/20 transition-all
|
||||
interactive: cursor-pointer group
|
||||
```
|
||||
|
||||
**ИНТЕРАКТИВНОСТЬ**:
|
||||
|
||||
- **Hover**: `hover:border-white/20 hover:bg-white/10`
|
||||
- **Scale**: `hover:scale-105 transition-transform`
|
||||
- **Shadow**: `hover:shadow-xl hover:shadow-purple-500/20`
|
||||
|
||||
### 3.3 Формы
|
||||
|
||||
**ИНПУТЫ**:
|
||||
|
||||
```css
|
||||
glass-input: backdrop-blur border-white/20
|
||||
placeholder: placeholder:text-white/50
|
||||
focus: focus-visible:ring-ring/50
|
||||
invalid: aria-invalid:border-destructive
|
||||
```
|
||||
|
||||
**СОСТОЯНИЯ**:
|
||||
|
||||
- **Default**: `glass-input text-white`
|
||||
- **Error**: `border-red-500/50 ring-red-500/20`
|
||||
- **Success**: `border-green-500/50 ring-green-500/20`
|
||||
- **Disabled**: `opacity-50 pointer-events-none`
|
||||
|
||||
---
|
||||
|
||||
## ✨ **4. АНИМАЦИИ И ПЕРЕХОДЫ**
|
||||
|
||||
### 4.1 Стандартные переходы
|
||||
|
||||
**DURATION**:
|
||||
|
||||
- **Fast**: `duration-200` - hover эффекты
|
||||
- **Normal**: `duration-300` - стандартные переходы
|
||||
- **Slow**: `duration-500` - сложные анимации
|
||||
|
||||
**EASING**:
|
||||
|
||||
- **Default**: `transition-all` - универсальный
|
||||
- **Transform**: `transition-transform` - масштабирование
|
||||
- **Colors**: `transition-colors` - смена цветов
|
||||
|
||||
### 4.2 Hover эффекты
|
||||
|
||||
**МАСШТАБИРОВАНИЕ**:
|
||||
|
||||
```css
|
||||
hover:scale-105 - легкое увеличение
|
||||
hover:scale-110 - заметное увеличение
|
||||
hover:scale-95 - уменьшение при нажатии
|
||||
hover:-translate-y-1 - поднятие
|
||||
```
|
||||
|
||||
**ЦВЕТОВЫЕ ИЗМЕНЕНИЯ**:
|
||||
|
||||
```css
|
||||
hover:bg-white/10 - осветление фона
|
||||
hover:text-white - усиление текста
|
||||
hover:border-white/40 - усиление границ
|
||||
```
|
||||
|
||||
### 4.3 Системные анимации
|
||||
|
||||
**LOADING**:
|
||||
|
||||
- **Spin**: `animate-spin` - индикаторы загрузки
|
||||
- **Pulse**: `animate-pulse` - уведомления
|
||||
- **Bounce**: `animate-bounce` - привлечение внимания
|
||||
- **Ping**: `animate-ping` - статусы онлайн
|
||||
|
||||
---
|
||||
|
||||
## 📐 **5. СЕТКИ И ОТСТУПЫ**
|
||||
|
||||
### 5.1 Spacing система
|
||||
|
||||
**ВНУТРЕННИЕ ОТСТУПЫ**:
|
||||
|
||||
- **xs**: `p-2` (8px) - плотные элементы
|
||||
- **sm**: `p-3` (12px) - компактные карточки
|
||||
- **md**: `p-4` (16px) - стандартные карточки
|
||||
- **lg**: `p-6` (24px) - основные секции
|
||||
- **xl**: `p-8` (32px) - страницы
|
||||
|
||||
**ВНЕШНИЕ ОТСТУПЫ**:
|
||||
|
||||
- **Между элементами**: `space-y-4` / `gap-4`
|
||||
- **Между секциями**: `space-y-6` / `gap-6`
|
||||
- **Между страницами**: `space-y-8`
|
||||
|
||||
### 5.2 Responsive сетки
|
||||
|
||||
**BREAKPOINTS**:
|
||||
|
||||
```css
|
||||
sm: 640px - мобильные
|
||||
md: 768px - планшеты
|
||||
lg: 1024px - десктопы
|
||||
xl: 1280px - широкие экраны
|
||||
```
|
||||
|
||||
**GRID PATTERNS**:
|
||||
|
||||
```css
|
||||
grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4
|
||||
grid-cols-2 md:grid-cols-4 lg:grid-cols-6
|
||||
flex flex-wrap gap-4
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🎯 **6. СОСТОЯНИЯ И СТАТУСЫ**
|
||||
|
||||
### 6.1 Визуальные индикаторы
|
||||
|
||||
**SUCCESS STATES**:
|
||||
|
||||
```css
|
||||
bg-green-500/10 border-green-500/30 text-green-300
|
||||
CheckCircle - иконка успеха
|
||||
```
|
||||
|
||||
**ERROR STATES**:
|
||||
|
||||
```css
|
||||
bg-red-500/10 border-red-500/30 text-red-300
|
||||
XCircle - иконка ошибки
|
||||
```
|
||||
|
||||
**WARNING STATES**:
|
||||
|
||||
```css
|
||||
bg-yellow-500/10 border-yellow-500/30 text-yellow-300
|
||||
AlertTriangle - иконка предупреждения
|
||||
```
|
||||
|
||||
**INFO STATES**:
|
||||
|
||||
```css
|
||||
bg-blue-500/10 border-blue-500/30 text-blue-300
|
||||
Info - иконка информации
|
||||
```
|
||||
|
||||
### 6.2 Бейджи и метки
|
||||
|
||||
**СТАТУСЫ WORKFLOW**:
|
||||
|
||||
- **Pending**: `bg-yellow-500/20 text-yellow-300`
|
||||
- **Approved**: `bg-green-500/20 text-green-300`
|
||||
- **In Progress**: `bg-blue-500/20 text-blue-300`
|
||||
- **Completed**: `bg-emerald-500/20 text-emerald-300`
|
||||
- **Cancelled**: `bg-red-500/20 text-red-300`
|
||||
|
||||
---
|
||||
|
||||
## 📱 **7. АДАПТИВНОСТЬ**
|
||||
|
||||
### 7.1 Mobile-first подход
|
||||
|
||||
**ПРИНЦИПЫ**:
|
||||
|
||||
- Базовые стили для мобильных
|
||||
- Прогрессивное улучшение для больших экранов
|
||||
- Минимальная ширина 320px
|
||||
- Максимальная производительность
|
||||
|
||||
### 7.2 Responsive компоненты
|
||||
|
||||
**SIDEBAR**:
|
||||
|
||||
```css
|
||||
Мобильные: overlay sidebar
|
||||
Планшеты: collapsible sidebar
|
||||
Десктоп: full sidebar
|
||||
```
|
||||
|
||||
**CARDS**:
|
||||
|
||||
```css
|
||||
Мобильные: 1 колонка
|
||||
Планшеты: 2 колонки
|
||||
Десктоп: 3-4 колонки
|
||||
```
|
||||
|
||||
**FORMS**:
|
||||
|
||||
```css
|
||||
Мобильные: stack layout
|
||||
Планшеты: 2-column layout
|
||||
Десктоп: optimized layout
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ♿ **8. ДОСТУПНОСТЬ**
|
||||
|
||||
### 8.1 Контрастность
|
||||
|
||||
**МИНИМАЛЬНЫЕ ТРЕБОВАНИЯ**:
|
||||
|
||||
- Текст на фоне: 4.5:1
|
||||
- Иконки и элементы: 3:1
|
||||
- Интерактивные элементы: 4.5:1
|
||||
|
||||
**ПРОВЕРКА КОНТРАСТА**:
|
||||
|
||||
```css
|
||||
text-white на bg-white/10 ✅
|
||||
text-white/70 на bg-white/5 ✅
|
||||
text-white/40 на bg-white/10 ⚠️
|
||||
```
|
||||
|
||||
### 8.2 Фокус и навигация
|
||||
|
||||
**FOCUS STYLES**:
|
||||
|
||||
```css
|
||||
focus-visible:ring-ring/50 focus-visible:ring-[3px]
|
||||
focus-visible:border-ring
|
||||
outline-none
|
||||
```
|
||||
|
||||
**KEYBOARD NAVIGATION**:
|
||||
|
||||
- Tab order логичен
|
||||
- Все интерактивные элементы доступны
|
||||
- Escape закрывает модалы
|
||||
- Enter активирует кнопки
|
||||
|
||||
---
|
||||
|
||||
## 🔧 **9. ТЕХНИЧЕСКИЕ ТРЕБОВАНИЯ**
|
||||
|
||||
### 9.1 CSS Architecture
|
||||
|
||||
**UTILITY-FIRST**:
|
||||
|
||||
- Tailwind CSS как основа
|
||||
- Custom CSS только для уникальных компонентов
|
||||
- CSS переменные для тем
|
||||
|
||||
**NAMING CONVENTIONS**:
|
||||
|
||||
```css
|
||||
glass-card - основные карточки
|
||||
glass-button - стеклянные кнопки
|
||||
glass-input - поля ввода
|
||||
glass-secondary - вторичные элементы
|
||||
```
|
||||
|
||||
### 9.2 Performance
|
||||
|
||||
**ОПТИМИЗАЦИЯ**:
|
||||
|
||||
- Minimal CSS bundle
|
||||
- Tree-shaking неиспользуемых стилей
|
||||
- Critical CSS inline
|
||||
- Progressive enhancement
|
||||
|
||||
**LAZY LOADING**:
|
||||
|
||||
- Изображения: `loading="lazy"`
|
||||
- Компоненты: React.lazy()
|
||||
- Анимации: только при необходимости
|
||||
|
||||
---
|
||||
|
||||
## 🎯 **10. ПРАВИЛА ИЕРАРХИИ ТАБОВ**
|
||||
|
||||
### 10.1 Принцип отдельного блока табов
|
||||
|
||||
**ОБЯЗАТЕЛЬНЫЕ ПРАВИЛА**:
|
||||
|
||||
> 1. ТАБЫ ВСЕГДА НАХОДЯТСЯ В ОТДЕЛЬНОМ БЛОКЕ, независимо от количества уровней иерархии
|
||||
> 2. БЛОК ТАБОВ ИМЕЕТ СТИЛИ КАК У САЙДБАРА
|
||||
> 3. ТАБЫ ОТДЕЛЕНЫ ОТ РАБОЧЕГО ПРОСТРАНСТВА
|
||||
|
||||
**СТИЛИ БЛОКА ТАБОВ**:
|
||||
|
||||
```css
|
||||
bg-white/10 backdrop-blur-xl border border-white/20 rounded-2xl p-6
|
||||
```
|
||||
|
||||
**СТИЛИ РАБОЧЕГО ПРОСТРАНСТВА**:
|
||||
|
||||
```css
|
||||
bg-white/10 backdrop-blur-xl border border-white/20 rounded-2xl
|
||||
```
|
||||
|
||||
**ЗАПРЕЩЕНО**:
|
||||
|
||||
- Размещать табы в блоке с другими модулями
|
||||
- Смешивать табы с основным контентом
|
||||
- Использовать glass-card вместо правильных стилей сайдбара
|
||||
- Объединять блок табов с рабочим пространством
|
||||
|
||||
### 10.2 Визуальная иерархия
|
||||
|
||||
**УРОВЕНЬ 1 (Главные табы)**:
|
||||
|
||||
```css
|
||||
h-11 /* Высота 44px */
|
||||
bg-white/15 /* Контрастный фон */
|
||||
border-white/30 /* Яркая граница */
|
||||
rounded-xl /* Крупное скругление */
|
||||
font-semibold /* Жирный шрифт */
|
||||
purple-500/40 /* Интенсивный градиент */
|
||||
shadow-lg /* Глубокая тень */
|
||||
```
|
||||
|
||||
**УРОВЕНЬ 2 (Подтабы)**:
|
||||
|
||||
```css
|
||||
h-9 /* Высота 36px */
|
||||
bg-white/8 /* Средний фон */
|
||||
border-white/20 /* Средняя граница */
|
||||
rounded-lg /* Среднее скругление */
|
||||
font-medium /* Средний шрифт */
|
||||
white/15 /* Простая активация */
|
||||
ml-4 /* Отступ показывает иерархию */
|
||||
```
|
||||
|
||||
**УРОВЕНЬ 3 (Подподтабы)**:
|
||||
|
||||
```css
|
||||
h-8 /* Высота 32px */
|
||||
bg-white/5 /* Слабый фон */
|
||||
border-white/15 /* Деликатная граница */
|
||||
rounded-md /* Мелкое скругление */
|
||||
font-normal /* Обычный шрифт */
|
||||
white/10 /* Слабая активация */
|
||||
ml-8 /* Больший отступ */
|
||||
text-white/50 /* Приглушенный текст */
|
||||
```
|
||||
|
||||
### 10.3 Отступы и spacing
|
||||
|
||||
**ИЕРАРХИЧЕСКИЕ ОТСТУПЫ**:
|
||||
|
||||
- **Уровень 1**: `ml-0` - прижат к краю
|
||||
- **Уровень 2**: `ml-4` - показывает подчинение
|
||||
- **Уровень 3**: `ml-8` - максимальная вложенность
|
||||
|
||||
**ВЕРТИКАЛЬНЫЕ ОТСТУПЫ**:
|
||||
|
||||
- Между уровнями: `space-y-3`
|
||||
- После TabsList: `mb-3`
|
||||
- В контенте: `space-y-0` (без лишних отступов)
|
||||
|
||||
### 10.4 Размеры элементов
|
||||
|
||||
**ПРОГРЕССИВНОЕ УМЕНЬШЕНИЕ**:
|
||||
|
||||
- **Высота табов**: 44px → 36px → 32px
|
||||
- **Размер иконок**: 16px → 12px → 10px
|
||||
- **Размер текста**: sm → xs → xs
|
||||
- **Толщина шрифта**: semibold → medium → normal
|
||||
|
||||
### 10.5 Цветовая деградация
|
||||
|
||||
**КОНТРАСТНОСТЬ**:
|
||||
|
||||
- **Уровень 1**: Максимальная (white/15, border-white/30)
|
||||
- **Уровень 2**: Средняя (white/8, border-white/20)
|
||||
- **Уровень 3**: Минимальная (white/5, border-white/15)
|
||||
|
||||
**АКТИВАЦИЯ**:
|
||||
|
||||
- **Уровень 1**: Градиент + тень + border
|
||||
- **Уровень 2**: Простой фон white/15
|
||||
- **Уровень 3**: Слабый фон white/10
|
||||
|
||||
---
|
||||
|
||||
## 📋 **11. ЧЕКЛИСТ ПРИМЕНЕНИЯ**
|
||||
|
||||
### 10.1 Перед внедрением компонента
|
||||
|
||||
- [ ] Соответствует цветовой палитре системы
|
||||
- [ ] Использует правильную типографику
|
||||
- [ ] Имеет корректные состояния (hover, focus, disabled)
|
||||
- [ ] Адаптивен для всех breakpoints
|
||||
- [ ] Соответствует требованиям доступности
|
||||
- [ ] Использует стандартные анимации
|
||||
- [ ] Оптимизирован для производительности
|
||||
|
||||
### 10.2 При создании новых компонентов
|
||||
|
||||
- [ ] Базируется на существующих паттернах
|
||||
- [ ] Совместим с UI Kit системы
|
||||
- [ ] Документирован в Storybook/UI Kit демо
|
||||
- [ ] Протестирован на различных устройствах
|
||||
- [ ] Соответствует принципам дизайн-системы
|
||||
|
||||
---
|
||||
|
||||
## 🎨 **11. ПРИМЕРЫ ИСПОЛЬЗОВАНИЯ**
|
||||
|
||||
### 11.1 Базовая карточка
|
||||
|
||||
```tsx
|
||||
<Card className="glass-card border-white/10 hover:border-white/20 transition-all">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-white">Заголовок</CardTitle>
|
||||
<CardDescription className="text-white/70">Описание</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-white/80">Содержимое карточки</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
```
|
||||
|
||||
### 11.2 Интерактивная кнопка
|
||||
|
||||
```tsx
|
||||
<Button
|
||||
variant="glass"
|
||||
className="hover:scale-105 transition-transform"
|
||||
onClick={handleClick}
|
||||
>
|
||||
<Icon className="h-4 w-4 mr-2" />
|
||||
Действие
|
||||
</Button>
|
||||
```
|
||||
|
||||
### 11.3 Форма с валидацией
|
||||
|
||||
```tsx
|
||||
<div className="space-y-2">
|
||||
<Label className="text-white/90">Поле ввода</Label>
|
||||
<Input
|
||||
className="glass-input text-white placeholder:text-white/50"
|
||||
placeholder="Введите значение..."
|
||||
/>
|
||||
{error && <p className="text-red-300 text-xs">{error}</p>}
|
||||
</div>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
**📊 СТАТУС**: Действующие правила v1.0
|
||||
**🔄 ОБНОВЛЕНО**: На основе анализа UI Kit системы
|
||||
**📅 ДАТА**: Основано на текущем состоянии проекта
|
Reference in New Issue
Block a user