Обновлены модели и компоненты для управления поставками и расходниками. Добавлены новые поля в модели SupplyOrder и соответствующие резолверы для поддержки логистики. Реализованы компоненты уведомлений для отображения статуса логистических заявок и поставок. Оптимизирован интерфейс для улучшения пользовательского опыта, добавлены логи для диагностики запросов. Обновлены GraphQL схемы и мутации для поддержки новых функциональных возможностей.
This commit is contained in:
@ -112,6 +112,7 @@ model Organization {
|
|||||||
externalAds ExternalAd[] @relation("ExternalAds")
|
externalAds ExternalAd[] @relation("ExternalAds")
|
||||||
wbWarehouseCaches WBWarehouseCache[] @relation("WBWarehouseCaches")
|
wbWarehouseCaches WBWarehouseCache[] @relation("WBWarehouseCaches")
|
||||||
sellerStatsCaches SellerStatsCache[] @relation("SellerStatsCaches")
|
sellerStatsCaches SellerStatsCache[] @relation("SellerStatsCaches")
|
||||||
|
sellerSupplies Supply[] @relation("SellerSupplies")
|
||||||
|
|
||||||
@@map("organizations")
|
@@map("organizations")
|
||||||
}
|
}
|
||||||
@ -212,6 +213,10 @@ model Supply {
|
|||||||
currentStock Int @default(0)
|
currentStock Int @default(0)
|
||||||
usedStock Int @default(0) // Количество использованных расходников
|
usedStock Int @default(0) // Количество использованных расходников
|
||||||
imageUrl String?
|
imageUrl String?
|
||||||
|
type SupplyType @default(FULFILLMENT_CONSUMABLES) // Тип расходников
|
||||||
|
sellerOwnerId String? // ID селлера-владельца (для расходников селлеров)
|
||||||
|
sellerOwner Organization? @relation("SellerSupplies", fields: [sellerOwnerId], references: [id], onDelete: SetNull)
|
||||||
|
shopLocation String? // Местоположение в магазине фулфилмента
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
organizationId String
|
organizationId String
|
||||||
@ -459,6 +464,11 @@ enum ProductType {
|
|||||||
CONSUMABLE
|
CONSUMABLE
|
||||||
}
|
}
|
||||||
|
|
||||||
|
enum SupplyType {
|
||||||
|
FULFILLMENT_CONSUMABLES // Расходники фулфилмента (купленные фулфилментом для себя)
|
||||||
|
SELLER_CONSUMABLES // Расходники селлеров (принятые от селлеров для хранения)
|
||||||
|
}
|
||||||
|
|
||||||
model Logistics {
|
model Logistics {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
fromLocation String
|
fromLocation String
|
||||||
@ -482,7 +492,7 @@ model SupplyOrder {
|
|||||||
totalAmount Decimal @db.Decimal(12, 2)
|
totalAmount Decimal @db.Decimal(12, 2)
|
||||||
totalItems Int
|
totalItems Int
|
||||||
fulfillmentCenterId String?
|
fulfillmentCenterId String?
|
||||||
logisticsPartnerId String
|
logisticsPartnerId String? // Опциональная логистика - может назначить фулфилмент
|
||||||
consumableType String? // Классификация расходников: FULFILLMENT_CONSUMABLES, SELLER_CONSUMABLES
|
consumableType String? // Классификация расходников: FULFILLMENT_CONSUMABLES, SELLER_CONSUMABLES
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
@ -491,7 +501,7 @@ model SupplyOrder {
|
|||||||
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
|
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
|
||||||
partner Organization @relation("SupplyOrderPartner", fields: [partnerId], references: [id])
|
partner Organization @relation("SupplyOrderPartner", fields: [partnerId], references: [id])
|
||||||
fulfillmentCenter Organization? @relation("SupplyOrderFulfillmentCenter", fields: [fulfillmentCenterId], references: [id])
|
fulfillmentCenter Organization? @relation("SupplyOrderFulfillmentCenter", fields: [fulfillmentCenterId], references: [id])
|
||||||
logisticsPartner Organization @relation("SupplyOrderLogistics", fields: [logisticsPartnerId], references: [id])
|
logisticsPartner Organization? @relation("SupplyOrderLogistics", fields: [logisticsPartnerId], references: [id])
|
||||||
|
|
||||||
@@map("supply_orders")
|
@@map("supply_orders")
|
||||||
}
|
}
|
||||||
|
@ -1,84 +1,82 @@
|
|||||||
import { ApolloServer } from '@apollo/server'
|
import { ApolloServer } from "@apollo/server";
|
||||||
import { startServerAndCreateNextHandler } from '@as-integrations/next'
|
import { startServerAndCreateNextHandler } from "@as-integrations/next";
|
||||||
import { NextRequest } from 'next/server'
|
import { NextRequest } from "next/server";
|
||||||
import jwt from 'jsonwebtoken'
|
import jwt from "jsonwebtoken";
|
||||||
import { typeDefs } from '@/graphql/typedefs'
|
import { typeDefs } from "@/graphql/typedefs";
|
||||||
import { resolvers } from '@/graphql/resolvers'
|
import { resolvers } from "@/graphql/resolvers";
|
||||||
|
import { Context } from "@/graphql/context";
|
||||||
// Интерфейс для контекста
|
|
||||||
interface Context {
|
|
||||||
user?: {
|
|
||||||
id: string
|
|
||||||
phone: string
|
|
||||||
}
|
|
||||||
admin?: {
|
|
||||||
id: string
|
|
||||||
username: string
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Создаем Apollo Server
|
// Создаем Apollo Server
|
||||||
const server = new ApolloServer<Context>({
|
const server = new ApolloServer<Context>({
|
||||||
typeDefs,
|
typeDefs,
|
||||||
resolvers,
|
resolvers,
|
||||||
})
|
});
|
||||||
|
|
||||||
// Создаем Next.js handler
|
// Создаем Next.js handler
|
||||||
const handler = startServerAndCreateNextHandler<NextRequest>(server, {
|
const handler = startServerAndCreateNextHandler<NextRequest>(server, {
|
||||||
context: async (req: NextRequest) => {
|
context: async (req: NextRequest) => {
|
||||||
// Извлекаем токен из заголовка Authorization
|
// Извлекаем токен из заголовка Authorization
|
||||||
const authHeader = req.headers.get('authorization')
|
const authHeader = req.headers.get("authorization");
|
||||||
const token = authHeader?.replace('Bearer ', '')
|
const token = authHeader?.replace("Bearer ", "");
|
||||||
|
|
||||||
console.log('GraphQL Context - Auth header:', authHeader)
|
console.log("GraphQL Context - Auth header:", authHeader);
|
||||||
console.log('GraphQL Context - Token:', token ? `${token.substring(0, 20)}...` : 'No token')
|
console.log(
|
||||||
|
"GraphQL Context - Token:",
|
||||||
|
token ? `${token.substring(0, 20)}...` : "No token"
|
||||||
|
);
|
||||||
|
|
||||||
if (!token) {
|
if (!token) {
|
||||||
console.log('GraphQL Context - No token provided')
|
console.log("GraphQL Context - No token provided");
|
||||||
return { user: undefined, admin: undefined }
|
return { user: undefined, admin: undefined };
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Верифицируем JWT токен
|
// Верифицируем JWT токен
|
||||||
const decoded = jwt.verify(token, process.env.JWT_SECRET!) as {
|
const decoded = jwt.verify(token, process.env.JWT_SECRET!) as {
|
||||||
userId?: string
|
userId?: string;
|
||||||
phone?: string
|
phone?: string;
|
||||||
adminId?: string
|
adminId?: string;
|
||||||
username?: string
|
username?: string;
|
||||||
type?: string
|
type?: string;
|
||||||
}
|
};
|
||||||
|
|
||||||
// Проверяем тип токена
|
// Проверяем тип токена
|
||||||
if (decoded.type === 'admin' && decoded.adminId && decoded.username) {
|
if (decoded.type === "admin" && decoded.adminId && decoded.username) {
|
||||||
console.log('GraphQL Context - Decoded admin:', { id: decoded.adminId, username: decoded.username })
|
console.log("GraphQL Context - Decoded admin:", {
|
||||||
|
id: decoded.adminId,
|
||||||
|
username: decoded.username,
|
||||||
|
});
|
||||||
return {
|
return {
|
||||||
admin: {
|
admin: {
|
||||||
id: decoded.adminId,
|
id: decoded.adminId,
|
||||||
username: decoded.username
|
username: decoded.username,
|
||||||
}
|
},
|
||||||
}
|
};
|
||||||
} else if (decoded.userId && decoded.phone) {
|
} 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 {
|
return {
|
||||||
user: {
|
user: {
|
||||||
id: decoded.userId,
|
id: decoded.userId,
|
||||||
phone: decoded.phone
|
phone: decoded.phone,
|
||||||
}
|
},
|
||||||
}
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
return { user: undefined, admin: undefined }
|
return { user: undefined, admin: undefined };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('GraphQL Context - Invalid token:', error)
|
console.error("GraphQL Context - Invalid token:", error);
|
||||||
return { user: undefined, admin: undefined }
|
return { user: undefined, admin: undefined };
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
})
|
});
|
||||||
|
|
||||||
export async function GET(request: NextRequest) {
|
export async function GET(request: NextRequest) {
|
||||||
return handler(request)
|
return handler(request);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
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() {
|
export function Sidebar() {
|
||||||
const { user, logout } = useAuth();
|
const { user, logout } = useAuth();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@ -149,7 +208,7 @@ export function Sidebar() {
|
|||||||
router.push("/supplies");
|
router.push("/supplies");
|
||||||
break;
|
break;
|
||||||
case "WHOLESALE":
|
case "WHOLESALE":
|
||||||
router.push("/supplies");
|
router.push("/supplier-orders");
|
||||||
break;
|
break;
|
||||||
case "LOGIST":
|
case "LOGIST":
|
||||||
router.push("/logistics-orders");
|
router.push("/logistics-orders");
|
||||||
@ -202,7 +261,8 @@ export function Sidebar() {
|
|||||||
const isSuppliesActive =
|
const isSuppliesActive =
|
||||||
pathname.startsWith("/supplies") ||
|
pathname.startsWith("/supplies") ||
|
||||||
pathname.startsWith("/fulfillment-supplies") ||
|
pathname.startsWith("/fulfillment-supplies") ||
|
||||||
pathname.startsWith("/logistics");
|
pathname.startsWith("/logistics") ||
|
||||||
|
pathname.startsWith("/supplier-orders");
|
||||||
const isPartnersActive = pathname.startsWith("/partners");
|
const isPartnersActive = pathname.startsWith("/partners");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -475,8 +535,7 @@ export function Sidebar() {
|
|||||||
>
|
>
|
||||||
<Truck className="h-4 w-4 flex-shrink-0" />
|
<Truck className="h-4 w-4 flex-shrink-0" />
|
||||||
{!isCollapsed && <span className="ml-3">Мои поставки</span>}
|
{!isCollapsed && <span className="ml-3">Мои поставки</span>}
|
||||||
{/* Уведомление о непринятых поставках */}
|
{/* Селлеры не получают уведомления о поставках - только отслеживают статус */}
|
||||||
<PendingSuppliesNotification />
|
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@ -536,8 +595,8 @@ export function Sidebar() {
|
|||||||
{!isCollapsed && (
|
{!isCollapsed && (
|
||||||
<span className="ml-3">Входящие поставки</span>
|
<span className="ml-3">Входящие поставки</span>
|
||||||
)}
|
)}
|
||||||
{/* Уведомление о непринятых поставках */}
|
{/* Уведомление только о поставках, не о заявках на партнерство */}
|
||||||
<PendingSuppliesNotification />
|
<FulfillmentSuppliesNotification />
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@ -595,8 +654,8 @@ export function Sidebar() {
|
|||||||
>
|
>
|
||||||
<Truck className="h-4 w-4 flex-shrink-0" />
|
<Truck className="h-4 w-4 flex-shrink-0" />
|
||||||
{!isCollapsed && <span className="ml-3">Заявки</span>}
|
{!isCollapsed && <span className="ml-3">Заявки</span>}
|
||||||
{/* Уведомление о непринятых поставках */}
|
{/* Уведомление только о входящих заказах поставок, не о заявках на партнерство */}
|
||||||
<PendingSuppliesNotification />
|
<WholesaleOrdersNotification />
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@ -616,8 +675,8 @@ export function Sidebar() {
|
|||||||
>
|
>
|
||||||
<Truck className="h-4 w-4 flex-shrink-0" />
|
<Truck className="h-4 w-4 flex-shrink-0" />
|
||||||
{!isCollapsed && <span className="ml-3">Перевозки</span>}
|
{!isCollapsed && <span className="ml-3">Перевозки</span>}
|
||||||
{/* Уведомление о непринятых поставках */}
|
{/* Уведомление только о логистических заявках */}
|
||||||
<PendingSuppliesNotification />
|
<LogisticsOrdersNotification />
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
@ -1,86 +1,104 @@
|
|||||||
"use client"
|
"use client";
|
||||||
|
|
||||||
import { useState, useEffect, useMemo } from 'react'
|
import { useState, useEffect, useMemo } from "react";
|
||||||
import { useQuery, useMutation } from '@apollo/client'
|
import { useQuery, useMutation } from "@apollo/client";
|
||||||
import { apolloClient } from '@/lib/apollo-client'
|
import { apolloClient } from "@/lib/apollo-client";
|
||||||
import { Sidebar } from '@/components/dashboard/sidebar'
|
import { Sidebar } from "@/components/dashboard/sidebar";
|
||||||
import { Card } from '@/components/ui/card'
|
import { Card } from "@/components/ui/card";
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from "@/components/ui/button";
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
|
|
||||||
import { EmployeeInlineForm } from './employee-inline-form'
|
import { EmployeeInlineForm } from "./employee-inline-form";
|
||||||
import { EmployeeCompactForm } from './employee-compact-form'
|
import { EmployeeCompactForm } from "./employee-compact-form";
|
||||||
import { EmployeeEditInlineForm } from './employee-edit-inline-form'
|
import { EmployeeEditInlineForm } from "./employee-edit-inline-form";
|
||||||
|
|
||||||
import { EmployeeSearch } from './employee-search'
|
import { EmployeeSearch } from "./employee-search";
|
||||||
import { EmployeeLegend } from './employee-legend'
|
import { EmployeeLegend } from "./employee-legend";
|
||||||
import { EmployeeEmptyState } from './employee-empty-state'
|
import { EmployeeEmptyState } from "./employee-empty-state";
|
||||||
import { EmployeeRow } from './employee-row'
|
import { EmployeeRow } from "./employee-row";
|
||||||
import { EmployeeReports } from './employee-reports'
|
import { EmployeeReports } from "./employee-reports";
|
||||||
import { toast } from 'sonner'
|
import { toast } from "sonner";
|
||||||
import { GET_MY_EMPLOYEES, GET_EMPLOYEE_SCHEDULE } from '@/graphql/queries'
|
import { GET_MY_EMPLOYEES, GET_EMPLOYEE_SCHEDULE } from "@/graphql/queries";
|
||||||
import { CREATE_EMPLOYEE, UPDATE_EMPLOYEE, DELETE_EMPLOYEE, UPDATE_EMPLOYEE_SCHEDULE } from '@/graphql/mutations'
|
import {
|
||||||
import { Users, FileText, Plus, Layout, LayoutGrid } from 'lucide-react'
|
CREATE_EMPLOYEE,
|
||||||
|
UPDATE_EMPLOYEE,
|
||||||
|
DELETE_EMPLOYEE,
|
||||||
|
UPDATE_EMPLOYEE_SCHEDULE,
|
||||||
|
} from "@/graphql/mutations";
|
||||||
|
import { Users, FileText, Plus, Layout, LayoutGrid } from "lucide-react";
|
||||||
|
|
||||||
// Интерфейс сотрудника
|
// Интерфейс сотрудника
|
||||||
interface Employee {
|
interface Employee {
|
||||||
id: string
|
id: string;
|
||||||
firstName: string
|
firstName: string;
|
||||||
lastName: string
|
lastName: string;
|
||||||
middleName?: string
|
middleName?: string;
|
||||||
position: string
|
position: string;
|
||||||
phone: string
|
phone: string;
|
||||||
email?: string
|
email?: string;
|
||||||
avatar?: string
|
avatar?: string;
|
||||||
hireDate: string
|
hireDate: string;
|
||||||
status: 'ACTIVE' | 'VACATION' | 'SICK' | 'FIRED'
|
status: "ACTIVE" | "VACATION" | "SICK" | "FIRED";
|
||||||
salary?: number
|
salary?: number;
|
||||||
address?: string
|
address?: string;
|
||||||
birthDate?: string
|
birthDate?: string;
|
||||||
passportSeries?: string
|
passportSeries?: string;
|
||||||
passportNumber?: string
|
passportNumber?: string;
|
||||||
passportIssued?: string
|
passportIssued?: string;
|
||||||
passportDate?: string
|
passportDate?: string;
|
||||||
emergencyContact?: string
|
emergencyContact?: string;
|
||||||
emergencyPhone?: string
|
emergencyPhone?: string;
|
||||||
telegram?: string
|
telegram?: string;
|
||||||
whatsapp?: string
|
whatsapp?: string;
|
||||||
passportPhoto?: string
|
passportPhoto?: string;
|
||||||
createdAt: string
|
createdAt: string;
|
||||||
updatedAt: string
|
updatedAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function EmployeesDashboard() {
|
export function EmployeesDashboard() {
|
||||||
const [searchQuery, setSearchQuery] = useState('')
|
const [searchQuery, setSearchQuery] = useState("");
|
||||||
const [showAddForm, setShowAddForm] = useState(false)
|
const [showAddForm, setShowAddForm] = useState(false);
|
||||||
const [showCompactForm, setShowCompactForm] = useState(true) // По умолчанию компактная форма
|
const [showCompactForm, setShowCompactForm] = useState(true); // По умолчанию компактная форма
|
||||||
const [showEditForm, setShowEditForm] = useState(false)
|
const [showEditForm, setShowEditForm] = useState(false);
|
||||||
const [createLoading, setCreateLoading] = useState(false)
|
const [createLoading, setCreateLoading] = useState(false);
|
||||||
const [editingEmployee, setEditingEmployee] = useState<Employee | null>(null)
|
const [editingEmployee, setEditingEmployee] = useState<Employee | null>(null);
|
||||||
const [deletingEmployeeId, setDeletingEmployeeId] = useState<string | null>(null)
|
const [deletingEmployeeId, setDeletingEmployeeId] = useState<string | null>(
|
||||||
const [employeeSchedules, setEmployeeSchedules] = useState<{[key: string]: ScheduleRecord[]}>({})
|
null
|
||||||
const [currentYear, setCurrentYear] = useState(new Date().getFullYear())
|
);
|
||||||
const [currentMonth, setCurrentMonth] = useState(new Date().getMonth())
|
const [employeeSchedules, setEmployeeSchedules] = useState<{
|
||||||
const [activeTab, setActiveTab] = useState('combined')
|
[key: string]: ScheduleRecord[];
|
||||||
|
}>({});
|
||||||
|
const [currentYear, setCurrentYear] = useState(new Date().getFullYear());
|
||||||
|
const [currentMonth, setCurrentMonth] = useState(new Date().getMonth());
|
||||||
|
const [activeTab, setActiveTab] = useState("combined");
|
||||||
|
|
||||||
interface ScheduleRecord {
|
interface ScheduleRecord {
|
||||||
id: string
|
id: string;
|
||||||
date: string
|
date: string;
|
||||||
status: string
|
status: string;
|
||||||
hoursWorked?: number
|
hoursWorked?: number;
|
||||||
employee: {
|
employee: {
|
||||||
id: string
|
id: string;
|
||||||
}
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// GraphQL запросы и мутации
|
// GraphQL запросы и мутации
|
||||||
const { data, loading, refetch } = useQuery(GET_MY_EMPLOYEES)
|
const { data, loading, refetch } = useQuery(GET_MY_EMPLOYEES);
|
||||||
const [createEmployee] = useMutation(CREATE_EMPLOYEE)
|
const [createEmployee] = useMutation(CREATE_EMPLOYEE, {
|
||||||
const [updateEmployee] = useMutation(UPDATE_EMPLOYEE)
|
refetchQueries: [{ query: GET_MY_EMPLOYEES }],
|
||||||
const [deleteEmployee] = useMutation(DELETE_EMPLOYEE)
|
onCompleted: () => {
|
||||||
const [updateEmployeeSchedule] = useMutation(UPDATE_EMPLOYEE_SCHEDULE)
|
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(() => {
|
useEffect(() => {
|
||||||
@ -93,35 +111,40 @@ export function EmployeesDashboard() {
|
|||||||
variables: {
|
variables: {
|
||||||
employeeId: employee.id,
|
employeeId: employee.id,
|
||||||
year: currentYear,
|
year: currentYear,
|
||||||
month: currentMonth
|
month: currentMonth,
|
||||||
}
|
},
|
||||||
})
|
});
|
||||||
return { employeeId: employee.id, scheduleData: data?.employeeSchedule || [] }
|
return {
|
||||||
|
employeeId: employee.id,
|
||||||
|
scheduleData: data?.employeeSchedule || [],
|
||||||
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`Error loading schedule for ${employee.id}:`, error)
|
console.error(`Error loading schedule for ${employee.id}:`, error);
|
||||||
return { employeeId: employee.id, scheduleData: [] }
|
return { employeeId: employee.id, scheduleData: [] };
|
||||||
}
|
}
|
||||||
})
|
});
|
||||||
|
|
||||||
const results = await Promise.all(schedulePromises)
|
const results = await Promise.all(schedulePromises);
|
||||||
const scheduleMap: {[key: string]: ScheduleRecord[]} = {}
|
const scheduleMap: { [key: string]: ScheduleRecord[] } = {};
|
||||||
results.forEach((result: { employeeId: string; scheduleData: ScheduleRecord[] }) => {
|
results.forEach(
|
||||||
|
(result: { employeeId: string; scheduleData: ScheduleRecord[] }) => {
|
||||||
if (result && result.scheduleData) {
|
if (result && result.scheduleData) {
|
||||||
scheduleMap[result.employeeId] = result.scheduleData
|
scheduleMap[result.employeeId] = result.scheduleData;
|
||||||
}
|
|
||||||
})
|
|
||||||
setEmployeeSchedules(scheduleMap)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
);
|
||||||
|
setEmployeeSchedules(scheduleMap);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
loadScheduleData()
|
loadScheduleData();
|
||||||
}, [employees, currentYear, currentMonth])
|
}, [employees, currentYear, currentMonth]);
|
||||||
|
|
||||||
const handleEditEmployee = (employee: Employee) => {
|
const handleEditEmployee = (employee: Employee) => {
|
||||||
setEditingEmployee(employee)
|
setEditingEmployee(employee);
|
||||||
setShowEditForm(true)
|
setShowEditForm(true);
|
||||||
setShowAddForm(false) // Закрываем форму добавления если открыта
|
setShowAddForm(false); // Закрываем форму добавления если открыта
|
||||||
}
|
};
|
||||||
|
|
||||||
const handleEmployeeSaved = async (employeeData: Partial<Employee>) => {
|
const handleEmployeeSaved = async (employeeData: Partial<Employee>) => {
|
||||||
try {
|
try {
|
||||||
@ -130,164 +153,174 @@ export function EmployeesDashboard() {
|
|||||||
const { data } = await updateEmployee({
|
const { data } = await updateEmployee({
|
||||||
variables: {
|
variables: {
|
||||||
id: editingEmployee.id,
|
id: editingEmployee.id,
|
||||||
input: employeeData
|
input: employeeData,
|
||||||
}
|
},
|
||||||
})
|
});
|
||||||
if (data?.updateEmployee?.success) {
|
if (data?.updateEmployee?.success) {
|
||||||
toast.success('Сотрудник успешно обновлен')
|
toast.success("Сотрудник успешно обновлен");
|
||||||
refetch()
|
refetch();
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Добавление нового сотрудника
|
// Добавление нового сотрудника
|
||||||
const { data } = await createEmployee({
|
const { data } = await createEmployee({
|
||||||
variables: { input: employeeData }
|
variables: { input: employeeData },
|
||||||
})
|
});
|
||||||
if (data?.createEmployee?.success) {
|
if (data?.createEmployee?.success) {
|
||||||
toast.success('Сотрудник успешно добавлен')
|
toast.success("Сотрудник успешно добавлен");
|
||||||
refetch()
|
refetch();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
setShowEditForm(false)
|
setShowEditForm(false);
|
||||||
setEditingEmployee(null)
|
setEditingEmployee(null);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error saving employee:', error)
|
console.error("Error saving employee:", error);
|
||||||
toast.error('Ошибка при сохранении сотрудника')
|
toast.error("Ошибка при сохранении сотрудника");
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleCreateEmployee = async (employeeData: Partial<Employee>) => {
|
const handleCreateEmployee = async (employeeData: Partial<Employee>) => {
|
||||||
setCreateLoading(true)
|
setCreateLoading(true);
|
||||||
try {
|
try {
|
||||||
const { data } = await createEmployee({
|
const { data } = await createEmployee({
|
||||||
variables: { input: employeeData }
|
variables: { input: employeeData },
|
||||||
})
|
});
|
||||||
if (data?.createEmployee?.success) {
|
if (data?.createEmployee?.success) {
|
||||||
toast.success('Сотрудник успешно добавлен!')
|
toast.success("Сотрудник успешно добавлен!");
|
||||||
setShowAddForm(false)
|
setShowAddForm(false);
|
||||||
refetch()
|
refetch();
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error creating employee:', error)
|
console.error("Error creating employee:", error);
|
||||||
toast.error('Ошибка при создании сотрудника')
|
toast.error("Ошибка при создании сотрудника");
|
||||||
} finally {
|
} finally {
|
||||||
setCreateLoading(false)
|
setCreateLoading(false);
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleEmployeeDeleted = async (employeeId: string) => {
|
const handleEmployeeDeleted = async (employeeId: string) => {
|
||||||
try {
|
try {
|
||||||
setDeletingEmployeeId(employeeId)
|
setDeletingEmployeeId(employeeId);
|
||||||
const { data } = await deleteEmployee({
|
const { data } = await deleteEmployee({
|
||||||
variables: { id: employeeId }
|
variables: { id: employeeId },
|
||||||
})
|
});
|
||||||
if (data?.deleteEmployee) {
|
if (data?.deleteEmployee) {
|
||||||
toast.success('Сотрудник успешно уволен')
|
toast.success("Сотрудник успешно уволен");
|
||||||
refetch()
|
refetch();
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error deleting employee:', error)
|
console.error("Error deleting employee:", error);
|
||||||
toast.error('Ошибка при увольнении сотрудника')
|
toast.error("Ошибка при увольнении сотрудника");
|
||||||
} finally {
|
} finally {
|
||||||
setDeletingEmployeeId(null)
|
setDeletingEmployeeId(null);
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// Функция для изменения статуса дня в табеле
|
// Функция для изменения статуса дня в табеле
|
||||||
const changeDayStatus = async (employeeId: string, day: number, currentStatus: string) => {
|
const changeDayStatus = async (
|
||||||
|
employeeId: string,
|
||||||
|
day: number,
|
||||||
|
currentStatus: string
|
||||||
|
) => {
|
||||||
try {
|
try {
|
||||||
// Циклично переключаем статусы
|
// Циклично переключаем статусы
|
||||||
const statuses = ['WORK', 'WEEKEND', 'VACATION', 'SICK', 'ABSENT']
|
const statuses = ["WORK", "WEEKEND", "VACATION", "SICK", "ABSENT"];
|
||||||
const currentIndex = statuses.indexOf(currentStatus.toUpperCase())
|
const currentIndex = statuses.indexOf(currentStatus.toUpperCase());
|
||||||
const nextStatus = statuses[(currentIndex + 1) % statuses.length]
|
const nextStatus = statuses[(currentIndex + 1) % statuses.length];
|
||||||
|
|
||||||
// Формируем дату
|
// Формируем дату
|
||||||
const date = new Date(currentYear, currentMonth, day)
|
const date = new Date(currentYear, currentMonth, day);
|
||||||
const hours = nextStatus === 'WORK' ? 8 : 0
|
const hours = nextStatus === "WORK" ? 8 : 0;
|
||||||
|
|
||||||
// Отправляем мутацию
|
// Отправляем мутацию
|
||||||
await updateEmployeeSchedule({
|
await updateEmployeeSchedule({
|
||||||
variables: {
|
variables: {
|
||||||
input: {
|
input: {
|
||||||
employeeId: employeeId,
|
employeeId: employeeId,
|
||||||
date: date.toISOString().split('T')[0], // YYYY-MM-DD формат
|
date: date.toISOString().split("T")[0], // YYYY-MM-DD формат
|
||||||
status: nextStatus,
|
status: nextStatus,
|
||||||
hoursWorked: hours
|
hoursWorked: hours,
|
||||||
}
|
},
|
||||||
}
|
},
|
||||||
})
|
});
|
||||||
|
|
||||||
// Обновляем локальное состояние
|
// Обновляем локальное состояние
|
||||||
const updatedDate = new Date(currentYear, currentMonth, day)
|
const updatedDate = new Date(currentYear, currentMonth, day);
|
||||||
const dateStr = updatedDate.toISOString().split('T')[0]
|
const dateStr = updatedDate.toISOString().split("T")[0];
|
||||||
|
|
||||||
setEmployeeSchedules(prev => {
|
setEmployeeSchedules((prev) => {
|
||||||
const currentSchedule = prev[employeeId] || []
|
const currentSchedule = prev[employeeId] || [];
|
||||||
const existingRecordIndex = currentSchedule.findIndex(record =>
|
const existingRecordIndex = currentSchedule.findIndex(
|
||||||
record.date.split('T')[0] === dateStr
|
(record) => record.date.split("T")[0] === dateStr
|
||||||
)
|
);
|
||||||
|
|
||||||
const newRecord = {
|
const newRecord = {
|
||||||
id: Date.now().toString(), // временный ID
|
id: Date.now().toString(), // временный ID
|
||||||
date: updatedDate.toISOString(),
|
date: updatedDate.toISOString(),
|
||||||
status: nextStatus,
|
status: nextStatus,
|
||||||
hoursWorked: hours,
|
hoursWorked: hours,
|
||||||
employee: { id: employeeId }
|
employee: { id: employeeId },
|
||||||
}
|
};
|
||||||
|
|
||||||
let updatedSchedule
|
let updatedSchedule;
|
||||||
if (existingRecordIndex >= 0) {
|
if (existingRecordIndex >= 0) {
|
||||||
// Обновляем существующую запись
|
// Обновляем существующую запись
|
||||||
updatedSchedule = [...currentSchedule]
|
updatedSchedule = [...currentSchedule];
|
||||||
updatedSchedule[existingRecordIndex] = { ...updatedSchedule[existingRecordIndex], ...newRecord }
|
updatedSchedule[existingRecordIndex] = {
|
||||||
|
...updatedSchedule[existingRecordIndex],
|
||||||
|
...newRecord,
|
||||||
|
};
|
||||||
} else {
|
} else {
|
||||||
// Добавляем новую запись
|
// Добавляем новую запись
|
||||||
updatedSchedule = [...currentSchedule, newRecord]
|
updatedSchedule = [...currentSchedule, newRecord];
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...prev,
|
...prev,
|
||||||
[employeeId]: updatedSchedule
|
[employeeId]: updatedSchedule,
|
||||||
}
|
};
|
||||||
})
|
});
|
||||||
|
|
||||||
toast.success('Статус дня обновлен')
|
|
||||||
|
|
||||||
|
toast.success("Статус дня обновлен");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error updating day status:', error)
|
console.error("Error updating day status:", error);
|
||||||
toast.error('Ошибка при обновлении статуса дня')
|
toast.error("Ошибка при обновлении статуса дня");
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// Функция для обновления данных дня из модалки
|
// Функция для обновления данных дня из модалки
|
||||||
const updateDayData = async (employeeId: string, date: Date, data: {
|
const updateDayData = async (
|
||||||
status: string
|
employeeId: string,
|
||||||
hoursWorked?: number
|
date: Date,
|
||||||
overtimeHours?: number
|
data: {
|
||||||
notes?: string
|
status: string;
|
||||||
}) => {
|
hoursWorked?: number;
|
||||||
|
overtimeHours?: number;
|
||||||
|
notes?: string;
|
||||||
|
}
|
||||||
|
) => {
|
||||||
try {
|
try {
|
||||||
// Отправляем мутацию
|
// Отправляем мутацию
|
||||||
await updateEmployeeSchedule({
|
await updateEmployeeSchedule({
|
||||||
variables: {
|
variables: {
|
||||||
input: {
|
input: {
|
||||||
employeeId: employeeId,
|
employeeId: employeeId,
|
||||||
date: date.toISOString().split('T')[0], // YYYY-MM-DD формат
|
date: date.toISOString().split("T")[0], // YYYY-MM-DD формат
|
||||||
status: data.status,
|
status: data.status,
|
||||||
hoursWorked: data.hoursWorked,
|
hoursWorked: data.hoursWorked,
|
||||||
overtimeHours: data.overtimeHours,
|
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 => {
|
setEmployeeSchedules((prev) => {
|
||||||
const currentSchedule = prev[employeeId] || []
|
const currentSchedule = prev[employeeId] || [];
|
||||||
const existingRecordIndex = currentSchedule.findIndex(record =>
|
const existingRecordIndex = currentSchedule.findIndex(
|
||||||
record.date.split('T')[0] === dateStr
|
(record) => record.date.split("T")[0] === dateStr
|
||||||
)
|
);
|
||||||
|
|
||||||
const newRecord = {
|
const newRecord = {
|
||||||
id: Date.now().toString(), // временный ID
|
id: Date.now().toString(), // временный ID
|
||||||
@ -296,73 +329,98 @@ export function EmployeesDashboard() {
|
|||||||
hoursWorked: data.hoursWorked,
|
hoursWorked: data.hoursWorked,
|
||||||
overtimeHours: data.overtimeHours,
|
overtimeHours: data.overtimeHours,
|
||||||
notes: data.notes,
|
notes: data.notes,
|
||||||
employee: { id: employeeId }
|
employee: { id: employeeId },
|
||||||
}
|
};
|
||||||
|
|
||||||
let updatedSchedule
|
let updatedSchedule;
|
||||||
if (existingRecordIndex >= 0) {
|
if (existingRecordIndex >= 0) {
|
||||||
// Обновляем существующую запись
|
// Обновляем существующую запись
|
||||||
updatedSchedule = [...currentSchedule]
|
updatedSchedule = [...currentSchedule];
|
||||||
updatedSchedule[existingRecordIndex] = { ...updatedSchedule[existingRecordIndex], ...newRecord }
|
updatedSchedule[existingRecordIndex] = {
|
||||||
|
...updatedSchedule[existingRecordIndex],
|
||||||
|
...newRecord,
|
||||||
|
};
|
||||||
} else {
|
} else {
|
||||||
// Добавляем новую запись
|
// Добавляем новую запись
|
||||||
updatedSchedule = [...currentSchedule, newRecord]
|
updatedSchedule = [...currentSchedule, newRecord];
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...prev,
|
...prev,
|
||||||
[employeeId]: updatedSchedule
|
[employeeId]: updatedSchedule,
|
||||||
}
|
};
|
||||||
})
|
});
|
||||||
|
|
||||||
toast.success('Данные дня обновлены')
|
|
||||||
|
|
||||||
|
toast.success("Данные дня обновлены");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error updating day data:', error)
|
console.error("Error updating day data:", error);
|
||||||
toast.error('Ошибка при обновлении данных дня')
|
toast.error("Ошибка при обновлении данных дня");
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const exportToCSV = () => {
|
const exportToCSV = () => {
|
||||||
const csvContent = [
|
const csvContent = [
|
||||||
['ФИО', 'Должность', 'Статус', 'Зарплата', 'Телефон', 'Email', 'Дата найма'],
|
[
|
||||||
|
"ФИО",
|
||||||
|
"Должность",
|
||||||
|
"Статус",
|
||||||
|
"Зарплата",
|
||||||
|
"Телефон",
|
||||||
|
"Email",
|
||||||
|
"Дата найма",
|
||||||
|
],
|
||||||
...employees.map((emp: Employee) => [
|
...employees.map((emp: Employee) => [
|
||||||
`${emp.firstName} ${emp.lastName}`,
|
`${emp.firstName} ${emp.lastName}`,
|
||||||
emp.position,
|
emp.position,
|
||||||
emp.status === 'ACTIVE' ? 'Активен' :
|
emp.status === "ACTIVE"
|
||||||
emp.status === 'VACATION' ? 'В отпуске' :
|
? "Активен"
|
||||||
emp.status === 'SICK' ? 'На больничном' : 'Уволен',
|
: emp.status === "VACATION"
|
||||||
emp.salary?.toString() || '',
|
? "В отпуске"
|
||||||
|
: emp.status === "SICK"
|
||||||
|
? "На больничном"
|
||||||
|
: "Уволен",
|
||||||
|
emp.salary?.toString() || "",
|
||||||
emp.phone,
|
emp.phone,
|
||||||
emp.email || '',
|
emp.email || "",
|
||||||
new Date(emp.hireDate).toLocaleDateString('ru-RU')
|
new Date(emp.hireDate).toLocaleDateString("ru-RU"),
|
||||||
])
|
]),
|
||||||
].map(row => row.join(',')).join('\n')
|
]
|
||||||
|
.map((row) => row.join(","))
|
||||||
|
.join("\n");
|
||||||
|
|
||||||
const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' })
|
const blob = new Blob([csvContent], { type: "text/csv;charset=utf-8;" });
|
||||||
const link = document.createElement('a')
|
const link = document.createElement("a");
|
||||||
const url = URL.createObjectURL(blob)
|
const url = URL.createObjectURL(blob);
|
||||||
link.setAttribute('href', url)
|
link.setAttribute("href", url);
|
||||||
link.setAttribute('download', `employees_report_${new Date().toISOString().split('T')[0]}.csv`)
|
link.setAttribute(
|
||||||
document.body.appendChild(link)
|
"download",
|
||||||
link.click()
|
`employees_report_${new Date().toISOString().split("T")[0]}.csv`
|
||||||
document.body.removeChild(link)
|
);
|
||||||
toast.success('Отчет успешно экспортирован')
|
document.body.appendChild(link);
|
||||||
}
|
link.click();
|
||||||
|
document.body.removeChild(link);
|
||||||
|
toast.success("Отчет успешно экспортирован");
|
||||||
|
};
|
||||||
|
|
||||||
const generateReport = () => {
|
const generateReport = () => {
|
||||||
const stats = {
|
const stats = {
|
||||||
total: employees.length,
|
total: employees.length,
|
||||||
active: employees.filter((e: Employee) => e.status === 'ACTIVE').length,
|
active: employees.filter((e: Employee) => e.status === "ACTIVE").length,
|
||||||
vacation: employees.filter((e: Employee) => e.status === 'VACATION').length,
|
vacation: employees.filter((e: Employee) => e.status === "VACATION")
|
||||||
sick: employees.filter((e: Employee) => e.status === 'SICK').length,
|
.length,
|
||||||
inactive: employees.filter((e: Employee) => e.status === 'FIRED').length,
|
sick: employees.filter((e: Employee) => e.status === "SICK").length,
|
||||||
avgSalary: Math.round(employees.reduce((sum: number, e: Employee) => sum + (e.salary || 0), 0) / employees.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 = `
|
const reportText = `
|
||||||
ОТЧЕТ ПО СОТРУДНИКАМ
|
ОТЧЕТ ПО СОТРУДНИКАМ
|
||||||
Дата: ${new Date().toLocaleDateString('ru-RU')}
|
Дата: ${new Date().toLocaleDateString("ru-RU")}
|
||||||
|
|
||||||
ОБЩАЯ СТАТИСТИКА:
|
ОБЩАЯ СТАТИСТИКА:
|
||||||
• Всего сотрудников: ${stats.total}
|
• Всего сотрудников: ${stats.total}
|
||||||
@ -370,24 +428,29 @@ export function EmployeesDashboard() {
|
|||||||
• В отпуске: ${stats.vacation}
|
• В отпуске: ${stats.vacation}
|
||||||
• На больничном: ${stats.sick}
|
• На больничном: ${stats.sick}
|
||||||
• Неактивных: ${stats.inactive}
|
• Неактивных: ${stats.inactive}
|
||||||
• Средняя зарплата: ${stats.avgSalary.toLocaleString('ru-RU')} ₽
|
• Средняя зарплата: ${stats.avgSalary.toLocaleString("ru-RU")} ₽
|
||||||
|
|
||||||
СПИСОК СОТРУДНИКОВ:
|
СПИСОК СОТРУДНИКОВ:
|
||||||
${employees.map((emp: Employee) =>
|
${employees
|
||||||
`• ${emp.firstName} ${emp.lastName} - ${emp.position}`
|
.map(
|
||||||
).join('\n')}
|
(emp: Employee) => `• ${emp.firstName} ${emp.lastName} - ${emp.position}`
|
||||||
`.trim()
|
)
|
||||||
|
.join("\n")}
|
||||||
|
`.trim();
|
||||||
|
|
||||||
const blob = new Blob([reportText], { type: 'text/plain;charset=utf-8;' })
|
const blob = new Blob([reportText], { type: "text/plain;charset=utf-8;" });
|
||||||
const link = document.createElement('a')
|
const link = document.createElement("a");
|
||||||
const url = URL.createObjectURL(blob)
|
const url = URL.createObjectURL(blob);
|
||||||
link.setAttribute('href', url)
|
link.setAttribute("href", url);
|
||||||
link.setAttribute('download', `employees_summary_${new Date().toISOString().split('T')[0]}.txt`)
|
link.setAttribute(
|
||||||
document.body.appendChild(link)
|
"download",
|
||||||
link.click()
|
`employees_summary_${new Date().toISOString().split("T")[0]}.txt`
|
||||||
document.body.removeChild(link)
|
);
|
||||||
toast.success('Сводный отчет создан')
|
document.body.appendChild(link);
|
||||||
}
|
link.click();
|
||||||
|
document.body.removeChild(link);
|
||||||
|
toast.success("Сводный отчет создан");
|
||||||
|
};
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
@ -401,7 +464,7 @@ ${employees.map((emp: Employee) =>
|
|||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -410,7 +473,11 @@ ${employees.map((emp: Employee) =>
|
|||||||
<main className="flex-1 ml-56 p-6">
|
<main className="flex-1 ml-56 p-6">
|
||||||
<div className="max-w-7xl mx-auto">
|
<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">
|
<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">
|
<TabsList className="glass-card inline-flex h-10 items-center justify-center rounded-lg bg-white/5 p-1">
|
||||||
@ -467,14 +534,14 @@ ${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"
|
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" />
|
<Plus className="h-4 w-4 mr-2" />
|
||||||
{showAddForm ? 'Скрыть форму' : 'Добавить'}
|
{showAddForm ? "Скрыть форму" : "Добавить"}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Форма добавления сотрудника */}
|
{/* Форма добавления сотрудника */}
|
||||||
{showAddForm && (
|
{showAddForm &&
|
||||||
showCompactForm ? (
|
(showCompactForm ? (
|
||||||
<EmployeeCompactForm
|
<EmployeeCompactForm
|
||||||
onSave={handleCreateEmployee}
|
onSave={handleCreateEmployee}
|
||||||
onCancel={() => setShowAddForm(false)}
|
onCancel={() => setShowAddForm(false)}
|
||||||
@ -486,8 +553,7 @@ ${employees.map((emp: Employee) =>
|
|||||||
onCancel={() => setShowAddForm(false)}
|
onCancel={() => setShowAddForm(false)}
|
||||||
isLoading={createLoading}
|
isLoading={createLoading}
|
||||||
/>
|
/>
|
||||||
)
|
))}
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Форма редактирования сотрудника */}
|
{/* Форма редактирования сотрудника */}
|
||||||
{showEditForm && editingEmployee && (
|
{showEditForm && editingEmployee && (
|
||||||
@ -495,8 +561,8 @@ ${employees.map((emp: Employee) =>
|
|||||||
employee={editingEmployee}
|
employee={editingEmployee}
|
||||||
onSave={handleEmployeeSaved}
|
onSave={handleEmployeeSaved}
|
||||||
onCancel={() => {
|
onCancel={() => {
|
||||||
setShowEditForm(false)
|
setShowEditForm(false);
|
||||||
setEditingEmployee(null)
|
setEditingEmployee(null);
|
||||||
}}
|
}}
|
||||||
isLoading={createLoading}
|
isLoading={createLoading}
|
||||||
/>
|
/>
|
||||||
@ -506,10 +572,15 @@ ${employees.map((emp: Employee) =>
|
|||||||
<TabsContent value="combined">
|
<TabsContent value="combined">
|
||||||
<Card className="glass-card p-6">
|
<Card className="glass-card p-6">
|
||||||
{(() => {
|
{(() => {
|
||||||
const filteredEmployees = employees.filter((employee: Employee) =>
|
const filteredEmployees = employees.filter(
|
||||||
`${employee.firstName} ${employee.lastName}`.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
(employee: Employee) =>
|
||||||
employee.position.toLowerCase().includes(searchQuery.toLowerCase())
|
`${employee.firstName} ${employee.lastName}`
|
||||||
)
|
.toLowerCase()
|
||||||
|
.includes(searchQuery.toLowerCase()) ||
|
||||||
|
employee.position
|
||||||
|
.toLowerCase()
|
||||||
|
.includes(searchQuery.toLowerCase())
|
||||||
|
);
|
||||||
|
|
||||||
if (filteredEmployees.length === 0) {
|
if (filteredEmployees.length === 0) {
|
||||||
return (
|
return (
|
||||||
@ -517,7 +588,7 @@ ${employees.map((emp: Employee) =>
|
|||||||
searchQuery={searchQuery}
|
searchQuery={searchQuery}
|
||||||
onShowAddForm={() => setShowAddForm(true)}
|
onShowAddForm={() => setShowAddForm(true)}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -526,11 +597,11 @@ ${employees.map((emp: Employee) =>
|
|||||||
<div className="flex items-center justify-between mb-6">
|
<div className="flex items-center justify-between mb-6">
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
<h3 className="text-white font-medium text-lg capitalize">
|
<h3 className="text-white font-medium text-lg capitalize">
|
||||||
{new Date().toLocaleDateString('ru-RU', {
|
{new Date().toLocaleDateString("ru-RU", {
|
||||||
weekday: 'long',
|
weekday: "long",
|
||||||
day: 'numeric',
|
day: "numeric",
|
||||||
month: 'long',
|
month: "long",
|
||||||
year: 'numeric'
|
year: "numeric",
|
||||||
})}
|
})}
|
||||||
</h3>
|
</h3>
|
||||||
|
|
||||||
@ -541,9 +612,12 @@ ${employees.map((emp: Employee) =>
|
|||||||
size="sm"
|
size="sm"
|
||||||
className="glass-secondary text-white hover:text-white h-8 px-3"
|
className="glass-secondary text-white hover:text-white h-8 px-3"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
const newDate = new Date(currentYear, currentMonth - 1)
|
const newDate = new Date(
|
||||||
setCurrentYear(newDate.getFullYear())
|
currentYear,
|
||||||
setCurrentMonth(newDate.getMonth())
|
currentMonth - 1
|
||||||
|
);
|
||||||
|
setCurrentYear(newDate.getFullYear());
|
||||||
|
setCurrentMonth(newDate.getMonth());
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
←
|
←
|
||||||
@ -553,9 +627,9 @@ ${employees.map((emp: Employee) =>
|
|||||||
size="sm"
|
size="sm"
|
||||||
className="glass-secondary text-white hover:text-white h-8 px-3"
|
className="glass-secondary text-white hover:text-white h-8 px-3"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
const today = new Date()
|
const today = new Date();
|
||||||
setCurrentYear(today.getFullYear())
|
setCurrentYear(today.getFullYear());
|
||||||
setCurrentMonth(today.getMonth())
|
setCurrentMonth(today.getMonth());
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Сегодня
|
Сегодня
|
||||||
@ -565,9 +639,12 @@ ${employees.map((emp: Employee) =>
|
|||||||
size="sm"
|
size="sm"
|
||||||
className="glass-secondary text-white hover:text-white h-8 px-3"
|
className="glass-secondary text-white hover:text-white h-8 px-3"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
const newDate = new Date(currentYear, currentMonth + 1)
|
const newDate = new Date(
|
||||||
setCurrentYear(newDate.getFullYear())
|
currentYear,
|
||||||
setCurrentMonth(newDate.getMonth())
|
currentMonth + 1
|
||||||
|
);
|
||||||
|
setCurrentYear(newDate.getFullYear());
|
||||||
|
setCurrentMonth(newDate.getMonth());
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
→
|
→
|
||||||
@ -581,8 +658,16 @@ ${employees.map((emp: Employee) =>
|
|||||||
|
|
||||||
{/* Компактный список сотрудников с раскрывающимся табелем */}
|
{/* Компактный список сотрудников с раскрывающимся табелем */}
|
||||||
<div>
|
<div>
|
||||||
{filteredEmployees.map((employee: Employee, index: number) => (
|
{filteredEmployees.map(
|
||||||
<div key={employee.id} className={index < filteredEmployees.length - 1 ? "mb-4" : ""}>
|
(employee: Employee, index: number) => (
|
||||||
|
<div
|
||||||
|
key={employee.id}
|
||||||
|
className={
|
||||||
|
index < filteredEmployees.length - 1
|
||||||
|
? "mb-4"
|
||||||
|
: ""
|
||||||
|
}
|
||||||
|
>
|
||||||
<EmployeeRow
|
<EmployeeRow
|
||||||
employee={employee}
|
employee={employee}
|
||||||
employeeSchedules={employeeSchedules}
|
employeeSchedules={employeeSchedules}
|
||||||
@ -595,10 +680,11 @@ ${employees.map((emp: Employee) =>
|
|||||||
deletingEmployeeId={deletingEmployeeId}
|
deletingEmployeeId={deletingEmployeeId}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
)
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
})()}
|
})()}
|
||||||
</Card>
|
</Card>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
@ -615,5 +701,5 @@ ${employees.map((emp: Employee) =>
|
|||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
}
|
@ -99,14 +99,44 @@ export function CreateFulfillmentConsumablesSupplyPage() {
|
|||||||
GET_MY_COUNTERPARTIES
|
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(
|
const {
|
||||||
GET_ALL_PRODUCTS,
|
data: productsData,
|
||||||
{
|
loading: productsLoading,
|
||||||
|
error: productsError,
|
||||||
|
} = useQuery(GET_ALL_PRODUCTS, {
|
||||||
skip: !selectedSupplier,
|
skip: !selectedSupplier,
|
||||||
variables: { search: productSearchQuery || null, category: null },
|
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);
|
const [createSupplyOrder] = useMutation(CREATE_SUPPLY_ORDER);
|
||||||
@ -117,9 +147,9 @@ export function CreateFulfillmentConsumablesSupplyPage() {
|
|||||||
).filter((org: FulfillmentConsumableSupplier) => org.type === "WHOLESALE");
|
).filter((org: FulfillmentConsumableSupplier) => org.type === "WHOLESALE");
|
||||||
|
|
||||||
// Фильтруем только логистические компании
|
// Фильтруем только логистические компании
|
||||||
const logisticsPartners = (
|
const logisticsPartners = (counterpartiesData?.myCounterparties || []).filter(
|
||||||
counterpartiesData?.myCounterparties || []
|
(org: FulfillmentConsumableSupplier) => org.type === "LOGIST"
|
||||||
).filter((org: FulfillmentConsumableSupplier) => org.type === "LOGIST");
|
);
|
||||||
|
|
||||||
// Фильтруем поставщиков по поисковому запросу
|
// Фильтруем поставщиков по поисковому запросу
|
||||||
const filteredSuppliers = consumableSuppliers.filter(
|
const filteredSuppliers = consumableSuppliers.filter(
|
||||||
@ -150,6 +180,7 @@ export function CreateFulfillmentConsumablesSupplyPage() {
|
|||||||
}
|
}
|
||||||
: null,
|
: null,
|
||||||
productsLoading,
|
productsLoading,
|
||||||
|
productsError: productsError?.message,
|
||||||
allProductsCount: productsData?.allProducts?.length || 0,
|
allProductsCount: productsData?.allProducts?.length || 0,
|
||||||
supplierProductsCount: supplierProducts.length,
|
supplierProductsCount: supplierProducts.length,
|
||||||
allProducts:
|
allProducts:
|
||||||
@ -160,14 +191,20 @@ export function CreateFulfillmentConsumablesSupplyPage() {
|
|||||||
organizationName: p.organization.name,
|
organizationName: p.organization.name,
|
||||||
type: p.type || "NO_TYPE",
|
type: p.type || "NO_TYPE",
|
||||||
})) || [],
|
})) || [],
|
||||||
supplierProducts: supplierProducts.map((p) => ({
|
supplierProductsDetails: supplierProducts.slice(0, 5).map((p) => ({
|
||||||
id: p.id,
|
id: p.id,
|
||||||
name: p.name,
|
name: p.name,
|
||||||
organizationId: p.organization.id,
|
organizationId: p.organization.id,
|
||||||
organizationName: p.organization.name,
|
organizationName: p.organization.name,
|
||||||
})),
|
})),
|
||||||
});
|
});
|
||||||
}, [selectedSupplier, productsData, productsLoading, supplierProducts]);
|
}, [
|
||||||
|
selectedSupplier,
|
||||||
|
productsData,
|
||||||
|
productsLoading,
|
||||||
|
productsError,
|
||||||
|
supplierProducts.length,
|
||||||
|
]);
|
||||||
|
|
||||||
const formatCurrency = (amount: number) => {
|
const formatCurrency = (amount: number) => {
|
||||||
return new Intl.NumberFormat("ru-RU", {
|
return new Intl.NumberFormat("ru-RU", {
|
||||||
@ -198,10 +235,13 @@ export function CreateFulfillmentConsumablesSupplyPage() {
|
|||||||
|
|
||||||
// 🔒 ВАЛИДАЦИЯ ОСТАТКОВ согласно правилам (раздел 6.2)
|
// 🔒 ВАЛИДАЦИЯ ОСТАТКОВ согласно правилам (раздел 6.2)
|
||||||
if (quantity > 0) {
|
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) {
|
if (quantity > availableStock) {
|
||||||
toast.error(`❌ Недостаточно остатков!\nДоступно: ${availableStock} шт.\nЗапрашивается: ${quantity} шт.`);
|
toast.error(
|
||||||
|
`❌ Недостаточно остатков!\nДоступно: ${availableStock} шт.\nЗапрашивается: ${quantity} шт.`
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -265,7 +305,15 @@ export function CreateFulfillmentConsumablesSupplyPage() {
|
|||||||
!deliveryDate ||
|
!deliveryDate ||
|
||||||
!selectedLogistics
|
!selectedLogistics
|
||||||
) {
|
) {
|
||||||
toast.error("Заполните все обязательные поля");
|
toast.error(
|
||||||
|
"Заполните все обязательные поля: поставщик, расходники, дата доставки и логистика"
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Дополнительная проверка ID логистики
|
||||||
|
if (!selectedLogistics.id) {
|
||||||
|
toast.error("Выберите логистическую компанию");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -279,7 +327,7 @@ export function CreateFulfillmentConsumablesSupplyPage() {
|
|||||||
deliveryDate: deliveryDate,
|
deliveryDate: deliveryDate,
|
||||||
// Для фулфилмента указываем себя как получателя (поставка на свой склад)
|
// Для фулфилмента указываем себя как получателя (поставка на свой склад)
|
||||||
fulfillmentCenterId: user?.organization?.id,
|
fulfillmentCenterId: user?.organization?.id,
|
||||||
logisticsPartnerId: selectedLogistics?.id,
|
logisticsPartnerId: selectedLogistics.id,
|
||||||
// 🏷️ КЛАССИФИКАЦИЯ согласно правилам (раздел 2.2)
|
// 🏷️ КЛАССИФИКАЦИЯ согласно правилам (раздел 2.2)
|
||||||
consumableType: "FULFILLMENT_CONSUMABLES", // Расходники фулфилмента
|
consumableType: "FULFILLMENT_CONSUMABLES", // Расходники фулфилмента
|
||||||
items: selectedConsumables.map((consumable) => ({
|
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">
|
<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 orderedStock = product.ordered || 0;
|
||||||
const availableStock = totalStock - orderedStock;
|
const availableStock =
|
||||||
|
totalStock - orderedStock;
|
||||||
|
|
||||||
if (availableStock <= 0) {
|
if (availableStock <= 0) {
|
||||||
return (
|
return (
|
||||||
<div className="absolute inset-0 bg-black/60 backdrop-blur-sm flex items-center justify-center z-10">
|
<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-center">
|
||||||
<div className="text-red-400 font-bold text-xs">НЕТ В НАЛИЧИИ</div>
|
<div className="text-red-400 font-bold text-xs">
|
||||||
|
НЕТ В НАЛИЧИИ
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@ -636,9 +688,11 @@ export function CreateFulfillmentConsumablesSupplyPage() {
|
|||||||
)}
|
)}
|
||||||
{/* 🚨 ИНДИКАТОР НИЗКИХ ОСТАТКОВ согласно правилам (раздел 6.3) */}
|
{/* 🚨 ИНДИКАТОР НИЗКИХ ОСТАТКОВ согласно правилам (раздел 6.3) */}
|
||||||
{(() => {
|
{(() => {
|
||||||
const totalStock = product.stock || product.quantity || 0;
|
const totalStock =
|
||||||
|
product.stock || product.quantity || 0;
|
||||||
const orderedStock = product.ordered || 0;
|
const orderedStock = product.ordered || 0;
|
||||||
const availableStock = totalStock - orderedStock;
|
const availableStock =
|
||||||
|
totalStock - orderedStock;
|
||||||
|
|
||||||
if (availableStock <= 0) {
|
if (availableStock <= 0) {
|
||||||
return (
|
return (
|
||||||
@ -663,19 +717,26 @@ export function CreateFulfillmentConsumablesSupplyPage() {
|
|||||||
{/* 📊 АКТУАЛЬНЫЙ ОСТАТОК согласно правилам (раздел 6.4.2) */}
|
{/* 📊 АКТУАЛЬНЫЙ ОСТАТОК согласно правилам (раздел 6.4.2) */}
|
||||||
<div className="text-right">
|
<div className="text-right">
|
||||||
{(() => {
|
{(() => {
|
||||||
const totalStock = product.stock || product.quantity || 0;
|
const totalStock =
|
||||||
const orderedStock = product.ordered || 0;
|
product.stock ||
|
||||||
const availableStock = totalStock - orderedStock;
|
product.quantity ||
|
||||||
|
0;
|
||||||
|
const orderedStock =
|
||||||
|
product.ordered || 0;
|
||||||
|
const availableStock =
|
||||||
|
totalStock - orderedStock;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col items-end">
|
<div className="flex flex-col items-end">
|
||||||
<span className={`text-xs font-medium ${
|
<span
|
||||||
|
className={`text-xs font-medium ${
|
||||||
availableStock <= 0
|
availableStock <= 0
|
||||||
? 'text-red-400'
|
? "text-red-400"
|
||||||
: availableStock <= 10
|
: availableStock <= 10
|
||||||
? 'text-yellow-400'
|
? "text-yellow-400"
|
||||||
: 'text-white/80'
|
: "text-white/80"
|
||||||
}`}>
|
}`}
|
||||||
|
>
|
||||||
Доступно: {availableStock}
|
Доступно: {availableStock}
|
||||||
</span>
|
</span>
|
||||||
{orderedStock > 0 && (
|
{orderedStock > 0 && (
|
||||||
@ -693,9 +754,11 @@ export function CreateFulfillmentConsumablesSupplyPage() {
|
|||||||
{/* Управление количеством */}
|
{/* Управление количеством */}
|
||||||
<div className="flex flex-col items-center space-y-2 mt-auto">
|
<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 orderedStock = product.ordered || 0;
|
||||||
const availableStock = totalStock - orderedStock;
|
const availableStock =
|
||||||
|
totalStock - orderedStock;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center space-x-2">
|
||||||
@ -773,21 +836,32 @@ export function CreateFulfillmentConsumablesSupplyPage() {
|
|||||||
onClick={() =>
|
onClick={() =>
|
||||||
updateConsumableQuantity(
|
updateConsumableQuantity(
|
||||||
product.id,
|
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 ${
|
className={`h-6 w-6 p-0 rounded-full transition-all duration-300 ${
|
||||||
selectedQuantity >= availableStock || availableStock <= 0
|
selectedQuantity >=
|
||||||
? 'text-white/30 cursor-not-allowed'
|
availableStock ||
|
||||||
: 'text-white/60 hover:text-white hover:bg-white/20'
|
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={
|
title={
|
||||||
availableStock <= 0
|
availableStock <= 0
|
||||||
? 'Товар отсутствует на складе'
|
? "Товар отсутствует на складе"
|
||||||
: selectedQuantity >= availableStock
|
: selectedQuantity >=
|
||||||
|
availableStock
|
||||||
? `Максимум доступно: ${availableStock}`
|
? `Максимум доступно: ${availableStock}`
|
||||||
: 'Увеличить количество'
|
: "Увеличить количество"
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Plus className="h-3 w-3" />
|
<Plus className="h-3 w-3" />
|
||||||
@ -903,7 +977,9 @@ export function CreateFulfillmentConsumablesSupplyPage() {
|
|||||||
value={selectedLogistics?.id || ""}
|
value={selectedLogistics?.id || ""}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
const logisticsId = e.target.value;
|
const logisticsId = e.target.value;
|
||||||
const logistics = logisticsPartners.find(p => p.id === logisticsId);
|
const logistics = logisticsPartners.find(
|
||||||
|
(p) => p.id === logisticsId
|
||||||
|
);
|
||||||
setSelectedLogistics(logistics || null);
|
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"
|
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>
|
</select>
|
||||||
<div className="absolute inset-y-0 right-0 flex items-center px-2 pointer-events-none">
|
<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">
|
<svg
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
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>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -1,17 +1,29 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
import React, { useState } from "react";
|
||||||
import { useQuery } from "@apollo/client";
|
import { useQuery } from "@apollo/client";
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
import { Card } from "@/components/ui/card";
|
import { Card } from "@/components/ui/card";
|
||||||
import { Sidebar } from "@/components/dashboard/sidebar";
|
import { Sidebar } from "@/components/dashboard/sidebar";
|
||||||
import { useSidebar } from "@/hooks/useSidebar";
|
import { useSidebar } from "@/hooks/useSidebar";
|
||||||
import { GET_PENDING_SUPPLIES_COUNT } from "@/graphql/queries";
|
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 { FulfillmentSuppliesTab } from "./fulfillment-supplies/fulfillment-supplies-tab";
|
||||||
import { MarketplaceSuppliesTab } from "./marketplace-supplies/marketplace-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 }) {
|
function NotificationBadge({ count }: { count: number }) {
|
||||||
@ -27,72 +39,390 @@ function NotificationBadge({ count }: { count: number }) {
|
|||||||
export function FulfillmentSuppliesDashboard() {
|
export function FulfillmentSuppliesDashboard() {
|
||||||
const { getSidebarMargin } = useSidebar();
|
const { getSidebarMargin } = useSidebar();
|
||||||
const [activeTab, setActiveTab] = useState("fulfillment");
|
const [activeTab, setActiveTab] = useState("fulfillment");
|
||||||
|
const [activeSubTab, setActiveSubTab] = useState("goods"); // товар
|
||||||
|
const [activeThirdTab, setActiveThirdTab] = useState("new"); // новые
|
||||||
|
|
||||||
// Загружаем данные о непринятых поставках
|
// Загружаем данные о непринятых поставках
|
||||||
const { data: pendingData } = useQuery(GET_PENDING_SUPPLIES_COUNT, {
|
const { data: pendingData, error: pendingError } = useQuery(
|
||||||
|
GET_PENDING_SUPPLIES_COUNT,
|
||||||
|
{
|
||||||
pollInterval: 30000, // Обновляем каждые 30 секунд
|
pollInterval: 30000, // Обновляем каждые 30 секунд
|
||||||
fetchPolicy: "cache-first",
|
fetchPolicy: "cache-first",
|
||||||
errorPolicy: "ignore",
|
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 (
|
return (
|
||||||
<div className="h-screen flex overflow-hidden">
|
<div className="h-screen flex overflow-hidden">
|
||||||
<Sidebar />
|
<Sidebar />
|
||||||
<main
|
<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="h-full w-full flex flex-col space-y-4">
|
||||||
{/* Основной контент с табами */}
|
{/* БЛОК 1: ТАБЫ ВСЕХ УРОВНЕЙ */}
|
||||||
<div className="flex-1 overflow-hidden">
|
<div className="bg-white/10 backdrop-blur-xl border border-white/20 rounded-2xl p-6">
|
||||||
<Tabs
|
{/* УРОВЕНЬ 1: Главные табы */}
|
||||||
value={activeTab}
|
<div className="mb-4">
|
||||||
onValueChange={setActiveTab}
|
<div className="grid w-full grid-cols-2 bg-white/15 backdrop-blur border-white/30 rounded-xl h-11 p-2">
|
||||||
className="h-full flex flex-col"
|
<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"
|
||||||
|
}`}
|
||||||
>
|
>
|
||||||
<TabsList className="grid w-full grid-cols-2 bg-white/5 backdrop-blur border-white/10 flex-shrink-0 h-8 xl:h-10">
|
<Building2 className="h-4 w-4" />
|
||||||
<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"
|
|
||||||
>
|
|
||||||
<Building2 className="h-3 w-3" />
|
|
||||||
<span className="hidden sm:inline">
|
<span className="hidden sm:inline">
|
||||||
Поставки на фулфилмент
|
Поставки на фулфилмент
|
||||||
</span>
|
</span>
|
||||||
<span className="sm:hidden">Фулфилмент</span>
|
<span className="sm:hidden">Фулфилмент</span>
|
||||||
<NotificationBadge count={pendingCount} />
|
<NotificationBadge count={pendingCount} />
|
||||||
</TabsTrigger>
|
</button>
|
||||||
<TabsTrigger
|
<button
|
||||||
value="marketplace"
|
onClick={() => setActiveTab("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"
|
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 className="hidden sm:inline">
|
||||||
Поставки на маркетплейсы
|
Поставки на маркетплейсы
|
||||||
</span>
|
</span>
|
||||||
<span className="sm:hidden">Маркетплейсы</span>
|
<span className="sm:hidden">Маркетплейсы</span>
|
||||||
</TabsTrigger>
|
</button>
|
||||||
</TabsList>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<TabsContent
|
{/* УРОВЕНЬ 2: Подтабы */}
|
||||||
value="fulfillment"
|
{activeTab === "fulfillment" && (
|
||||||
className="flex-1 overflow-hidden mt-2 xl:mt-3"
|
<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"
|
||||||
|
}`}
|
||||||
>
|
>
|
||||||
<Card className="glass-card h-full overflow-hidden p-0">
|
<Package className="h-3 w-3" />
|
||||||
<FulfillmentSuppliesTab />
|
<span className="hidden sm:inline">Товар</span>
|
||||||
</Card>
|
<span className="sm:hidden">Т</span>
|
||||||
</TabsContent>
|
</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
|
{/* УРОВЕНЬ 3: Подподтабы */}
|
||||||
value="marketplace"
|
{activeTab === "fulfillment" && activeSubTab === "goods" && (
|
||||||
className="flex-1 overflow-hidden mt-2 xl:mt-3"
|
<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"
|
||||||
|
}`}
|
||||||
>
|
>
|
||||||
<Card className="glass-card h-full overflow-hidden p-0">
|
<Clock className="h-2.5 w-2.5" />
|
||||||
<MarketplaceSuppliesTab />
|
<span className="hidden sm:inline">Новые</span>
|
||||||
</Card>
|
<span className="sm:hidden">Н</span>
|
||||||
</TabsContent>
|
</button>
|
||||||
</Tabs>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
import React, { useState } from "react";
|
||||||
import { Card } from "@/components/ui/card";
|
import { Card } from "@/components/ui/card";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
@ -12,8 +12,14 @@ import {
|
|||||||
GET_MY_SUPPLIES,
|
GET_MY_SUPPLIES,
|
||||||
GET_PENDING_SUPPLIES_COUNT,
|
GET_PENDING_SUPPLIES_COUNT,
|
||||||
GET_WAREHOUSE_PRODUCTS,
|
GET_WAREHOUSE_PRODUCTS,
|
||||||
|
GET_MY_EMPLOYEES,
|
||||||
|
GET_LOGISTICS_PARTNERS,
|
||||||
} from "@/graphql/queries";
|
} 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 { useAuth } from "@/hooks/useAuth";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import {
|
import {
|
||||||
@ -34,16 +40,36 @@ import {
|
|||||||
Store,
|
Store,
|
||||||
Bell,
|
Bell,
|
||||||
AlertTriangle,
|
AlertTriangle,
|
||||||
|
UserPlus,
|
||||||
|
Settings,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
|
||||||
interface SupplyOrder {
|
interface SupplyOrder {
|
||||||
id: string;
|
id: string;
|
||||||
partnerId: string;
|
partnerId: string;
|
||||||
deliveryDate: string;
|
deliveryDate: string;
|
||||||
status: "PENDING" | "CONFIRMED" | "IN_TRANSIT" | "DELIVERED" | "CANCELLED";
|
status:
|
||||||
|
| "PENDING"
|
||||||
|
| "SUPPLIER_APPROVED"
|
||||||
|
| "CONFIRMED"
|
||||||
|
| "LOGISTICS_CONFIRMED"
|
||||||
|
| "SHIPPED"
|
||||||
|
| "IN_TRANSIT"
|
||||||
|
| "DELIVERED"
|
||||||
|
| "CANCELLED";
|
||||||
totalAmount: number;
|
totalAmount: number;
|
||||||
totalItems: number;
|
totalItems: number;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
|
fulfillmentCenter?: {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
fullName: string;
|
||||||
|
};
|
||||||
|
organization?: {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
fullName: string;
|
||||||
|
};
|
||||||
partner: {
|
partner: {
|
||||||
id: string;
|
id: string;
|
||||||
inn: string;
|
inn: string;
|
||||||
@ -83,31 +109,106 @@ interface SupplyOrder {
|
|||||||
|
|
||||||
export function FulfillmentConsumablesOrdersTab() {
|
export function FulfillmentConsumablesOrdersTab() {
|
||||||
const [expandedOrders, setExpandedOrders] = useState<Set<string>>(new Set());
|
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 { 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 { data, loading, error, refetch } = useQuery(GET_SUPPLY_ORDERS);
|
||||||
|
|
||||||
// Мутация для обновления статуса заказа
|
// Мутация для приемки поставки фулфилментом
|
||||||
const [updateSupplyOrderStatus, { loading: updating }] = useMutation(
|
const [fulfillmentReceiveOrder, { loading: receiving }] = useMutation(
|
||||||
UPDATE_SUPPLY_ORDER_STATUS,
|
FULFILLMENT_RECEIVE_ORDER,
|
||||||
{
|
{
|
||||||
onCompleted: (data) => {
|
onCompleted: (data) => {
|
||||||
if (data.updateSupplyOrderStatus.success) {
|
if (data.fulfillmentReceiveOrder.success) {
|
||||||
toast.success(data.updateSupplyOrderStatus.message);
|
toast.success(data.fulfillmentReceiveOrder.message);
|
||||||
refetch(); // Обновляем список заказов
|
refetch(); // Обновляем список заказов
|
||||||
} else {
|
} else {
|
||||||
toast.error(data.updateSupplyOrderStatus.message);
|
toast.error(data.fulfillmentReceiveOrder.message);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
refetchQueries: [
|
refetchQueries: [
|
||||||
{ query: GET_SUPPLY_ORDERS }, // Обновляем заказы поставок
|
{ query: GET_SUPPLY_ORDERS }, // Обновляем заказы поставок
|
||||||
{ query: GET_MY_SUPPLIES }, // Обновляем склад фулфилмента (расходники фулфилмента)
|
{ query: GET_MY_SUPPLIES }, // Обновляем склад фулфилмента (расходники фулфилмента)
|
||||||
{ query: GET_WAREHOUSE_PRODUCTS }, // Обновляем товары склада
|
{ query: GET_WAREHOUSE_PRODUCTS }, // Обновляем товары склада
|
||||||
|
{ query: GET_PENDING_SUPPLIES_COUNT }, // Обновляем счетчики уведомлений
|
||||||
],
|
],
|
||||||
onError: (error) => {
|
onError: (error) => {
|
||||||
console.error("Error updating supply order status:", error);
|
console.error("Error receiving supply order:", error);
|
||||||
toast.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, // Обратный порядок для новых заказов сверху
|
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 getStatusBadge = (status: SupplyOrder["status"]) => {
|
||||||
const statusMap = {
|
const statusMap = {
|
||||||
PENDING: {
|
PENDING: {
|
||||||
@ -151,11 +265,26 @@ export function FulfillmentConsumablesOrdersTab() {
|
|||||||
color: "bg-blue-500/20 text-blue-300 border-blue-500/30",
|
color: "bg-blue-500/20 text-blue-300 border-blue-500/30",
|
||||||
icon: Clock,
|
icon: Clock,
|
||||||
},
|
},
|
||||||
CONFIRMED: {
|
SUPPLIER_APPROVED: {
|
||||||
label: "Подтверждена",
|
label: "Одобрено",
|
||||||
color: "bg-green-500/20 text-green-300 border-green-500/30",
|
color: "bg-green-500/20 text-green-300 border-green-500/30",
|
||||||
icon: CheckCircle,
|
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: {
|
IN_TRANSIT: {
|
||||||
label: "В пути",
|
label: "В пути",
|
||||||
color: "bg-yellow-500/20 text-yellow-300 border-yellow-500/30",
|
color: "bg-yellow-500/20 text-yellow-300 border-yellow-500/30",
|
||||||
@ -181,24 +310,69 @@ export function FulfillmentConsumablesOrdersTab() {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleStatusUpdate = async (
|
// Функция для приема заказа фулфилментом
|
||||||
orderId: string,
|
const handleReceiveOrder = async (orderId: string) => {
|
||||||
newStatus: SupplyOrder["status"]
|
|
||||||
) => {
|
|
||||||
try {
|
try {
|
||||||
await updateSupplyOrderStatus({
|
await fulfillmentReceiveOrder({
|
||||||
variables: {
|
variables: { id: orderId },
|
||||||
id: orderId,
|
|
||||||
status: newStatus,
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} 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) => {
|
const formatDate = (dateString: string) => {
|
||||||
@ -247,15 +421,15 @@ export function FulfillmentConsumablesOrdersTab() {
|
|||||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-2">
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-2">
|
||||||
<Card className="bg-white/10 backdrop-blur border-white/20 p-2">
|
<Card className="bg-white/10 backdrop-blur border-white/20 p-2">
|
||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center space-x-2">
|
||||||
<div className="p-1 bg-blue-500/20 rounded">
|
<div className="p-1 bg-green-500/20 rounded">
|
||||||
<Clock className="h-3 w-3 text-blue-400" />
|
<CheckCircle className="h-3 w-3 text-green-400" />
|
||||||
</div>
|
</div>
|
||||||
<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">
|
<p className="text-sm font-bold text-white">
|
||||||
{
|
{
|
||||||
fulfillmentOrders.filter(
|
fulfillmentOrders.filter(
|
||||||
(order) => order.status === "PENDING"
|
(order) => order.status === "SUPPLIER_APPROVED"
|
||||||
).length
|
).length
|
||||||
}
|
}
|
||||||
</p>
|
</p>
|
||||||
@ -265,8 +439,8 @@ export function FulfillmentConsumablesOrdersTab() {
|
|||||||
|
|
||||||
<Card className="bg-white/10 backdrop-blur border-white/20 p-2">
|
<Card className="bg-white/10 backdrop-blur border-white/20 p-2">
|
||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center space-x-2">
|
||||||
<div className="p-1 bg-green-500/20 rounded">
|
<div className="p-1 bg-emerald-500/20 rounded">
|
||||||
<CheckCircle className="h-3 w-3 text-green-400" />
|
<CheckCircle className="h-3 w-3 text-emerald-400" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p className="text-white/60 text-xs">Подтверждено</p>
|
<p className="text-white/60 text-xs">Подтверждено</p>
|
||||||
@ -336,8 +510,18 @@ export function FulfillmentConsumablesOrdersTab() {
|
|||||||
ordersWithNumbers.map((order) => (
|
ordersWithNumbers.map((order) => (
|
||||||
<Card
|
<Card
|
||||||
key={order.id}
|
key={order.id}
|
||||||
className="bg-white/10 backdrop-blur border-white/20 overflow-hidden hover:bg-white/15 transition-colors cursor-pointer"
|
className={`bg-white/10 backdrop-blur border-white/20 overflow-hidden hover:bg-white/15 transition-colors ${
|
||||||
onClick={() => toggleOrderExpansion(order.id)}
|
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">
|
<div className="px-3 py-2">
|
||||||
@ -427,53 +611,20 @@ export function FulfillmentConsumablesOrdersTab() {
|
|||||||
|
|
||||||
{/* Правая часть - статус и действия */}
|
{/* Правая часть - статус и действия */}
|
||||||
<div className="flex items-center space-x-2 flex-shrink-0">
|
<div className="flex items-center space-x-2 flex-shrink-0">
|
||||||
<Badge
|
{getStatusBadge(order.status)}
|
||||||
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>
|
|
||||||
|
|
||||||
{canMarkAsDelivered(order.status) && (
|
{canReceiveOrder(order.status) && (
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
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"
|
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" />
|
<CheckCircle className="h-3 w-3 mr-1" />
|
||||||
Получено
|
Принять
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@ -528,6 +679,100 @@ export function FulfillmentConsumablesOrdersTab() {
|
|||||||
</div>
|
</div>
|
||||||
</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) && (
|
{expandedOrders.has(order.id) && (
|
||||||
<>
|
<>
|
||||||
|
@ -33,8 +33,6 @@ import {
|
|||||||
CheckCircle,
|
CheckCircle,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
// Интерфейс для заказа
|
// Интерфейс для заказа
|
||||||
interface SupplyOrder {
|
interface SupplyOrder {
|
||||||
id: string;
|
id: string;
|
||||||
@ -174,10 +172,14 @@ export function FulfillmentDetailedSuppliesTab() {
|
|||||||
// "Расходники фулфилмента" = расходники, которые МЫ (фулфилмент-центр) заказали для себя
|
// "Расходники фулфилмента" = расходники, которые МЫ (фулфилмент-центр) заказали для себя
|
||||||
// Критерии: создатель = мы И получатель = мы (ОБА условия)
|
// Критерии: создатель = мы И получатель = мы (ОБА условия)
|
||||||
const ourSupplyOrders: SupplyOrder[] = (data?.supplyOrders || []).filter(
|
const ourSupplyOrders: SupplyOrder[] = (data?.supplyOrders || []).filter(
|
||||||
(order: SupplyOrder) => {
|
(order: any) => {
|
||||||
|
// Защита от null/undefined значений
|
||||||
return (
|
return (
|
||||||
order.organizationId === currentOrganizationId && // Создали мы
|
order?.organizationId === currentOrganizationId && // Создали мы
|
||||||
order.fulfillmentCenterId === 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 className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-xl font-bold text-white mb-1">Расходники фулфилмента</h2>
|
<h2 className="text-xl font-bold text-white mb-1">
|
||||||
|
Расходники фулфилмента
|
||||||
|
</h2>
|
||||||
<p className="text-white/60 text-sm">
|
<p className="text-white/60 text-sm">
|
||||||
Поставки расходников, поступающие на склад фулфилмент-центра
|
Поставки расходников, поступающие на склад фулфилмент-центра
|
||||||
</p>
|
</p>
|
||||||
|
@ -40,8 +40,6 @@ import {
|
|||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
// Интерфейсы для данных
|
// Интерфейсы для данных
|
||||||
interface Employee {
|
interface Employee {
|
||||||
id: string;
|
id: string;
|
||||||
@ -662,53 +660,51 @@ export function FulfillmentGoodsTab() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-full flex flex-col p-2 xl:p-4">
|
<div className="space-y-3">
|
||||||
<Tabs
|
{/* УРОВЕНЬ 3: Подподтабы (маленький размер, больший отступ) */}
|
||||||
value={activeTab}
|
<div className="ml-8">
|
||||||
onValueChange={setActiveTab}
|
<Tabs value={activeTab} onValueChange={setActiveTab} className="w-full">
|
||||||
className="h-full flex flex-col"
|
<TabsList className="grid w-full grid-cols-3 bg-white/5 backdrop-blur border-white/15 h-8 rounded-md p-1 mb-3">
|
||||||
>
|
|
||||||
{/* Вкладки товаров */}
|
|
||||||
<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
|
<TabsTrigger
|
||||||
value="new"
|
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"
|
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-3 w-3 xl:h-4 xl:w-4" />
|
<Clock className="h-2.5 w-2.5" />
|
||||||
<span className="hidden sm:inline">Новые</span>
|
<span className="hidden sm:inline">Новые</span>
|
||||||
<span className="sm:hidden">Н</span>
|
<span className="sm:hidden">Н</span>
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
<TabsTrigger
|
<TabsTrigger
|
||||||
value="receiving"
|
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"
|
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-3 w-3 xl:h-4 xl:w-4" />
|
<FileText className="h-2.5 w-2.5" />
|
||||||
<span className="hidden sm:inline">Приёмка</span>
|
<span className="hidden sm:inline">Приёмка</span>
|
||||||
<span className="sm:hidden">П</span>
|
<span className="sm:hidden">П</span>
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
<TabsTrigger
|
<TabsTrigger
|
||||||
value="received"
|
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"
|
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-3 w-3 xl:h-4 xl:w-4" />
|
<CheckCircle className="h-2.5 w-2.5" />
|
||||||
<span className="hidden sm:inline">Принято</span>
|
<span className="hidden sm:inline">Принято</span>
|
||||||
<span className="sm:hidden">Пр</span>
|
<span className="sm:hidden">Пр</span>
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
</TabsList>
|
</TabsList>
|
||||||
|
|
||||||
<TabsContent value="new" className="flex-1 overflow-hidden">
|
<TabsContent value="new" className="space-y-0">
|
||||||
<TabContent tabName="new" />
|
<TabContent tabName="new" />
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
<TabsContent value="receiving" className="flex-1 overflow-hidden">
|
<TabsContent value="receiving" className="space-y-0">
|
||||||
<TabContent tabName="receiving" />
|
<TabContent tabName="receiving" />
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
<TabsContent value="received" className="flex-1 overflow-hidden">
|
<TabsContent value="received" className="space-y-0">
|
||||||
<TabContent tabName="received" />
|
<TabContent tabName="received" />
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
function TabContent({ tabName }: { tabName: string }) {
|
function TabContent({ tabName }: { tabName: string }) {
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useEffect } from "react";
|
import React, { useState, useEffect } from "react";
|
||||||
import { useSearchParams, useRouter } from "next/navigation";
|
import { useSearchParams, useRouter } from "next/navigation";
|
||||||
import { useQuery } from "@apollo/client";
|
import { useQuery } from "@apollo/client";
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
@ -32,13 +32,33 @@ export function FulfillmentSuppliesTab() {
|
|||||||
const [activeTab, setActiveTab] = useState("goods");
|
const [activeTab, setActiveTab] = useState("goods");
|
||||||
|
|
||||||
// Загружаем данные о непринятых поставках
|
// Загружаем данные о непринятых поставках
|
||||||
const { data: pendingData } = useQuery(GET_PENDING_SUPPLIES_COUNT, {
|
const { data: pendingData, error: pendingError } = useQuery(
|
||||||
|
GET_PENDING_SUPPLIES_COUNT,
|
||||||
|
{
|
||||||
pollInterval: 30000, // Обновляем каждые 30 секунд
|
pollInterval: 30000, // Обновляем каждые 30 секунд
|
||||||
fetchPolicy: "cache-first",
|
fetchPolicy: "cache-first",
|
||||||
errorPolicy: "ignore",
|
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 =
|
const ourSupplyOrdersCount =
|
||||||
pendingData?.pendingSuppliesCount?.ourSupplyOrders || 0;
|
pendingData?.pendingSuppliesCount?.ourSupplyOrders || 0;
|
||||||
const sellerSupplyOrdersCount =
|
const sellerSupplyOrdersCount =
|
||||||
@ -66,26 +86,28 @@ export function FulfillmentSuppliesTab() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-full flex flex-col">
|
<div className="space-y-3">
|
||||||
|
{/* УРОВЕНЬ 2: Подтабы (средний размер, отступ показывает иерархию) */}
|
||||||
|
<div className="ml-4">
|
||||||
<Tabs
|
<Tabs
|
||||||
value={activeTab}
|
value={activeTab}
|
||||||
onValueChange={handleTabChange}
|
onValueChange={handleTabChange}
|
||||||
className="h-full flex flex-col"
|
className="w-full"
|
||||||
>
|
>
|
||||||
<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">
|
<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
|
<TabsTrigger
|
||||||
value="goods"
|
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"
|
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-2.5 w-2.5 xl:h-3 xl:w-3" />
|
<Package className="h-3 w-3" />
|
||||||
<span className="hidden sm:inline">Товар</span>
|
<span className="hidden sm:inline">Товар</span>
|
||||||
<span className="sm:hidden">Т</span>
|
<span className="sm:hidden">Т</span>
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
<TabsTrigger
|
<TabsTrigger
|
||||||
value="detailed-supplies"
|
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"
|
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-2.5 w-2.5 xl:h-3 xl:w-3" />
|
<Building2 className="h-3 w-3" />
|
||||||
<span className="hidden md:inline">Расходники фулфилмента</span>
|
<span className="hidden md:inline">Расходники фулфилмента</span>
|
||||||
<span className="md:hidden hidden sm:inline">Фулфилмент</span>
|
<span className="md:hidden hidden sm:inline">Фулфилмент</span>
|
||||||
<span className="sm:hidden">Ф</span>
|
<span className="sm:hidden">Ф</span>
|
||||||
@ -93,9 +115,9 @@ export function FulfillmentSuppliesTab() {
|
|||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
<TabsTrigger
|
<TabsTrigger
|
||||||
value="consumables"
|
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"
|
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-2.5 w-2.5 xl:h-3 xl:w-3" />
|
<Wrench className="h-3 w-3" />
|
||||||
<span className="hidden md:inline">Расходники селлеров</span>
|
<span className="hidden md:inline">Расходники селлеров</span>
|
||||||
<span className="md:hidden hidden sm:inline">Селлеры</span>
|
<span className="md:hidden hidden sm:inline">Селлеры</span>
|
||||||
<span className="sm:hidden">С</span>
|
<span className="sm:hidden">С</span>
|
||||||
@ -103,37 +125,35 @@ export function FulfillmentSuppliesTab() {
|
|||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
<TabsTrigger
|
<TabsTrigger
|
||||||
value="returns"
|
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"
|
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-2.5 w-2.5 xl:h-3 xl:w-3" />
|
<RotateCcw className="h-3 w-3" />
|
||||||
<span className="hidden sm:inline">Возвраты с ПВЗ</span>
|
<span className="hidden sm:inline">Возвраты с ПВЗ</span>
|
||||||
<span className="sm:hidden">В</span>
|
<span className="sm:hidden">В</span>
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
</TabsList>
|
</TabsList>
|
||||||
|
|
||||||
<TabsContent value="goods" className="flex-1 overflow-hidden">
|
<TabsContent value="goods" className="space-y-0">
|
||||||
<FulfillmentGoodsTab />
|
<FulfillmentGoodsTab />
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
<TabsContent
|
<TabsContent value="detailed-supplies" className="space-y-0">
|
||||||
value="detailed-supplies"
|
<div className="p-4">
|
||||||
className="flex-1 overflow-hidden"
|
|
||||||
>
|
|
||||||
<div className="h-full p-2 xl:p-4 overflow-y-auto">
|
|
||||||
<FulfillmentDetailedSuppliesTab />
|
<FulfillmentDetailedSuppliesTab />
|
||||||
</div>
|
</div>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
<TabsContent value="consumables" className="flex-1 overflow-hidden">
|
<TabsContent value="consumables" className="space-y-0">
|
||||||
<div className="h-full p-2 xl:p-4 overflow-y-auto">
|
<div className="p-4">
|
||||||
<FulfillmentConsumablesOrdersTab />
|
<FulfillmentConsumablesOrdersTab />
|
||||||
</div>
|
</div>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
<TabsContent value="returns" className="flex-1 overflow-hidden">
|
<TabsContent value="returns" className="space-y-0">
|
||||||
<PvzReturnsTab />
|
<PvzReturnsTab />
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -7,6 +7,11 @@ import { Button } from "@/components/ui/button";
|
|||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Avatar, AvatarImage, AvatarFallback } from "@/components/ui/avatar";
|
import { Avatar, AvatarImage, AvatarFallback } from "@/components/ui/avatar";
|
||||||
|
import {
|
||||||
|
Popover,
|
||||||
|
PopoverContent,
|
||||||
|
PopoverTrigger,
|
||||||
|
} from "@/components/ui/popover";
|
||||||
import { Sidebar } from "@/components/dashboard/sidebar";
|
import { Sidebar } from "@/components/dashboard/sidebar";
|
||||||
import { useSidebar } from "@/hooks/useSidebar";
|
import { useSidebar } from "@/hooks/useSidebar";
|
||||||
import { useAuth } from "@/hooks/useAuth";
|
import { useAuth } from "@/hooks/useAuth";
|
||||||
@ -15,7 +20,8 @@ import {
|
|||||||
GET_MY_COUNTERPARTIES,
|
GET_MY_COUNTERPARTIES,
|
||||||
GET_SUPPLY_ORDERS,
|
GET_SUPPLY_ORDERS,
|
||||||
GET_WAREHOUSE_PRODUCTS,
|
GET_WAREHOUSE_PRODUCTS,
|
||||||
GET_MY_SUPPLIES, // Расходники селлеров
|
GET_MY_SUPPLIES, // Расходники селлеров (старые данные заказов)
|
||||||
|
GET_SELLER_SUPPLIES_ON_WAREHOUSE, // Расходники селлеров на складе (новый API)
|
||||||
GET_MY_FULFILLMENT_SUPPLIES, // Расходники фулфилмента
|
GET_MY_FULFILLMENT_SUPPLIES, // Расходники фулфилмента
|
||||||
GET_FULFILLMENT_WAREHOUSE_STATS, // Статистика склада с изменениями за сутки
|
GET_FULFILLMENT_WAREHOUSE_STATS, // Статистика склада с изменениями за сутки
|
||||||
} from "@/graphql/queries";
|
} from "@/graphql/queries";
|
||||||
@ -55,6 +61,7 @@ interface ProductVariant {
|
|||||||
defectsQuantity: number;
|
defectsQuantity: number;
|
||||||
sellerSuppliesPlace?: string;
|
sellerSuppliesPlace?: string;
|
||||||
sellerSuppliesQuantity: number;
|
sellerSuppliesQuantity: number;
|
||||||
|
sellerSuppliesOwners?: string[]; // Владельцы расходников
|
||||||
pvzReturnsPlace?: string;
|
pvzReturnsPlace?: string;
|
||||||
pvzReturnsQuantity: number;
|
pvzReturnsQuantity: number;
|
||||||
}
|
}
|
||||||
@ -72,6 +79,7 @@ interface ProductItem {
|
|||||||
defectsQuantity: number;
|
defectsQuantity: number;
|
||||||
sellerSuppliesPlace?: string;
|
sellerSuppliesPlace?: string;
|
||||||
sellerSuppliesQuantity: number;
|
sellerSuppliesQuantity: number;
|
||||||
|
sellerSuppliesOwners?: string[]; // Владельцы расходников
|
||||||
pvzReturnsPlace?: string;
|
pvzReturnsPlace?: string;
|
||||||
pvzReturnsQuantity: number;
|
pvzReturnsQuantity: number;
|
||||||
// Третий уровень - варианты товара
|
// Третий уровень - варианты товара
|
||||||
@ -200,13 +208,13 @@ export function FulfillmentWarehouseDashboard() {
|
|||||||
fetchPolicy: "cache-and-network",
|
fetchPolicy: "cache-and-network",
|
||||||
});
|
});
|
||||||
|
|
||||||
// Загружаем расходники селлеров
|
// Загружаем расходники селлеров на складе фулфилмента
|
||||||
const {
|
const {
|
||||||
data: suppliesData,
|
data: sellerSuppliesData,
|
||||||
loading: suppliesLoading,
|
loading: sellerSuppliesLoading,
|
||||||
error: suppliesError,
|
error: sellerSuppliesError,
|
||||||
refetch: refetchSupplies,
|
refetch: refetchSellerSupplies,
|
||||||
} = useQuery(GET_MY_SUPPLIES, {
|
} = useQuery(GET_SELLER_SUPPLIES_ON_WAREHOUSE, {
|
||||||
fetchPolicy: "cache-and-network",
|
fetchPolicy: "cache-and-network",
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -246,8 +254,10 @@ export function FulfillmentWarehouseDashboard() {
|
|||||||
goods: warehouseStatsData.fulfillmentWarehouseStats.goods,
|
goods: warehouseStatsData.fulfillmentWarehouseStats.goods,
|
||||||
defects: warehouseStatsData.fulfillmentWarehouseStats.defects,
|
defects: warehouseStatsData.fulfillmentWarehouseStats.defects,
|
||||||
pvzReturns: warehouseStatsData.fulfillmentWarehouseStats.pvzReturns,
|
pvzReturns: warehouseStatsData.fulfillmentWarehouseStats.pvzReturns,
|
||||||
fulfillmentSupplies: warehouseStatsData.fulfillmentWarehouseStats.fulfillmentSupplies,
|
fulfillmentSupplies:
|
||||||
sellerSupplies: warehouseStatsData.fulfillmentWarehouseStats.sellerSupplies,
|
warehouseStatsData.fulfillmentWarehouseStats.fulfillmentSupplies,
|
||||||
|
sellerSupplies:
|
||||||
|
warehouseStatsData.fulfillmentWarehouseStats.sellerSupplies,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -258,7 +268,7 @@ export function FulfillmentWarehouseDashboard() {
|
|||||||
);
|
);
|
||||||
const supplyOrders: SupplyOrder[] = ordersData?.supplyOrders || [];
|
const supplyOrders: SupplyOrder[] = ordersData?.supplyOrders || [];
|
||||||
const allProducts = productsData?.warehouseProducts || [];
|
const allProducts = productsData?.warehouseProducts || [];
|
||||||
const mySupplies = suppliesData?.mySupplies || []; // Расходники селлеров
|
const sellerSupplies = sellerSuppliesData?.sellerSuppliesOnWarehouse || []; // Расходники селлеров на складе
|
||||||
const myFulfillmentSupplies =
|
const myFulfillmentSupplies =
|
||||||
fulfillmentSuppliesData?.myFulfillmentSupplies || []; // Расходники фулфилмента
|
fulfillmentSuppliesData?.myFulfillmentSupplies || []; // Расходники фулфилмента
|
||||||
|
|
||||||
@ -276,8 +286,8 @@ export function FulfillmentWarehouseDashboard() {
|
|||||||
deliveredOrders: supplyOrders.filter((o) => o.status === "DELIVERED")
|
deliveredOrders: supplyOrders.filter((o) => o.status === "DELIVERED")
|
||||||
.length,
|
.length,
|
||||||
productsCount: allProducts.length,
|
productsCount: allProducts.length,
|
||||||
suppliesCount: mySupplies.length, // Добавляем логирование расходников
|
suppliesCount: sellerSupplies.length, // Добавляем логирование расходников
|
||||||
supplies: mySupplies.map((s: any) => ({
|
supplies: sellerSupplies.map((s: any) => ({
|
||||||
id: s.id,
|
id: s.id,
|
||||||
name: s.name,
|
name: s.name,
|
||||||
currentStock: s.currentStock,
|
currentStock: s.currentStock,
|
||||||
@ -293,7 +303,7 @@ export function FulfillmentWarehouseDashboard() {
|
|||||||
})),
|
})),
|
||||||
// Добавляем анализ соответствия товаров и расходников
|
// Добавляем анализ соответствия товаров и расходников
|
||||||
productSupplyMatching: allProducts.map((product: any) => {
|
productSupplyMatching: allProducts.map((product: any) => {
|
||||||
const matchingSupply = mySupplies.find((supply: any) => {
|
const matchingSupply = sellerSupplies.find((supply: any) => {
|
||||||
return (
|
return (
|
||||||
supply.name.toLowerCase() === product.name.toLowerCase() ||
|
supply.name.toLowerCase() === product.name.toLowerCase() ||
|
||||||
supply.name
|
supply.name
|
||||||
@ -311,11 +321,11 @@ export function FulfillmentWarehouseDashboard() {
|
|||||||
counterpartiesLoading,
|
counterpartiesLoading,
|
||||||
ordersLoading,
|
ordersLoading,
|
||||||
productsLoading,
|
productsLoading,
|
||||||
suppliesLoading, // Добавляем статус загрузки расходников
|
sellerSuppliesLoading, // Добавляем статус загрузки расходников селлеров
|
||||||
counterpartiesError: counterpartiesError?.message,
|
counterpartiesError: counterpartiesError?.message,
|
||||||
ordersError: ordersError?.message,
|
ordersError: ordersError?.message,
|
||||||
productsError: productsError?.message,
|
productsError: productsError?.message,
|
||||||
suppliesError: suppliesError?.message, // Добавляем ошибки загрузки расходников
|
sellerSuppliesError: sellerSuppliesError?.message, // Добавляем ошибки загрузки расходников селлеров
|
||||||
});
|
});
|
||||||
|
|
||||||
// Расчет поступлений расходников за сутки (выносим отдельно для использования в storeData)
|
// Расчет поступлений расходников за сутки (выносим отдельно для использования в storeData)
|
||||||
@ -408,7 +418,7 @@ export function FulfillmentWarehouseDashboard() {
|
|||||||
console.log("📊 Статистика расходников селлера:", {
|
console.log("📊 Статистика расходников селлера:", {
|
||||||
suppliesReceivedToday,
|
suppliesReceivedToday,
|
||||||
suppliesUsedToday,
|
suppliesUsedToday,
|
||||||
totalSellerSupplies: mySupplies.reduce(
|
totalSellerSupplies: sellerSupplies.reduce(
|
||||||
(sum: number, supply: any) => sum + (supply.currentStock || 0),
|
(sum: number, supply: any) => sum + (supply.currentStock || 0),
|
||||||
0
|
0
|
||||||
),
|
),
|
||||||
@ -418,7 +428,10 @@ export function FulfillmentWarehouseDashboard() {
|
|||||||
// Получаем статистику склада из GraphQL (с реальными изменениями за сутки)
|
// Получаем статистику склада из GraphQL (с реальными изменениями за сутки)
|
||||||
const warehouseStats: WarehouseStats = useMemo(() => {
|
const warehouseStats: WarehouseStats = useMemo(() => {
|
||||||
// Если данные еще загружаются, возвращаем нули
|
// Если данные еще загружаются, возвращаем нули
|
||||||
if (warehouseStatsLoading || !warehouseStatsData?.fulfillmentWarehouseStats) {
|
if (
|
||||||
|
warehouseStatsLoading ||
|
||||||
|
!warehouseStatsData?.fulfillmentWarehouseStats
|
||||||
|
) {
|
||||||
return {
|
return {
|
||||||
products: { current: 0, change: 0 },
|
products: { current: 0, change: 0 },
|
||||||
goods: { current: 0, change: 0 },
|
goods: { current: 0, change: 0 },
|
||||||
@ -511,26 +524,64 @@ export function FulfillmentWarehouseDashboard() {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Группируем расходники по названию
|
// ИСПРАВЛЕНО: Группируем расходники по СЕЛЛЕРУ-ВЛАДЕЛЬЦУ, а не по названию
|
||||||
const groupedSupplies = new Map<string, number>();
|
const suppliesByOwner = new Map<
|
||||||
mySupplies.forEach((supply: any) => {
|
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 supplyName = supply.name;
|
||||||
const currentStock = supply.currentStock || 0;
|
const currentStock = supply.currentStock || 0;
|
||||||
|
const supplyType = supply.type;
|
||||||
|
|
||||||
if (groupedSupplies.has(supplyName)) {
|
// ИСПРАВЛЕНО: Строгая проверка согласно правилам
|
||||||
groupedSupplies.set(
|
if (!ownerId || supplyType !== "SELLER_CONSUMABLES") {
|
||||||
supplyName,
|
console.warn(
|
||||||
groupedSupplies.get(supplyName)! + currentStock
|
"⚠️ ОТФИЛЬТРОВАН расходник в компоненте (нарушение правил):",
|
||||||
|
{
|
||||||
|
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 {
|
} else {
|
||||||
groupedSupplies.set(supplyName, currentStock);
|
// Добавляем новый расходник для этого селлера
|
||||||
|
ownerSupplies.set(supplyName, {
|
||||||
|
quantity: currentStock,
|
||||||
|
ownerName: ownerName,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Логирование группировки
|
// Логирование группировки
|
||||||
console.log("📊 Группировка товаров и расходников:", {
|
console.log("📊 Группировка товаров и расходников:", {
|
||||||
groupedProductsCount: groupedProducts.size,
|
groupedProductsCount: groupedProducts.size,
|
||||||
groupedSuppliesCount: groupedSupplies.size,
|
suppliesByOwnerCount: suppliesByOwner.size,
|
||||||
groupedProducts: Array.from(groupedProducts.entries()).map(
|
groupedProducts: Array.from(groupedProducts.entries()).map(
|
||||||
([name, data]) => ({
|
([name, data]) => ({
|
||||||
name,
|
name,
|
||||||
@ -539,10 +590,20 @@ export function FulfillmentWarehouseDashboard() {
|
|||||||
uniqueSuppliers: [...new Set(data.suppliers)],
|
uniqueSuppliers: [...new Set(data.suppliers)],
|
||||||
})
|
})
|
||||||
),
|
),
|
||||||
groupedSupplies: Array.from(groupedSupplies.entries()).map(
|
suppliesByOwner: Array.from(suppliesByOwner.entries()).map(
|
||||||
([name, quantity]) => ({
|
([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,
|
name,
|
||||||
totalQuantity: quantity,
|
quantity: data.quantity,
|
||||||
|
})),
|
||||||
})
|
})
|
||||||
),
|
),
|
||||||
});
|
});
|
||||||
@ -567,37 +628,56 @@ export function FulfillmentWarehouseDashboard() {
|
|||||||
const productData = groupedProducts.get(productName)!;
|
const productData = groupedProducts.get(productName)!;
|
||||||
const itemProducts = productData.totalQuantity;
|
const itemProducts = productData.totalQuantity;
|
||||||
|
|
||||||
// Ищем соответствующий расходник по названию
|
// ИСПРАВЛЕНО: Ищем расходники конкретного селлера-владельца
|
||||||
const matchingSupplyQuantity = groupedSupplies.get(productName) || 0;
|
let itemSuppliesQuantity = 0;
|
||||||
|
let suppliesOwners: string[] = [];
|
||||||
|
|
||||||
// Если нет точного совпадения, ищем частичное совпадение
|
// Получаем реального селлера для этого виртуального партнера
|
||||||
let itemSuppliesQuantity = matchingSupplyQuantity;
|
const realSeller = sellerPartners[index];
|
||||||
if (itemSuppliesQuantity === 0) {
|
|
||||||
for (const [supplyName, quantity] of groupedSupplies.entries()) {
|
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 (
|
if (
|
||||||
supplyName.toLowerCase().includes(productName.toLowerCase()) ||
|
supplyName
|
||||||
|
.toLowerCase()
|
||||||
|
.includes(productName.toLowerCase()) ||
|
||||||
productName.toLowerCase().includes(supplyName.toLowerCase())
|
productName.toLowerCase().includes(supplyName.toLowerCase())
|
||||||
) {
|
) {
|
||||||
itemSuppliesQuantity = quantity;
|
itemSuppliesQuantity = supplyData.quantity;
|
||||||
|
suppliesOwners = [supplyData.ownerName];
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fallback к процентному соотношению
|
|
||||||
if (itemSuppliesQuantity === 0) {
|
|
||||||
itemSuppliesQuantity = Math.floor(itemProducts * 0.1);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(`📦 Товар "${productName}":`, {
|
// Если у этого селлера нет расходников для данного товара - оставляем 0
|
||||||
|
// НЕ используем fallback, так как должны показывать только реальные данные
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
`📦 Товар "${productName}" (партнер: ${
|
||||||
|
realSeller?.name || "Unknown"
|
||||||
|
}):`,
|
||||||
|
{
|
||||||
totalQuantity: itemProducts,
|
totalQuantity: itemProducts,
|
||||||
suppliersCount: productData.suppliers.length,
|
suppliersCount: productData.suppliers.length,
|
||||||
uniqueSuppliers: [...new Set(productData.suppliers)],
|
uniqueSuppliers: [...new Set(productData.suppliers)],
|
||||||
matchingSupplyQuantity: matchingSupplyQuantity,
|
sellerSuppliesQuantity: itemSuppliesQuantity,
|
||||||
finalSuppliesQuantity: itemSuppliesQuantity,
|
suppliesOwners: suppliesOwners,
|
||||||
usedFallback:
|
sellerId: realSeller?.id,
|
||||||
matchingSupplyQuantity === 0 && itemSuppliesQuantity > 0,
|
hasSellerSupplies: itemSuppliesQuantity > 0,
|
||||||
});
|
}
|
||||||
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: `grouped-${productName}-${itemIndex}`, // Уникальный ID для группированного товара
|
id: `grouped-${productName}-${itemIndex}`, // Уникальный ID для группированного товара
|
||||||
@ -615,6 +695,7 @@ export function FulfillmentWarehouseDashboard() {
|
|||||||
defectsQuantity: 0, // Нет реальных данных о браке
|
defectsQuantity: 0, // Нет реальных данных о браке
|
||||||
sellerSuppliesPlace: `D${index + 1}-${itemIndex + 1}`,
|
sellerSuppliesPlace: `D${index + 1}-${itemIndex + 1}`,
|
||||||
sellerSuppliesQuantity: itemSuppliesQuantity, // Суммированное количество расходников (реальные данные)
|
sellerSuppliesQuantity: itemSuppliesQuantity, // Суммированное количество расходников (реальные данные)
|
||||||
|
sellerSuppliesOwners: suppliesOwners, // Владельцы расходников (ИСПРАВЛЕНО)
|
||||||
pvzReturnsPlace: `E${index + 1}-${itemIndex + 1}`,
|
pvzReturnsPlace: `E${index + 1}-${itemIndex + 1}`,
|
||||||
pvzReturnsQuantity: 0, // Нет реальных данных о возвратах с ПВЗ
|
pvzReturnsQuantity: 0, // Нет реальных данных о возвратах с ПВЗ
|
||||||
// Создаем варианты товара
|
// Создаем варианты товара
|
||||||
@ -634,6 +715,7 @@ export function FulfillmentWarehouseDashboard() {
|
|||||||
sellerSuppliesQuantity: Math.floor(
|
sellerSuppliesQuantity: Math.floor(
|
||||||
itemSuppliesQuantity * 0.4
|
itemSuppliesQuantity * 0.4
|
||||||
), // Часть от расходников
|
), // Часть от расходников
|
||||||
|
sellerSuppliesOwners: suppliesOwners, // Владельцы расходников (ИСПРАВЛЕНО)
|
||||||
pvzReturnsPlace: `E${index + 1}-${itemIndex + 1}-1`,
|
pvzReturnsPlace: `E${index + 1}-${itemIndex + 1}-1`,
|
||||||
pvzReturnsQuantity: 0, // Нет реальных данных о возвратах
|
pvzReturnsQuantity: 0, // Нет реальных данных о возвратах
|
||||||
},
|
},
|
||||||
@ -650,6 +732,7 @@ export function FulfillmentWarehouseDashboard() {
|
|||||||
sellerSuppliesQuantity: Math.floor(
|
sellerSuppliesQuantity: Math.floor(
|
||||||
itemSuppliesQuantity * 0.4
|
itemSuppliesQuantity * 0.4
|
||||||
), // Часть от расходников
|
), // Часть от расходников
|
||||||
|
sellerSuppliesOwners: suppliesOwners, // Владельцы расходников (ИСПРАВЛЕНО)
|
||||||
pvzReturnsPlace: `E${index + 1}-${itemIndex + 1}-2`,
|
pvzReturnsPlace: `E${index + 1}-${itemIndex + 1}-2`,
|
||||||
pvzReturnsQuantity: 0, // Нет реальных данных о возвратах
|
pvzReturnsQuantity: 0, // Нет реальных данных о возвратах
|
||||||
},
|
},
|
||||||
@ -666,6 +749,7 @@ export function FulfillmentWarehouseDashboard() {
|
|||||||
sellerSuppliesQuantity: Math.floor(
|
sellerSuppliesQuantity: Math.floor(
|
||||||
itemSuppliesQuantity * 0.2
|
itemSuppliesQuantity * 0.2
|
||||||
), // Оставшаяся часть расходников
|
), // Оставшаяся часть расходников
|
||||||
|
sellerSuppliesOwners: suppliesOwners, // Владельцы расходников (ИСПРАВЛЕНО)
|
||||||
pvzReturnsPlace: `E${index + 1}-${itemIndex + 1}-3`,
|
pvzReturnsPlace: `E${index + 1}-${itemIndex + 1}-3`,
|
||||||
pvzReturnsQuantity: 0, // Нет реальных данных о возвратах
|
pvzReturnsQuantity: 0, // Нет реальных данных о возвратах
|
||||||
},
|
},
|
||||||
@ -738,7 +822,7 @@ export function FulfillmentWarehouseDashboard() {
|
|||||||
totalSellerSupplies > 0
|
totalSellerSupplies > 0
|
||||||
? Math.floor(
|
? Math.floor(
|
||||||
(totalSellerSupplies /
|
(totalSellerSupplies /
|
||||||
(mySupplies.reduce(
|
(sellerSupplies.reduce(
|
||||||
(sum: number, supply: any) =>
|
(sum: number, supply: any) =>
|
||||||
sum + (supply.currentStock || 0),
|
sum + (supply.currentStock || 0),
|
||||||
0
|
0
|
||||||
@ -774,7 +858,7 @@ export function FulfillmentWarehouseDashboard() {
|
|||||||
items,
|
items,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
}, [sellerPartners, allProducts, mySupplies, suppliesReceivedToday]);
|
}, [sellerPartners, allProducts, sellerSupplies, suppliesReceivedToday]);
|
||||||
|
|
||||||
// Функции для аватаров магазинов
|
// Функции для аватаров магазинов
|
||||||
const getInitials = (name: string): string => {
|
const getInitials = (name: string): string => {
|
||||||
@ -1007,9 +1091,14 @@ export function FulfillmentWarehouseDashboard() {
|
|||||||
onClick?: () => void;
|
onClick?: () => void;
|
||||||
}) => {
|
}) => {
|
||||||
// Используем percentChange из GraphQL, если доступно, иначе вычисляем локально
|
// Используем percentChange из GraphQL, если доступно, иначе вычисляем локально
|
||||||
const displayPercentChange = percentChange !== undefined && percentChange !== null && !isNaN(percentChange)
|
const displayPercentChange =
|
||||||
|
percentChange !== undefined &&
|
||||||
|
percentChange !== null &&
|
||||||
|
!isNaN(percentChange)
|
||||||
? percentChange
|
? percentChange
|
||||||
: (current > 0 ? (change / current) * 100 : 0);
|
: current > 0
|
||||||
|
? (change / current) * 100
|
||||||
|
: 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@ -1125,7 +1214,7 @@ export function FulfillmentWarehouseDashboard() {
|
|||||||
counterpartiesLoading ||
|
counterpartiesLoading ||
|
||||||
ordersLoading ||
|
ordersLoading ||
|
||||||
productsLoading ||
|
productsLoading ||
|
||||||
suppliesLoading
|
sellerSuppliesLoading
|
||||||
) {
|
) {
|
||||||
return (
|
return (
|
||||||
<div className="h-screen flex overflow-hidden">
|
<div className="h-screen flex overflow-hidden">
|
||||||
@ -1210,7 +1299,7 @@ export function FulfillmentWarehouseDashboard() {
|
|||||||
counterpartiesLoading ||
|
counterpartiesLoading ||
|
||||||
ordersLoading ||
|
ordersLoading ||
|
||||||
productsLoading ||
|
productsLoading ||
|
||||||
suppliesLoading
|
sellerSuppliesLoading
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<RotateCcw className="h-3 w-3 mr-1" />
|
<RotateCcw className="h-3 w-3 mr-1" />
|
||||||
@ -1224,7 +1313,10 @@ export function FulfillmentWarehouseDashboard() {
|
|||||||
icon={Box}
|
icon={Box}
|
||||||
current={warehouseStats.products.current}
|
current={warehouseStats.products.current}
|
||||||
change={warehouseStats.products.change}
|
change={warehouseStats.products.change}
|
||||||
percentChange={warehouseStatsData?.fulfillmentWarehouseStats?.products?.percentChange}
|
percentChange={
|
||||||
|
warehouseStatsData?.fulfillmentWarehouseStats?.products
|
||||||
|
?.percentChange
|
||||||
|
}
|
||||||
description="Готовые к отправке"
|
description="Готовые к отправке"
|
||||||
/>
|
/>
|
||||||
<StatCard
|
<StatCard
|
||||||
@ -1232,7 +1324,10 @@ export function FulfillmentWarehouseDashboard() {
|
|||||||
icon={Package}
|
icon={Package}
|
||||||
current={warehouseStats.goods.current}
|
current={warehouseStats.goods.current}
|
||||||
change={warehouseStats.goods.change}
|
change={warehouseStats.goods.change}
|
||||||
percentChange={warehouseStatsData?.fulfillmentWarehouseStats?.goods?.percentChange}
|
percentChange={
|
||||||
|
warehouseStatsData?.fulfillmentWarehouseStats?.goods
|
||||||
|
?.percentChange
|
||||||
|
}
|
||||||
description="В обработке"
|
description="В обработке"
|
||||||
/>
|
/>
|
||||||
<StatCard
|
<StatCard
|
||||||
@ -1240,7 +1335,10 @@ export function FulfillmentWarehouseDashboard() {
|
|||||||
icon={AlertTriangle}
|
icon={AlertTriangle}
|
||||||
current={warehouseStats.defects.current}
|
current={warehouseStats.defects.current}
|
||||||
change={warehouseStats.defects.change}
|
change={warehouseStats.defects.change}
|
||||||
percentChange={warehouseStatsData?.fulfillmentWarehouseStats?.defects?.percentChange}
|
percentChange={
|
||||||
|
warehouseStatsData?.fulfillmentWarehouseStats?.defects
|
||||||
|
?.percentChange
|
||||||
|
}
|
||||||
description="Требует утилизации"
|
description="Требует утилизации"
|
||||||
/>
|
/>
|
||||||
<StatCard
|
<StatCard
|
||||||
@ -1248,7 +1346,10 @@ export function FulfillmentWarehouseDashboard() {
|
|||||||
icon={RotateCcw}
|
icon={RotateCcw}
|
||||||
current={warehouseStats.pvzReturns.current}
|
current={warehouseStats.pvzReturns.current}
|
||||||
change={warehouseStats.pvzReturns.change}
|
change={warehouseStats.pvzReturns.change}
|
||||||
percentChange={warehouseStatsData?.fulfillmentWarehouseStats?.pvzReturns?.percentChange}
|
percentChange={
|
||||||
|
warehouseStatsData?.fulfillmentWarehouseStats?.pvzReturns
|
||||||
|
?.percentChange
|
||||||
|
}
|
||||||
description="К обработке"
|
description="К обработке"
|
||||||
/>
|
/>
|
||||||
<StatCard
|
<StatCard
|
||||||
@ -1256,7 +1357,10 @@ export function FulfillmentWarehouseDashboard() {
|
|||||||
icon={Wrench}
|
icon={Wrench}
|
||||||
current={warehouseStats.fulfillmentSupplies.current}
|
current={warehouseStats.fulfillmentSupplies.current}
|
||||||
change={warehouseStats.fulfillmentSupplies.change}
|
change={warehouseStats.fulfillmentSupplies.change}
|
||||||
percentChange={warehouseStatsData?.fulfillmentWarehouseStats?.fulfillmentSupplies?.percentChange}
|
percentChange={
|
||||||
|
warehouseStatsData?.fulfillmentWarehouseStats
|
||||||
|
?.fulfillmentSupplies?.percentChange
|
||||||
|
}
|
||||||
description="Расходники, этикетки"
|
description="Расходники, этикетки"
|
||||||
onClick={() => router.push("/fulfillment-warehouse/supplies")}
|
onClick={() => router.push("/fulfillment-warehouse/supplies")}
|
||||||
/>
|
/>
|
||||||
@ -1265,7 +1369,10 @@ export function FulfillmentWarehouseDashboard() {
|
|||||||
icon={Users}
|
icon={Users}
|
||||||
current={warehouseStats.sellerSupplies.current}
|
current={warehouseStats.sellerSupplies.current}
|
||||||
change={warehouseStats.sellerSupplies.change}
|
change={warehouseStats.sellerSupplies.change}
|
||||||
percentChange={warehouseStatsData?.fulfillmentWarehouseStats?.sellerSupplies?.percentChange}
|
percentChange={
|
||||||
|
warehouseStatsData?.fulfillmentWarehouseStats?.sellerSupplies
|
||||||
|
?.percentChange
|
||||||
|
}
|
||||||
description="Материалы клиентов"
|
description="Материалы клиентов"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@ -1935,11 +2042,43 @@ export function FulfillmentWarehouseDashboard() {
|
|||||||
|
|
||||||
{/* Расходники селлера */}
|
{/* Расходники селлера */}
|
||||||
<div className="grid grid-cols-2 gap-0">
|
<div className="grid grid-cols-2 gap-0">
|
||||||
<div className="px-1 py-2 text-center text-xs text-white font-medium">
|
<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(
|
{formatNumber(
|
||||||
item.sellerSuppliesQuantity
|
item.sellerSuppliesQuantity
|
||||||
)}
|
)}
|
||||||
</div>
|
</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">
|
<div className="px-1 py-2 text-center text-xs text-white/60">
|
||||||
{item.sellerSuppliesPlace || "-"}
|
{item.sellerSuppliesPlace || "-"}
|
||||||
</div>
|
</div>
|
||||||
@ -2065,11 +2204,45 @@ export function FulfillmentWarehouseDashboard() {
|
|||||||
|
|
||||||
{/* Расходники селлера */}
|
{/* Расходники селлера */}
|
||||||
<div className="grid grid-cols-2 gap-0">
|
<div className="grid grid-cols-2 gap-0">
|
||||||
<div className="px-1 py-1.5 text-center text-[10px] text-white font-medium">
|
<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(
|
{formatNumber(
|
||||||
variant.sellerSuppliesQuantity
|
variant.sellerSuppliesQuantity
|
||||||
)}
|
)}
|
||||||
</div>
|
</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">
|
<div className="px-1 py-1.5 text-center text-[10px] text-white/60">
|
||||||
{variant.sellerSuppliesPlace ||
|
{variant.sellerSuppliesPlace ||
|
||||||
"-"}
|
"-"}
|
||||||
|
@ -13,7 +13,7 @@ import { useAuth } from "@/hooks/useAuth";
|
|||||||
import { GET_SUPPLY_ORDERS } from "@/graphql/queries";
|
import { GET_SUPPLY_ORDERS } from "@/graphql/queries";
|
||||||
import {
|
import {
|
||||||
LOGISTICS_CONFIRM_ORDER,
|
LOGISTICS_CONFIRM_ORDER,
|
||||||
LOGISTICS_REJECT_ORDER
|
LOGISTICS_REJECT_ORDER,
|
||||||
} from "@/graphql/mutations";
|
} from "@/graphql/mutations";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import {
|
import {
|
||||||
@ -37,7 +37,15 @@ interface SupplyOrder {
|
|||||||
organizationId: string;
|
organizationId: string;
|
||||||
partnerId: string;
|
partnerId: string;
|
||||||
deliveryDate: 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;
|
totalAmount: number;
|
||||||
totalItems: number;
|
totalItems: number;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
@ -89,7 +97,11 @@ export function LogisticsOrdersDashboard() {
|
|||||||
fetchPolicy: "cache-and-network",
|
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, {
|
const [logisticsConfirmOrder] = useMutation(LOGISTICS_CONFIRM_ORDER, {
|
||||||
@ -137,8 +149,15 @@ export function LogisticsOrdersDashboard() {
|
|||||||
// Фильтруем заказы где текущая организация является логистическим партнером
|
// Фильтруем заказы где текущая организация является логистическим партнером
|
||||||
const logisticsOrders: SupplyOrder[] = (data?.supplyOrders || []).filter(
|
const logisticsOrders: SupplyOrder[] = (data?.supplyOrders || []).filter(
|
||||||
(order: SupplyOrder) => {
|
(order: SupplyOrder) => {
|
||||||
const isLogisticsPartner = order.logisticsPartner?.id === user?.organization?.id;
|
const isLogisticsPartner =
|
||||||
console.log(`DEBUG ЛОГИСТИКА: Заказ ${order.id.slice(-8)} - статус: ${order.status}, logisticsPartnerId: ${order.logisticsPartner?.id}, currentOrgId: ${user?.organization?.id}, isLogisticsPartner: ${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;
|
return isLogisticsPartner;
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
@ -155,6 +174,11 @@ export function LogisticsOrdersDashboard() {
|
|||||||
color: "bg-yellow-500/20 text-yellow-300 border-yellow-500/30",
|
color: "bg-yellow-500/20 text-yellow-300 border-yellow-500/30",
|
||||||
icon: AlertTriangle,
|
icon: AlertTriangle,
|
||||||
},
|
},
|
||||||
|
CONFIRMED: {
|
||||||
|
label: "Требует подтверждения",
|
||||||
|
color: "bg-yellow-500/20 text-yellow-300 border-yellow-500/30",
|
||||||
|
icon: AlertTriangle,
|
||||||
|
},
|
||||||
LOGISTICS_CONFIRMED: {
|
LOGISTICS_CONFIRMED: {
|
||||||
label: "Подтверждено",
|
label: "Подтверждено",
|
||||||
color: "bg-blue-500/20 text-blue-300 border-blue-500/30",
|
color: "bg-blue-500/20 text-blue-300 border-blue-500/30",
|
||||||
@ -247,7 +271,9 @@ export function LogisticsOrdersDashboard() {
|
|||||||
return (
|
return (
|
||||||
<div className="h-screen flex overflow-hidden">
|
<div className="h-screen flex overflow-hidden">
|
||||||
<Sidebar />
|
<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="flex-1 overflow-y-auto flex items-center justify-center">
|
||||||
<div className="text-white">Загрузка заказов...</div>
|
<div className="text-white">Загрузка заказов...</div>
|
||||||
</div>
|
</div>
|
||||||
@ -260,9 +286,13 @@ export function LogisticsOrdersDashboard() {
|
|||||||
return (
|
return (
|
||||||
<div className="h-screen flex overflow-hidden">
|
<div className="h-screen flex overflow-hidden">
|
||||||
<Sidebar />
|
<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="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>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
@ -272,7 +302,9 @@ export function LogisticsOrdersDashboard() {
|
|||||||
return (
|
return (
|
||||||
<div className="h-screen flex overflow-hidden">
|
<div className="h-screen flex overflow-hidden">
|
||||||
<Sidebar />
|
<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-1 overflow-y-auto space-y-6">
|
||||||
{/* Заголовок */}
|
{/* Заголовок */}
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
@ -296,7 +328,13 @@ export function LogisticsOrdersDashboard() {
|
|||||||
<div>
|
<div>
|
||||||
<p className="text-white/60 text-sm">Требуют подтверждения</p>
|
<p className="text-white/60 text-sm">Требуют подтверждения</p>
|
||||||
<p className="text-xl font-bold text-white">
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -310,7 +348,11 @@ export function LogisticsOrdersDashboard() {
|
|||||||
<div>
|
<div>
|
||||||
<p className="text-white/60 text-sm">Подтверждено</p>
|
<p className="text-white/60 text-sm">Подтверждено</p>
|
||||||
<p className="text-xl font-bold text-white">
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -324,7 +366,11 @@ export function LogisticsOrdersDashboard() {
|
|||||||
<div>
|
<div>
|
||||||
<p className="text-white/60 text-sm">В пути</p>
|
<p className="text-white/60 text-sm">В пути</p>
|
||||||
<p className="text-xl font-bold text-white">
|
<p className="text-xl font-bold text-white">
|
||||||
{logisticsOrders.filter(order => order.status === "SHIPPED").length}
|
{
|
||||||
|
logisticsOrders.filter(
|
||||||
|
(order) => order.status === "SHIPPED"
|
||||||
|
).length
|
||||||
|
}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -338,7 +384,11 @@ export function LogisticsOrdersDashboard() {
|
|||||||
<div>
|
<div>
|
||||||
<p className="text-white/60 text-sm">Доставлено</p>
|
<p className="text-white/60 text-sm">Доставлено</p>
|
||||||
<p className="text-xl font-bold text-white">
|
<p className="text-xl font-bold text-white">
|
||||||
{logisticsOrders.filter(order => order.status === "DELIVERED").length}
|
{
|
||||||
|
logisticsOrders.filter(
|
||||||
|
(order) => order.status === "DELIVERED"
|
||||||
|
).length
|
||||||
|
}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -355,7 +405,8 @@ export function LogisticsOrdersDashboard() {
|
|||||||
Нет логистических заказов
|
Нет логистических заказов
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-white/60">
|
<p className="text-white/60">
|
||||||
Заказы поставок, требующие логистического сопровождения, будут отображаться здесь
|
Заказы поставок, требующие логистического сопровождения,
|
||||||
|
будут отображаться здесь
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
@ -384,19 +435,29 @@ export function LogisticsOrdersDashboard() {
|
|||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center space-x-2">
|
||||||
<Avatar className="w-8 h-8">
|
<Avatar className="w-8 h-8">
|
||||||
<AvatarFallback className="bg-blue-500 text-white text-sm">
|
<AvatarFallback className="bg-blue-500 text-white text-sm">
|
||||||
{getInitials(order.partner.name || order.partner.fullName || "П")}
|
{getInitials(
|
||||||
|
order.partner.name ||
|
||||||
|
order.partner.fullName ||
|
||||||
|
"П"
|
||||||
|
)}
|
||||||
</AvatarFallback>
|
</AvatarFallback>
|
||||||
</Avatar>
|
</Avatar>
|
||||||
<span className="text-white/60 text-sm">→</span>
|
<span className="text-white/60 text-sm">→</span>
|
||||||
<Avatar className="w-8 h-8">
|
<Avatar className="w-8 h-8">
|
||||||
<AvatarFallback className="bg-green-500 text-white text-sm">
|
<AvatarFallback className="bg-green-500 text-white text-sm">
|
||||||
{getInitials(order.organization.name || order.organization.fullName || "ФФ")}
|
{getInitials(
|
||||||
|
order.organization.name ||
|
||||||
|
order.organization.fullName ||
|
||||||
|
"ФФ"
|
||||||
|
)}
|
||||||
</AvatarFallback>
|
</AvatarFallback>
|
||||||
</Avatar>
|
</Avatar>
|
||||||
</div>
|
</div>
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<h3 className="text-white font-medium text-sm truncate">
|
<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>
|
</h3>
|
||||||
<p className="text-white/60 text-xs">
|
<p className="text-white/60 text-xs">
|
||||||
Поставщик → Фулфилмент
|
Поставщик → Фулфилмент
|
||||||
@ -426,7 +487,8 @@ export function LogisticsOrdersDashboard() {
|
|||||||
{getStatusBadge(order.status)}
|
{getStatusBadge(order.status)}
|
||||||
|
|
||||||
{/* Кнопки действий для логистики */}
|
{/* Кнопки действий для логистики */}
|
||||||
{order.status === "SUPPLIER_APPROVED" && (
|
{(order.status === "SUPPLIER_APPROVED" ||
|
||||||
|
order.status === "CONFIRMED") && (
|
||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center space-x-2">
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
@ -490,7 +552,8 @@ export function LogisticsOrdersDashboard() {
|
|||||||
</h4>
|
</h4>
|
||||||
<div className="bg-white/5 rounded p-3">
|
<div className="bg-white/5 rounded p-3">
|
||||||
<p className="text-white">
|
<p className="text-white">
|
||||||
{order.organization.name || order.organization.fullName}
|
{order.organization.name ||
|
||||||
|
order.organization.fullName}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</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";
|
"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 { Sidebar } from "@/components/dashboard/sidebar";
|
||||||
import { useSidebar } from "@/hooks/useSidebar";
|
import { useSidebar } from "@/hooks/useSidebar";
|
||||||
import { useAuth } from "@/hooks/useAuth";
|
import { SupplierOrdersTabs } from "./supplier-orders-tabs";
|
||||||
import { GET_SUPPLY_ORDERS } from "@/graphql/queries";
|
import { Package, AlertTriangle } from "lucide-react";
|
||||||
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;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
}>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function SupplierOrdersDashboard() {
|
export function SupplierOrdersDashboard() {
|
||||||
const { getSidebarMargin } = useSidebar();
|
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 (
|
return (
|
||||||
<div className="h-screen flex overflow-hidden">
|
<div className="h-screen flex overflow-hidden">
|
||||||
<Sidebar />
|
<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-1 overflow-y-auto space-y-6">
|
||||||
{/* Заголовок */}
|
{/* Заголовок страницы */}
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold text-white mb-2">
|
<h1 className="text-2xl font-bold text-white mb-2">Заявки</h1>
|
||||||
Заказы поставок
|
|
||||||
</h1>
|
|
||||||
<p className="text-white/60">
|
<p className="text-white/60">
|
||||||
Управление входящими заказами от фулфилмент-центров
|
Управление входящими заявками от заказчиков согласно правилам
|
||||||
|
системы
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Статистика */}
|
{/* Основной интерфейс заявок */}
|
||||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
<SupplierOrdersTabs />
|
||||||
<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>
|
||||||
<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>
|
|
||||||
</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>
|
</main>
|
||||||
</div>
|
</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 { useQuery, useMutation } from "@apollo/client";
|
||||||
import { Sidebar } from "@/components/dashboard/sidebar";
|
import { Sidebar } from "@/components/dashboard/sidebar";
|
||||||
import { useSidebar } from "@/hooks/useSidebar";
|
import { useSidebar } from "@/hooks/useSidebar";
|
||||||
|
import { useAuth } from "@/hooks/useAuth";
|
||||||
import { Card } from "@/components/ui/card";
|
import { Card } from "@/components/ui/card";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
@ -77,6 +78,7 @@ interface SelectedConsumable {
|
|||||||
|
|
||||||
export function CreateConsumablesSupplyPage() {
|
export function CreateConsumablesSupplyPage() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const { user } = useAuth();
|
||||||
const { getSidebarMargin } = useSidebar();
|
const { getSidebarMargin } = useSidebar();
|
||||||
const [selectedSupplier, setSelectedSupplier] =
|
const [selectedSupplier, setSelectedSupplier] =
|
||||||
useState<ConsumableSupplier | null>(null);
|
useState<ConsumableSupplier | null>(null);
|
||||||
@ -88,6 +90,8 @@ export function CreateConsumablesSupplyPage() {
|
|||||||
const [deliveryDate, setDeliveryDate] = useState("");
|
const [deliveryDate, setDeliveryDate] = useState("");
|
||||||
const [selectedFulfillmentCenter, setSelectedFulfillmentCenter] =
|
const [selectedFulfillmentCenter, setSelectedFulfillmentCenter] =
|
||||||
useState<ConsumableSupplier | null>(null);
|
useState<ConsumableSupplier | null>(null);
|
||||||
|
const [selectedLogistics, setSelectedLogistics] =
|
||||||
|
useState<ConsumableSupplier | null>(null);
|
||||||
const [isCreatingSupply, setIsCreatingSupply] = useState(false);
|
const [isCreatingSupply, setIsCreatingSupply] = useState(false);
|
||||||
|
|
||||||
// Загружаем контрагентов-поставщиков расходников
|
// Загружаем контрагентов-поставщиков расходников
|
||||||
@ -117,6 +121,11 @@ export function CreateConsumablesSupplyPage() {
|
|||||||
counterpartiesData?.myCounterparties || []
|
counterpartiesData?.myCounterparties || []
|
||||||
).filter((org: ConsumableSupplier) => org.type === "FULFILLMENT");
|
).filter((org: ConsumableSupplier) => org.type === "FULFILLMENT");
|
||||||
|
|
||||||
|
// Фильтруем логистические компании
|
||||||
|
const logisticsPartners = (counterpartiesData?.myCounterparties || []).filter(
|
||||||
|
(org: ConsumableSupplier) => org.type === "LOGIST"
|
||||||
|
);
|
||||||
|
|
||||||
// Фильтруем поставщиков по поисковому запросу
|
// Фильтруем поставщиков по поисковому запросу
|
||||||
const filteredSuppliers = consumableSuppliers.filter(
|
const filteredSuppliers = consumableSuppliers.filter(
|
||||||
(supplier: ConsumableSupplier) =>
|
(supplier: ConsumableSupplier) =>
|
||||||
@ -218,19 +227,82 @@ export function CreateConsumablesSupplyPage() {
|
|||||||
selectedConsumables.length === 0 ||
|
selectedConsumables.length === 0 ||
|
||||||
!deliveryDate
|
!deliveryDate
|
||||||
) {
|
) {
|
||||||
toast.error("Заполните все обязательные поля");
|
toast.error(
|
||||||
|
"Заполните все обязательные поля: поставщик, расходники и дата доставки"
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Для селлеров требуется выбор фулфилмент-центра
|
// Для селлеров требуется выбор фулфилмент-центра
|
||||||
// TODO: Добавить проверку типа текущей организации
|
|
||||||
if (!selectedFulfillmentCenter) {
|
if (!selectedFulfillmentCenter) {
|
||||||
toast.error("Выберите фулфилмент-центр для доставки");
|
toast.error("Выберите фулфилмент-центр для доставки");
|
||||||
return;
|
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);
|
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 {
|
try {
|
||||||
const result = await createSupplyOrder({
|
const result = await createSupplyOrder({
|
||||||
variables: {
|
variables: {
|
||||||
@ -238,6 +310,12 @@ export function CreateConsumablesSupplyPage() {
|
|||||||
partnerId: selectedSupplier.id,
|
partnerId: selectedSupplier.id,
|
||||||
deliveryDate: deliveryDate,
|
deliveryDate: deliveryDate,
|
||||||
fulfillmentCenterId: selectedFulfillmentCenter.id,
|
fulfillmentCenterId: selectedFulfillmentCenter.id,
|
||||||
|
// 🔄 ЛОГИСТИКА ОПЦИОНАЛЬНА: селлер может выбрать или оставить фулфилменту
|
||||||
|
...(selectedLogistics?.id
|
||||||
|
? { logisticsPartnerId: selectedLogistics.id }
|
||||||
|
: {}),
|
||||||
|
// 🏷️ КЛАССИФИКАЦИЯ согласно правилам (раздел 2.2)
|
||||||
|
consumableType: "SELLER_CONSUMABLES", // Расходники селлеров
|
||||||
items: selectedConsumables.map((consumable) => ({
|
items: selectedConsumables.map((consumable) => ({
|
||||||
productId: consumable.id,
|
productId: consumable.id,
|
||||||
quantity: consumable.selectedQuantity,
|
quantity: consumable.selectedQuantity,
|
||||||
@ -270,7 +348,21 @@ export function CreateConsumablesSupplyPage() {
|
|||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error creating consumables supply:", error);
|
console.error("Error creating consumables supply:", 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("Ошибка при создании поставки расходников");
|
toast.error("Ошибка при создании поставки расходников");
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
setIsCreatingSupply(false);
|
setIsCreatingSupply(false);
|
||||||
}
|
}
|
||||||
@ -764,7 +856,7 @@ export function CreateConsumablesSupplyPage() {
|
|||||||
);
|
);
|
||||||
setSelectedFulfillmentCenter(center || null);
|
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
|
required
|
||||||
>
|
>
|
||||||
<option value="" className="bg-gray-800 text-white">
|
<option value="" className="bg-gray-800 text-white">
|
||||||
@ -782,8 +874,73 @@ export function CreateConsumablesSupplyPage() {
|
|||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</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>
|
||||||
|
</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">
|
<div className="mb-3">
|
||||||
<label className="text-white/60 text-xs mb-1 block">
|
<label className="text-white/60 text-xs mb-1 block">
|
||||||
Дата поставки:
|
Дата поставки:
|
||||||
|
@ -42,7 +42,15 @@ interface SupplyOrderItem {
|
|||||||
interface SupplyOrder {
|
interface SupplyOrder {
|
||||||
id: string;
|
id: string;
|
||||||
deliveryDate: string;
|
deliveryDate: string;
|
||||||
status: "PENDING" | "CONFIRMED" | "IN_TRANSIT" | "DELIVERED" | "CANCELLED";
|
status:
|
||||||
|
| "PENDING"
|
||||||
|
| "SUPPLIER_APPROVED"
|
||||||
|
| "CONFIRMED"
|
||||||
|
| "LOGISTICS_CONFIRMED"
|
||||||
|
| "SHIPPED"
|
||||||
|
| "IN_TRANSIT"
|
||||||
|
| "DELIVERED"
|
||||||
|
| "CANCELLED";
|
||||||
totalAmount: number;
|
totalAmount: number;
|
||||||
totalItems: number;
|
totalItems: number;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
@ -102,10 +110,22 @@ export function SellerSupplyOrdersTab() {
|
|||||||
label: "Ожидает одобрения",
|
label: "Ожидает одобрения",
|
||||||
color: "bg-blue-500/20 text-blue-300 border-blue-500/30",
|
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: {
|
CONFIRMED: {
|
||||||
label: "Одобрена",
|
label: "Подтверждена",
|
||||||
color: "bg-green-500/20 text-green-300 border-green-500/30",
|
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: {
|
IN_TRANSIT: {
|
||||||
label: "В пути",
|
label: "В пути",
|
||||||
color: "bg-yellow-500/20 text-yellow-300 border-yellow-500/30",
|
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",
|
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>;
|
return <Badge className={`${color} border text-xs`}>{label}</Badge>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -42,7 +42,27 @@ export function SuppliesDashboard() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const pendingCount = pendingData?.pendingSuppliesCount;
|
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(() => {
|
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">
|
<Alert className="mb-4 bg-blue-500/20 border-blue-400/30 text-blue-300 animate-pulse">
|
||||||
<AlertTriangle className="h-4 w-4" />
|
<AlertTriangle className="h-4 w-4" />
|
||||||
<AlertDescription>
|
<AlertDescription>
|
||||||
У вас есть {pendingCount.total} элемент
|
{(() => {
|
||||||
{pendingCount.total > 1
|
switch (user?.organization?.type) {
|
||||||
? pendingCount.total < 5
|
case "WHOLESALE":
|
||||||
? "а"
|
const orders = pendingCount.incomingSupplierOrders || 0;
|
||||||
: "ов"
|
return `У вас ${orders} входящ${
|
||||||
: ""}
|
orders > 1 ? (orders < 5 ? "их" : "их") : "ий"
|
||||||
, требующ{pendingCount.total > 1 ? "их" : "ий"} одобрения:
|
} заказ${
|
||||||
{pendingCount.supplyOrders > 0 &&
|
orders > 1 ? (orders < 5 ? "а" : "ов") : ""
|
||||||
` ${pendingCount.supplyOrders} заказ${
|
} от клиентов, ожидающ${
|
||||||
pendingCount.supplyOrders > 1
|
orders > 1 ? "их" : "ий"
|
||||||
? pendingCount.supplyOrders < 5
|
} подтверждения`;
|
||||||
? "а"
|
case "FULFILLMENT":
|
||||||
: "ов"
|
const supplies = pendingCount.supplyOrders || 0;
|
||||||
: ""
|
return `У вас ${supplies} поставк${
|
||||||
} поставок`}
|
supplies > 1 ? (supplies < 5 ? "и" : "ов") : "а"
|
||||||
{pendingCount.incomingRequests > 0 &&
|
} к обработке`;
|
||||||
pendingCount.supplyOrders > 0 &&
|
case "LOGIST":
|
||||||
", "}
|
const logistics = pendingCount.logisticsOrders || 0;
|
||||||
{pendingCount.incomingRequests > 0 &&
|
return `У вас ${logistics} логистическ${
|
||||||
` ${pendingCount.incomingRequests} заявк${
|
logistics > 1 ? (logistics < 5 ? "их" : "их") : "ая"
|
||||||
pendingCount.incomingRequests > 1
|
} заявк${
|
||||||
? pendingCount.incomingRequests < 5
|
logistics > 1 ? (logistics < 5 ? "и" : "и") : "а"
|
||||||
? "и"
|
} к подтверждению`;
|
||||||
: ""
|
default:
|
||||||
: "а"
|
return `У вас есть элементы, требующие внимания`;
|
||||||
} на партнерство`}
|
}
|
||||||
|
})()}
|
||||||
</AlertDescription>
|
</AlertDescription>
|
||||||
</Alert>
|
</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`
|
export const CREATE_LOGISTICS = gql`
|
||||||
mutation CreateLogistics($input: LogisticsInput!) {
|
mutation CreateLogistics($input: LogisticsInput!) {
|
||||||
@ -924,8 +960,16 @@ export const RELEASE_PRODUCT_RESERVE = gql`
|
|||||||
|
|
||||||
// Мутация для обновления статуса "в пути"
|
// Мутация для обновления статуса "в пути"
|
||||||
export const UPDATE_PRODUCT_IN_TRANSIT = gql`
|
export const UPDATE_PRODUCT_IN_TRANSIT = gql`
|
||||||
mutation UpdateProductInTransit($productId: ID!, $quantity: Int!, $operation: String!) {
|
mutation UpdateProductInTransit(
|
||||||
updateProductInTransit(productId: $productId, quantity: $quantity, operation: $operation) {
|
$productId: ID!
|
||||||
|
$quantity: Int!
|
||||||
|
$operation: String!
|
||||||
|
) {
|
||||||
|
updateProductInTransit(
|
||||||
|
productId: $productId
|
||||||
|
quantity: $quantity
|
||||||
|
operation: $operation
|
||||||
|
) {
|
||||||
success
|
success
|
||||||
message
|
message
|
||||||
product {
|
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`
|
export const GET_MY_LOGISTICS = gql`
|
||||||
query GetMyLogistics {
|
query GetMyLogistics {
|
||||||
myLogistics {
|
myLogistics {
|
||||||
@ -122,6 +160,25 @@ export const GET_MY_LOGISTICS = gql`
|
|||||||
description
|
description
|
||||||
createdAt
|
createdAt
|
||||||
updatedAt
|
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
|
firstName
|
||||||
lastName
|
lastName
|
||||||
middleName
|
middleName
|
||||||
|
fullName
|
||||||
|
name
|
||||||
birthDate
|
birthDate
|
||||||
avatar
|
avatar
|
||||||
passportSeries
|
passportSeries
|
||||||
@ -927,6 +986,7 @@ export const GET_SUPPLY_ORDERS = gql`
|
|||||||
supplyOrders {
|
supplyOrders {
|
||||||
id
|
id
|
||||||
organizationId
|
organizationId
|
||||||
|
partnerId
|
||||||
deliveryDate
|
deliveryDate
|
||||||
status
|
status
|
||||||
totalAmount
|
totalAmount
|
||||||
@ -1023,11 +1083,7 @@ export const GET_SELLER_STATS_CACHE = gql`
|
|||||||
$dateFrom: String
|
$dateFrom: String
|
||||||
$dateTo: String
|
$dateTo: String
|
||||||
) {
|
) {
|
||||||
getSellerStatsCache(
|
getSellerStatsCache(period: $period, dateFrom: $dateFrom, dateTo: $dateTo) {
|
||||||
period: $period
|
|
||||||
dateFrom: $dateFrom
|
|
||||||
dateTo: $dateTo
|
|
||||||
) {
|
|
||||||
success
|
success
|
||||||
message
|
message
|
||||||
fromCache
|
fromCache
|
||||||
|
@ -900,7 +900,7 @@ export const resolvers = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Возвращаем заказы где текущая организация является заказчиком, поставщиком, получателем или логистическим партнером
|
// Возвращаем заказы где текущая организация является заказчиком, поставщиком, получателем или логистическим партнером
|
||||||
return await prisma.supplyOrder.findMany({
|
const orders = await prisma.supplyOrder.findMany({
|
||||||
where: {
|
where: {
|
||||||
OR: [
|
OR: [
|
||||||
{ organizationId: currentUser.organization.id }, // Заказы созданные организацией
|
{ organizationId: currentUser.organization.id }, // Заказы созданные организацией
|
||||||
@ -939,6 +939,8 @@ export const resolvers = {
|
|||||||
},
|
},
|
||||||
orderBy: { createdAt: "desc" },
|
orderBy: { createdAt: "desc" },
|
||||||
});
|
});
|
||||||
|
|
||||||
|
return orders;
|
||||||
},
|
},
|
||||||
|
|
||||||
// Счетчик поставок, требующих одобрения
|
// Счетчик поставок, требующих одобрения
|
||||||
@ -969,12 +971,17 @@ export const resolvers = {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// Расходники селлеров (созданные другими для нас) - требуют подтверждения получения
|
// Расходники селлеров (созданные другими для нас) - требуют действий фулфилмента
|
||||||
const sellerSupplyOrders = await prisma.supplyOrder.count({
|
const sellerSupplyOrders = await prisma.supplyOrder.count({
|
||||||
where: {
|
where: {
|
||||||
fulfillmentCenterId: currentUser.organization.id, // Получатель - мы
|
fulfillmentCenterId: currentUser.organization.id, // Получатель - мы
|
||||||
organizationId: { not: currentUser.organization.id }, // Создали НЕ мы
|
organizationId: { not: currentUser.organization.id }, // Создали НЕ мы
|
||||||
status: "IN_TRANSIT", // В пути - нужно подтвердить получение
|
status: {
|
||||||
|
in: [
|
||||||
|
"SUPPLIER_APPROVED", // Поставщик подтвердил - нужно назначить логистику
|
||||||
|
"IN_TRANSIT", // В пути - нужно подтвердить получение
|
||||||
|
],
|
||||||
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -986,9 +993,30 @@ export const resolvers = {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// Общий счетчик поставок
|
// 🚚 ЛОГИСТИЧЕСКИЕ ЗАЯВКИ ДЛЯ ЛОГИСТИКИ (LOGIST) - требуют действий логистики
|
||||||
const pendingSupplyOrders =
|
const logisticsOrders = await prisma.supplyOrder.count({
|
||||||
ourSupplyOrders + sellerSupplyOrders + incomingSupplierOrders;
|
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
|
// Считаем входящие заявки на партнерство со статусом PENDING
|
||||||
const pendingIncomingRequests = await prisma.counterpartyRequest.count({
|
const pendingIncomingRequests = await prisma.counterpartyRequest.count({
|
||||||
@ -1003,6 +1031,7 @@ export const resolvers = {
|
|||||||
ourSupplyOrders: ourSupplyOrders, // Расходники фулфилмента
|
ourSupplyOrders: ourSupplyOrders, // Расходники фулфилмента
|
||||||
sellerSupplyOrders: sellerSupplyOrders, // Расходники селлеров
|
sellerSupplyOrders: sellerSupplyOrders, // Расходники селлеров
|
||||||
incomingSupplierOrders: incomingSupplierOrders, // 🔔 Входящие заказы для поставщиков
|
incomingSupplierOrders: incomingSupplierOrders, // 🔔 Входящие заказы для поставщиков
|
||||||
|
logisticsOrders: logisticsOrders, // 🚚 Логистические заявки для логистики
|
||||||
incomingRequests: pendingIncomingRequests,
|
incomingRequests: pendingIncomingRequests,
|
||||||
total: pendingSupplyOrders + pendingIncomingRequests,
|
total: pendingSupplyOrders + pendingIncomingRequests,
|
||||||
};
|
};
|
||||||
@ -1146,9 +1175,11 @@ export const resolvers = {
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Подсчитываем количество из таблицы Supply (актуальные остатки на складе фулфилмента)
|
// Подсчитываем количество из таблицы Supply (актуальные остатки на складе фулфилмента)
|
||||||
|
// ИСПРАВЛЕНО: считаем только расходники фулфилмента, исключаем расходники селлеров
|
||||||
const fulfillmentSuppliesFromWarehouse = await prisma.supply.findMany({
|
const fulfillmentSuppliesFromWarehouse = await prisma.supply.findMany({
|
||||||
where: {
|
where: {
|
||||||
organizationId: organizationId, // Склад фулфилмента
|
organizationId: organizationId, // Склад фулфилмента
|
||||||
|
type: "FULFILLMENT_CONSUMABLES", // ТОЛЬКО расходники фулфилмента
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -1203,39 +1234,40 @@ export const resolvers = {
|
|||||||
`📊 FULFILLMENT SUPPLIES RECEIVED TODAY (ПРИБЫЛО): ${fulfillmentSuppliesReceivedToday.length} orders, ${fulfillmentSuppliesChangeToday} items`
|
`📊 FULFILLMENT SUPPLIES RECEIVED TODAY (ПРИБЫЛО): ${fulfillmentSuppliesReceivedToday.length} orders, ${fulfillmentSuppliesChangeToday} items`
|
||||||
);
|
);
|
||||||
|
|
||||||
// Расходники селлеров - получаем из заказов от селлеров (расходники = CONSUMABLE)
|
// Расходники селлеров - получаем из таблицы Supply (актуальные остатки на складе фулфилмента)
|
||||||
// Согласно правилам: селлеры заказывают расходники у поставщиков и доставляют на склад фулфилмента
|
// ИСПРАВЛЕНО: считаем из Supply с типом SELLER_CONSUMABLES
|
||||||
const sellerSuppliesCount = sellerDeliveredOrders.reduce(
|
const sellerSuppliesFromWarehouse = await prisma.supply.findMany({
|
||||||
(sum, order) =>
|
where: {
|
||||||
sum +
|
organizationId: organizationId, // Склад фулфилмента
|
||||||
order.items.reduce(
|
type: "SELLER_CONSUMABLES", // ТОЛЬКО расходники селлеров
|
||||||
(itemSum, item) =>
|
},
|
||||||
itemSum +
|
});
|
||||||
(item.product.type === "CONSUMABLE" ? item.quantity : 0),
|
|
||||||
0
|
const sellerSuppliesCount = sellerSuppliesFromWarehouse.reduce(
|
||||||
),
|
(sum, supply) => sum + (supply.currentStock || 0),
|
||||||
0
|
0
|
||||||
);
|
);
|
||||||
|
|
||||||
console.log(
|
console.log(
|
||||||
`💼 SELLER SUPPLIES DEBUG: totalCount=${sellerSuppliesCount} (from delivered orders)`
|
`💼 SELLER SUPPLIES DEBUG: totalCount=${sellerSuppliesCount} (from Supply warehouse)`
|
||||||
);
|
);
|
||||||
|
|
||||||
// Изменения расходников селлеров за сутки - используем уже полученные данные
|
// Изменения расходников селлеров за сутки - считаем из Supply записей, созданных за сутки
|
||||||
const sellerSuppliesChangeToday = recentSellerDeliveredOrders.reduce(
|
const sellerSuppliesReceivedToday = await prisma.supply.findMany({
|
||||||
(sum, order) =>
|
where: {
|
||||||
sum +
|
organizationId: organizationId, // Склад фулфилмента
|
||||||
order.items.reduce(
|
type: "SELLER_CONSUMABLES", // ТОЛЬКО расходники селлеров
|
||||||
(itemSum, item) =>
|
createdAt: { gte: oneDayAgo }, // Созданы за последние сутки
|
||||||
itemSum +
|
},
|
||||||
(item.product.type === "CONSUMABLE" ? item.quantity : 0),
|
});
|
||||||
0
|
|
||||||
),
|
const sellerSuppliesChangeToday = sellerSuppliesReceivedToday.reduce(
|
||||||
|
(sum, supply) => sum + (supply.currentStock || 0),
|
||||||
0
|
0
|
||||||
);
|
);
|
||||||
|
|
||||||
console.log(
|
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
|
// Мои поставки Wildberries
|
||||||
myWildberriesSupplies: async (
|
myWildberriesSupplies: async (
|
||||||
_: unknown,
|
_: 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) => {
|
myProducts: async (_: unknown, __: unknown, context: Context) => {
|
||||||
console.log("🔍 MY_PRODUCTS RESOLVER - ВЫЗВАН:", {
|
console.log("🔍 MY_PRODUCTS RESOLVER - ВЫЗВАН:", {
|
||||||
@ -1830,22 +1968,35 @@ export const resolvers = {
|
|||||||
|
|
||||||
// Сотрудники организации
|
// Сотрудники организации
|
||||||
myEmployees: async (_: unknown, __: unknown, context: Context) => {
|
myEmployees: async (_: unknown, __: unknown, context: Context) => {
|
||||||
|
console.log("🔍 myEmployees resolver called");
|
||||||
|
|
||||||
if (!context.user) {
|
if (!context.user) {
|
||||||
|
console.log("❌ No user in context for myEmployees");
|
||||||
throw new GraphQLError("Требуется авторизация", {
|
throw new GraphQLError("Требуется авторизация", {
|
||||||
extensions: { code: "UNAUTHENTICATED" },
|
extensions: { code: "UNAUTHENTICATED" },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
console.log("✅ User authenticated for myEmployees:", context.user.id);
|
||||||
|
|
||||||
|
try {
|
||||||
const currentUser = await prisma.user.findUnique({
|
const currentUser = await prisma.user.findUnique({
|
||||||
where: { id: context.user.id },
|
where: { id: context.user.id },
|
||||||
include: { organization: true },
|
include: { organization: true },
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!currentUser?.organization) {
|
if (!currentUser?.organization) {
|
||||||
|
console.log("❌ User has no organization");
|
||||||
throw new GraphQLError("У пользователя нет организации");
|
throw new GraphQLError("У пользователя нет организации");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
"📊 User organization type:",
|
||||||
|
currentUser.organization.type
|
||||||
|
);
|
||||||
|
|
||||||
if (currentUser.organization.type !== "FULFILLMENT") {
|
if (currentUser.organization.type !== "FULFILLMENT") {
|
||||||
|
console.log("❌ Not a fulfillment center");
|
||||||
throw new GraphQLError("Доступно только для фулфилмент центров");
|
throw new GraphQLError("Доступно только для фулфилмент центров");
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1857,7 +2008,12 @@ export const resolvers = {
|
|||||||
orderBy: { createdAt: "desc" },
|
orderBy: { createdAt: "desc" },
|
||||||
});
|
});
|
||||||
|
|
||||||
|
console.log("👥 Found employees:", employees.length);
|
||||||
return employees;
|
return employees;
|
||||||
|
} catch (error) {
|
||||||
|
console.error("❌ Error in myEmployees resolver:", error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
// Получение сотрудника по ID
|
// Получение сотрудника по ID
|
||||||
@ -3937,7 +4093,19 @@ export const resolvers = {
|
|||||||
include: { organization: true },
|
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("У пользователя нет организации");
|
throw new GraphQLError("У пользователя нет организации");
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -4067,21 +4235,34 @@ export const resolvers = {
|
|||||||
initialStatus = "CONFIRMED"; // Логист может сразу подтверждать заказы
|
initialStatus = "CONFIRMED"; // Логист может сразу подтверждать заказы
|
||||||
}
|
}
|
||||||
|
|
||||||
const supplyOrder = await prisma.supplyOrder.create({
|
// Подготавливаем данные для создания заказа
|
||||||
data: {
|
const createData: any = {
|
||||||
partnerId: args.input.partnerId,
|
partnerId: args.input.partnerId,
|
||||||
deliveryDate: new Date(args.input.deliveryDate),
|
deliveryDate: new Date(args.input.deliveryDate),
|
||||||
totalAmount: new Prisma.Decimal(totalAmount),
|
totalAmount: new Prisma.Decimal(totalAmount),
|
||||||
totalItems: totalItems,
|
totalItems: totalItems,
|
||||||
organizationId: currentUser.organization.id,
|
organizationId: currentUser.organization.id,
|
||||||
fulfillmentCenterId: fulfillmentCenterId,
|
fulfillmentCenterId: fulfillmentCenterId,
|
||||||
logisticsPartnerId: args.input.logisticsPartnerId,
|
consumableType: args.input.consumableType,
|
||||||
consumableType: args.input.consumableType, // Классификация расходников
|
|
||||||
status: initialStatus,
|
status: initialStatus,
|
||||||
items: {
|
items: {
|
||||||
create: orderItems,
|
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: {
|
include: {
|
||||||
partner: {
|
partner: {
|
||||||
include: {
|
include: {
|
||||||
@ -5961,17 +6142,13 @@ export const resolvers = {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (product) {
|
if (product) {
|
||||||
// Согласно правилам: Основные значения = Предыдущие остатки + Прибыло - Убыло
|
// ИСПРАВЛЕНО: НЕ списываем повторно, только переводим из inTransit в sold
|
||||||
const currentStock = product.stock || product.quantity || 0;
|
// Остаток уже был уменьшен при создании/одобрении заказа
|
||||||
const newStock = Math.max(currentStock - item.quantity, 0);
|
|
||||||
|
|
||||||
await prisma.product.update({
|
await prisma.product.update({
|
||||||
where: { id: item.product.id },
|
where: { id: item.product.id },
|
||||||
data: {
|
data: {
|
||||||
// Обновляем основные остатки (УБЫЛО)
|
// НЕ ТРОГАЕМ stock - он уже правильно уменьшен при заказе
|
||||||
stock: newStock,
|
// Только переводим из inTransit в sold
|
||||||
quantity: newStock, // Синхронизируем оба поля для совместимости
|
|
||||||
// Обновляем дополнительные значения
|
|
||||||
inTransit: Math.max(
|
inTransit: Math.max(
|
||||||
(product.inTransit || 0) - item.quantity,
|
(product.inTransit || 0) - item.quantity,
|
||||||
0
|
0
|
||||||
@ -5980,7 +6157,11 @@ export const resolvers = {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
console.log(
|
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 (
|
supplierApproveOrder: async (
|
||||||
_: unknown,
|
_: unknown,
|
||||||
@ -6458,7 +6750,7 @@ export const resolvers = {
|
|||||||
where: {
|
where: {
|
||||||
id: args.id,
|
id: args.id,
|
||||||
logisticsPartnerId: currentUser.organization.id,
|
logisticsPartnerId: currentUser.organization.id,
|
||||||
status: "SUPPLIER_APPROVED",
|
OR: [{ status: "SUPPLIER_APPROVED" }, { status: "CONFIRMED" }],
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -6530,7 +6822,7 @@ export const resolvers = {
|
|||||||
where: {
|
where: {
|
||||||
id: args.id,
|
id: args.id,
|
||||||
logisticsPartnerId: currentUser.organization.id,
|
logisticsPartnerId: currentUser.organization.id,
|
||||||
status: "SUPPLIER_APPROVED",
|
OR: [{ status: "SUPPLIER_APPROVED" }, { status: "CONFIRMED" }],
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -6608,10 +6900,16 @@ export const resolvers = {
|
|||||||
include: {
|
include: {
|
||||||
items: {
|
items: {
|
||||||
include: {
|
include: {
|
||||||
product: true,
|
product: {
|
||||||
|
include: {
|
||||||
|
category: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
},
|
||||||
|
organization: true, // Селлер-создатель заказа
|
||||||
|
partner: true, // Поставщик
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!existingOrder) {
|
if (!existingOrder) {
|
||||||
@ -6651,17 +6949,13 @@ export const resolvers = {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (product) {
|
if (product) {
|
||||||
// Согласно правилам: Основные значения = Предыдущие остатки + Прибыло - Убыло
|
// ИСПРАВЛЕНО: НЕ списываем повторно, только переводим из inTransit в sold
|
||||||
const currentStock = product.stock || product.quantity || 0;
|
// Остаток уже был уменьшен при создании/одобрении заказа
|
||||||
const newStock = Math.max(currentStock - item.quantity, 0);
|
|
||||||
|
|
||||||
await prisma.product.update({
|
await prisma.product.update({
|
||||||
where: { id: item.product.id },
|
where: { id: item.product.id },
|
||||||
data: {
|
data: {
|
||||||
// Обновляем основные остатки (УБЫЛО)
|
// НЕ ТРОГАЕМ stock - он уже правильно уменьшен при заказе
|
||||||
stock: newStock,
|
// Только переводим из inTransit в sold
|
||||||
quantity: newStock, // Синхронизируем оба поля для совместимости
|
|
||||||
// Обновляем дополнительные значения
|
|
||||||
inTransit: Math.max(
|
inTransit: Math.max(
|
||||||
(product.inTransit || 0) - item.quantity,
|
(product.inTransit || 0) - item.quantity,
|
||||||
0
|
0
|
||||||
@ -6670,33 +6964,62 @@ export const resolvers = {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
console.log(
|
console.log(
|
||||||
`✅ Товар поставщика "${product.name}" обновлен: доставлено ${item.quantity} единиц`
|
`✅ Товар поставщика "${product.name}" обновлен: получено ${item.quantity} единиц`
|
||||||
);
|
);
|
||||||
console.log(
|
console.log(
|
||||||
` 📊 Остатки: ${currentStock} -> ${newStock} (УБЫЛО: ${item.quantity})`
|
` 📊 Остаток: ${
|
||||||
|
product.stock || product.quantity || 0
|
||||||
|
} (НЕ ИЗМЕНЕН - уже списан при заказе)`
|
||||||
);
|
);
|
||||||
console.log(
|
console.log(
|
||||||
` 🚚 В пути: ${product.inTransit || 0} -> ${Math.max(
|
` 🚚 В пути: ${product.inTransit || 0} -> ${Math.max(
|
||||||
(product.inTransit || 0) - item.quantity,
|
(product.inTransit || 0) - item.quantity,
|
||||||
0
|
0
|
||||||
)}`
|
)} (УБЫЛО: ${item.quantity})`
|
||||||
);
|
);
|
||||||
console.log(
|
console.log(
|
||||||
` 💰 Продано: ${product.sold || 0} -> ${
|
` 💰 Продано: ${product.sold || 0} -> ${
|
||||||
(product.sold || 0) + item.quantity
|
(product.sold || 0) + item.quantity
|
||||||
}`
|
} (ПРИБЫЛО: ${item.quantity})`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Обновляем склад фулфилмента
|
// Обновляем склад фулфилмента с учетом типа расходников
|
||||||
console.log("📦 Обновляем склад фулфилмента...");
|
console.log("📦 Обновляем склад фулфилмента...");
|
||||||
|
console.log(
|
||||||
|
`🏷️ Тип поставки: ${
|
||||||
|
existingOrder.consumableType || "FULFILLMENT_CONSUMABLES"
|
||||||
|
}`
|
||||||
|
);
|
||||||
|
|
||||||
for (const item of existingOrder.items) {
|
for (const item of existingOrder.items) {
|
||||||
const existingSupply = await prisma.supply.findFirst({
|
// Определяем тип расходников и владельца
|
||||||
where: {
|
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,
|
organizationId: currentUser.organization.id,
|
||||||
name: item.product.name,
|
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: whereCondition,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (existingSupply) {
|
if (existingSupply) {
|
||||||
@ -6709,9 +7032,13 @@ export const resolvers = {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
console.log(
|
console.log(
|
||||||
`📈 Обновлен существующий расходник фулфилмента "${
|
`📈 Обновлен существующий ${
|
||||||
item.product.name
|
isSellerSupply ? "расходник селлера" : "расходник фулфилмента"
|
||||||
}": ${existingSupply.currentStock} -> ${
|
} "${item.product.name}" ${
|
||||||
|
isSellerSupply
|
||||||
|
? `(владелец: ${updatedOrder.organization?.name})`
|
||||||
|
: ""
|
||||||
|
}: ${existingSupply.currentStock} -> ${
|
||||||
existingSupply.currentStock + item.quantity
|
existingSupply.currentStock + item.quantity
|
||||||
}`
|
}`
|
||||||
);
|
);
|
||||||
@ -6719,8 +7046,12 @@ export const resolvers = {
|
|||||||
await prisma.supply.create({
|
await prisma.supply.create({
|
||||||
data: {
|
data: {
|
||||||
name: item.product.name,
|
name: item.product.name,
|
||||||
description:
|
description: isSellerSupply
|
||||||
item.product.description ||
|
? `Расходники селлера ${
|
||||||
|
updatedOrder.organization?.name ||
|
||||||
|
updatedOrder.organization?.fullName
|
||||||
|
}`
|
||||||
|
: item.product.description ||
|
||||||
`Расходники от ${updatedOrder.partner.name}`,
|
`Расходники от ${updatedOrder.partner.name}`,
|
||||||
price: item.price,
|
price: item.price,
|
||||||
quantity: item.quantity,
|
quantity: item.quantity,
|
||||||
@ -6733,11 +7064,21 @@ export const resolvers = {
|
|||||||
updatedOrder.partner.name ||
|
updatedOrder.partner.name ||
|
||||||
updatedOrder.partner.fullName ||
|
updatedOrder.partner.fullName ||
|
||||||
"Поставщик",
|
"Поставщик",
|
||||||
|
type: supplyType as
|
||||||
|
| "SELLER_CONSUMABLES"
|
||||||
|
| "FULFILLMENT_CONSUMABLES",
|
||||||
|
sellerOwnerId: sellerOwnerId,
|
||||||
organizationId: currentUser.organization.id,
|
organizationId: currentUser.organization.id,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
console.log(
|
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({
|
return await prisma.supply.findMany({
|
||||||
where: { organizationId: parent.id },
|
where: { organizationId: parent.id },
|
||||||
include: { organization: true },
|
include: {
|
||||||
|
organization: true,
|
||||||
|
sellerOwner: true, // Включаем информацию о селлере-владельце
|
||||||
|
},
|
||||||
orderBy: { createdAt: "desc" },
|
orderBy: { createdAt: "desc" },
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
@ -6949,6 +7293,20 @@ export const resolvers = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
Employee: {
|
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 }) => {
|
birthDate: (parent: { birthDate?: Date | string | null }) => {
|
||||||
if (!parent.birthDate) return null;
|
if (!parent.birthDate) return null;
|
||||||
if (parent.birthDate instanceof Date) {
|
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!]!
|
myFulfillmentSupplies: [Supply!]!
|
||||||
|
|
||||||
|
# Расходники селлеров на складе фулфилмента (только для фулфилмента)
|
||||||
|
sellerSuppliesOnWarehouse: [Supply!]!
|
||||||
|
|
||||||
# Заказы поставок расходников
|
# Заказы поставок расходников
|
||||||
supplyOrders: [SupplyOrder!]!
|
supplyOrders: [SupplyOrder!]!
|
||||||
|
|
||||||
@ -52,6 +55,9 @@ export const typeDefs = gql`
|
|||||||
# Логистика организации
|
# Логистика организации
|
||||||
myLogistics: [Logistics!]!
|
myLogistics: [Logistics!]!
|
||||||
|
|
||||||
|
# Логистические партнеры (организации-логисты)
|
||||||
|
logisticsPartners: [Organization!]!
|
||||||
|
|
||||||
# Поставки Wildberries
|
# Поставки Wildberries
|
||||||
myWildberriesSupplies: [WildberriesSupply!]!
|
myWildberriesSupplies: [WildberriesSupply!]!
|
||||||
|
|
||||||
@ -207,6 +213,13 @@ export const typeDefs = gql`
|
|||||||
status: SupplyOrderStatus!
|
status: SupplyOrderStatus!
|
||||||
): SupplyOrderResponse!
|
): SupplyOrderResponse!
|
||||||
|
|
||||||
|
# Назначение логистики фулфилментом
|
||||||
|
assignLogisticsToSupply(
|
||||||
|
supplyOrderId: ID!
|
||||||
|
logisticsPartnerId: ID!
|
||||||
|
responsibleId: ID
|
||||||
|
): SupplyOrderResponse!
|
||||||
|
|
||||||
# Действия поставщика
|
# Действия поставщика
|
||||||
supplierApproveOrder(id: ID!): SupplyOrderResponse!
|
supplierApproveOrder(id: ID!): SupplyOrderResponse!
|
||||||
supplierRejectOrder(id: ID!, reason: String): SupplyOrderResponse!
|
supplierRejectOrder(id: ID!, reason: String): SupplyOrderResponse!
|
||||||
@ -230,10 +243,17 @@ export const typeDefs = gql`
|
|||||||
deleteProduct(id: ID!): Boolean!
|
deleteProduct(id: ID!): Boolean!
|
||||||
|
|
||||||
# Валидация и управление остатками товаров
|
# Валидация и управление остатками товаров
|
||||||
checkArticleUniqueness(article: String!, excludeId: ID): ArticleUniquenessResponse!
|
checkArticleUniqueness(
|
||||||
|
article: String!
|
||||||
|
excludeId: ID
|
||||||
|
): ArticleUniquenessResponse!
|
||||||
reserveProductStock(productId: ID!, quantity: Int!): ProductStockResponse!
|
reserveProductStock(productId: ID!, quantity: Int!): ProductStockResponse!
|
||||||
releaseProductReserve(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!
|
createCategory(input: CategoryInput!): CategoryResponse!
|
||||||
@ -535,6 +555,11 @@ export const typeDefs = gql`
|
|||||||
}
|
}
|
||||||
|
|
||||||
# Типы для расходников
|
# Типы для расходников
|
||||||
|
enum SupplyType {
|
||||||
|
FULFILLMENT_CONSUMABLES # Расходники фулфилмента (купленные фулфилментом для себя)
|
||||||
|
SELLER_CONSUMABLES # Расходники селлеров (принятые от селлеров для хранения)
|
||||||
|
}
|
||||||
|
|
||||||
type Supply {
|
type Supply {
|
||||||
id: ID!
|
id: ID!
|
||||||
name: String!
|
name: String!
|
||||||
@ -550,6 +575,9 @@ export const typeDefs = gql`
|
|||||||
currentStock: Int
|
currentStock: Int
|
||||||
usedStock: Int
|
usedStock: Int
|
||||||
imageUrl: String
|
imageUrl: String
|
||||||
|
type: SupplyType!
|
||||||
|
sellerOwner: Organization # Селлер-владелец (для расходников селлеров)
|
||||||
|
shopLocation: String # Местоположение в магазине фулфилмента
|
||||||
createdAt: DateTime!
|
createdAt: DateTime!
|
||||||
updatedAt: DateTime!
|
updatedAt: DateTime!
|
||||||
organization: Organization!
|
organization: Organization!
|
||||||
@ -594,8 +622,8 @@ export const typeDefs = gql`
|
|||||||
totalItems: Int!
|
totalItems: Int!
|
||||||
fulfillmentCenterId: ID
|
fulfillmentCenterId: ID
|
||||||
fulfillmentCenter: Organization
|
fulfillmentCenter: Organization
|
||||||
logisticsPartnerId: ID!
|
logisticsPartnerId: ID
|
||||||
logisticsPartner: Organization!
|
logisticsPartner: Organization
|
||||||
items: [SupplyOrderItem!]!
|
items: [SupplyOrderItem!]!
|
||||||
createdAt: DateTime!
|
createdAt: DateTime!
|
||||||
updatedAt: DateTime!
|
updatedAt: DateTime!
|
||||||
@ -626,7 +654,7 @@ export const typeDefs = gql`
|
|||||||
partnerId: ID!
|
partnerId: ID!
|
||||||
deliveryDate: DateTime!
|
deliveryDate: DateTime!
|
||||||
fulfillmentCenterId: ID # ID фулфилмент-центра для доставки
|
fulfillmentCenterId: ID # ID фулфилмент-центра для доставки
|
||||||
logisticsPartnerId: ID! # ID логистической компании (обязательно)
|
logisticsPartnerId: ID # ID логистической компании (опционально - может выбрать селлер или фулфилмент)
|
||||||
items: [SupplyOrderItemInput!]!
|
items: [SupplyOrderItemInput!]!
|
||||||
notes: String # Дополнительные заметки к заказу
|
notes: String # Дополнительные заметки к заказу
|
||||||
consumableType: String # Классификация расходников: FULFILLMENT_CONSUMABLES, SELLER_CONSUMABLES
|
consumableType: String # Классификация расходников: FULFILLMENT_CONSUMABLES, SELLER_CONSUMABLES
|
||||||
@ -642,6 +670,7 @@ export const typeDefs = gql`
|
|||||||
ourSupplyOrders: Int! # Расходники фулфилмента
|
ourSupplyOrders: Int! # Расходники фулфилмента
|
||||||
sellerSupplyOrders: Int! # Расходники селлеров
|
sellerSupplyOrders: Int! # Расходники селлеров
|
||||||
incomingSupplierOrders: Int! # 🔔 Входящие заказы для поставщиков
|
incomingSupplierOrders: Int! # 🔔 Входящие заказы для поставщиков
|
||||||
|
logisticsOrders: Int! # 🚚 Логистические заявки для логистики
|
||||||
incomingRequests: Int!
|
incomingRequests: Int!
|
||||||
total: Int!
|
total: Int!
|
||||||
}
|
}
|
||||||
@ -819,6 +848,8 @@ export const typeDefs = gql`
|
|||||||
firstName: String!
|
firstName: String!
|
||||||
lastName: String!
|
lastName: String!
|
||||||
middleName: String
|
middleName: String
|
||||||
|
fullName: String
|
||||||
|
name: String
|
||||||
birthDate: DateTime
|
birthDate: DateTime
|
||||||
avatar: String
|
avatar: String
|
||||||
passportPhoto: String
|
passportPhoto: String
|
||||||
|
@ -1,52 +1,79 @@
|
|||||||
import { ApolloClient, InMemoryCache, createHttpLink, from } from '@apollo/client'
|
import {
|
||||||
import { setContext } from '@apollo/client/link/context'
|
ApolloClient,
|
||||||
import { onError } from '@apollo/client/link/error'
|
InMemoryCache,
|
||||||
|
createHttpLink,
|
||||||
|
from,
|
||||||
|
} from "@apollo/client";
|
||||||
|
import { setContext } from "@apollo/client/link/context";
|
||||||
|
import { onError } from "@apollo/client/link/error";
|
||||||
|
|
||||||
// HTTP Link для GraphQL запросов
|
// HTTP Link для GraphQL запросов
|
||||||
const httpLink = createHttpLink({
|
const httpLink = createHttpLink({
|
||||||
uri: '/api/graphql',
|
uri: "/api/graphql",
|
||||||
})
|
});
|
||||||
|
|
||||||
// Auth Link для добавления JWT токена в заголовки
|
// Auth Link для добавления JWT токена в заголовки
|
||||||
const authLink = setContext((operation, { headers }) => {
|
const authLink = setContext((operation, { headers }) => {
|
||||||
if (typeof window === 'undefined') {
|
if (typeof window === "undefined") {
|
||||||
return { headers }
|
return { headers };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Проверяем токены администратора и пользователя
|
// Проверяем токены администратора и пользователя
|
||||||
const adminToken = localStorage.getItem('adminAuthToken')
|
const adminToken = localStorage.getItem("adminAuthToken");
|
||||||
const userToken = localStorage.getItem('authToken')
|
const userToken = localStorage.getItem("authToken");
|
||||||
|
|
||||||
// Приоритет у админского токена
|
// Приоритет у админского токена
|
||||||
const token = adminToken || userToken
|
const token = adminToken || userToken;
|
||||||
const tokenType = adminToken ? 'admin' : 'user'
|
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 = {
|
const authHeaders = {
|
||||||
...headers,
|
...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 {
|
return {
|
||||||
headers: authHeaders
|
headers: authHeaders,
|
||||||
}
|
};
|
||||||
})
|
});
|
||||||
|
|
||||||
// Error Link для обработки ошибок - минимальная версия
|
// Error Link для обработки ошибок с детальным логированием
|
||||||
const errorLink = onError(() => {
|
const errorLink = onError(
|
||||||
// Пустой обработчик - не делаем ничего
|
({ graphQLErrors, networkError, operation, forward }) => {
|
||||||
// Это предотвращает любые ошибки в error handler
|
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
|
// Создаем Apollo Client
|
||||||
export const apolloClient = new ApolloClient({
|
export const apolloClient = new ApolloClient({
|
||||||
link: from([
|
link: from([errorLink, authLink, httpLink]),
|
||||||
authLink,
|
|
||||||
httpLink,
|
|
||||||
]),
|
|
||||||
cache: new InMemoryCache({
|
cache: new InMemoryCache({
|
||||||
typePolicies: {
|
typePolicies: {
|
||||||
User: {
|
User: {
|
||||||
@ -67,45 +94,45 @@ export const apolloClient = new ApolloClient({
|
|||||||
}),
|
}),
|
||||||
defaultOptions: {
|
defaultOptions: {
|
||||||
watchQuery: {
|
watchQuery: {
|
||||||
errorPolicy: 'all',
|
errorPolicy: "all",
|
||||||
},
|
},
|
||||||
query: {
|
query: {
|
||||||
errorPolicy: 'all',
|
errorPolicy: "all",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
})
|
});
|
||||||
|
|
||||||
// Утилитарные функции для работы с токеном и пользователем
|
// Утилитарные функции для работы с токеном и пользователем
|
||||||
export const setAuthToken = (token: string) => {
|
export const setAuthToken = (token: string) => {
|
||||||
if (typeof window !== 'undefined') {
|
if (typeof window !== "undefined") {
|
||||||
localStorage.setItem('authToken', token)
|
localStorage.setItem("authToken", token);
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
|
||||||
export const removeAuthToken = () => {
|
export const removeAuthToken = () => {
|
||||||
if (typeof window !== 'undefined') {
|
if (typeof window !== "undefined") {
|
||||||
localStorage.removeItem('authToken')
|
localStorage.removeItem("authToken");
|
||||||
localStorage.removeItem('userData')
|
localStorage.removeItem("userData");
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
|
||||||
export const getAuthToken = (): string | null => {
|
export const getAuthToken = (): string | null => {
|
||||||
if (typeof window !== 'undefined') {
|
if (typeof window !== "undefined") {
|
||||||
return localStorage.getItem('authToken')
|
return localStorage.getItem("authToken");
|
||||||
}
|
|
||||||
return null
|
|
||||||
}
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
export const setUserData = (userData: unknown) => {
|
export const setUserData = (userData: unknown) => {
|
||||||
if (typeof window !== 'undefined') {
|
if (typeof window !== "undefined") {
|
||||||
localStorage.setItem('userData', JSON.stringify(userData))
|
localStorage.setItem("userData", JSON.stringify(userData));
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
|
||||||
export const getUserData = (): unknown | null => {
|
export const getUserData = (): unknown | null => {
|
||||||
if (typeof window !== 'undefined') {
|
if (typeof window !== "undefined") {
|
||||||
const data = localStorage.getItem('userData')
|
const data = localStorage.getItem("userData");
|
||||||
return data ? JSON.parse(data) : null
|
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